From 6bef92be5b69592a75b771a7cc40392b5185666f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:36:38 +0000 Subject: [PATCH] chore: version packages --- .changeset/10757-request-rounds-core.md | 10 - .../10757-request-rounds-metadata-protocol.md | 10 - ...15212-8b-single-platform-insert-stamped.md | 17 - .../15214-c11-fresh-install-messages.md | 14 - ...elated-list-parent-data-source-rendered.md | 10 - .changeset/24289-hook-ctx-title-doors.md | 9 - ...8-platform-objects-change-slug-to-cloud.md | 21 - .../24318-spec-change-slug-gated-input.md | 19 - .changeset/24321-resume-as-decider.md | 26 - .changeset/24375-approval-object-chip.md | 14 - .changeset/24414-capability-predicate.md | 21 - .../24426-measure-total-hint-stored-number.md | 13 - .changeset/24448-run-context-org-user-ids.md | 19 - ...4450-nav-visible-data-needs-data-source.md | 31 - .changeset/24452-default-row-scope-lint.md | 13 - .changeset/24459-seed-blank-reference.md | 12 - .changeset/24464-doctor-tool-probe-failure.md | 11 - .../24468-lint-rule-hop-undeclared-field.md | 20 - ...468-objectql-rule-hop-sibling-isolation.md | 16 - ...471-extension-guidance-hooks-list-views.md | 13 - .../24472-bound-action-collision-report.md | 14 - .../24486-spec-cloud-adr-0028-citation.md | 10 - ...91-page-save-door-visibility-predicates.md | 29 - .changeset/24497-hook-runas-org-peers.md | 18 - ...98-verify-validation-verdict-whole-body.md | 13 - .changeset/24501-list-object-column.md | 20 - .../24519-approvals-context-identity.md | 14 - .changeset/24523-default-organization-memo.md | 12 - .../24529-declared-webhooks-from-registry.md | 30 - ...4530-settings-audit-global-rung-sibling.md | 20 - .changeset/24531-identity-mail-sibling.md | 23 - .../24534-export-nav-visible-data-check.md | 15 - .changeset/24536-cel-env-reuse.md | 12 - .changeset/24537-findfile-memo.md | 20 - ...46-resume-inputs-variables-both-refused.md | 18 - .changeset/24548-complete-holds-bytes.md | 19 - .changeset/24549-parent-gate-parity.md | 14 - .../24550-sharing-rule-meta-door-closed.md | 28 - .../24561-batch-hook-refusal-sentence.md | 11 - .changeset/24562-seed-replay-time-equality.md | 11 - .changeset/24563-list-view-out-of-closure.md | 36 - .../24564-reminder-action-link-at-rest.md | 12 - .../24565-audit-def-cache-follows-metadata.md | 15 - .changeset/24567-drop-gate-refusal-18-0.md | 13 - .changeset/24576-try-catch-help-words.md | 10 - .changeset/24577-mirror-declared-values.md | 14 - .changeset/24597-field-code-catalog.md | 14 - .changeset/24613-run-identity-email.md | 22 - .changeset/24614-system-run-user-grants.md | 22 - .changeset/24618-date-bucket-calendar-day.md | 11 - ...spec-upgrade-entries-fresh-install-only.md | 15 - .changeset/24644-console-pin-12dc7b47c340.md | 66 - .changeset/24652-report-v18-upgrade-text.md | 11 - .changeset/pre.json | 4 - .../pre/11509-element-flat-binding-retired.md | 42 - .../pre/11979-default-org-admin-grant.md | 23 - ...anifest-permissions-string-list-retired.md | 37 - ...tadata-protocol-seed-organization-stamp.md | 18 - .../15195-objectql-no-organization-refused.md | 17 - ...uth-default-organization-boot-invariant.md | 20 - .../15195-runtime-app-plugin-after-auth.md | 7 - ...5195-verify-bootstack-production-single.md | 17 - ...196-builtin-positions-declared-metadata.md | 17 - .../pre/15196-core-security-catalog-read.md | 14 - .../15196-declared-positions-catalog-read.md | 17 - ...5196-grant-permission-set-name-backfill.md | 12 - .../15196-grant-permission-set-name-column.md | 15 - ...15196-grant-readers-by-name-plugin-auth.md | 12 - ...6-grant-readers-by-name-plugin-security.md | 13 - ...5196-position-environment-write-through.md | 36 - .../pre/15196-retired-catalog-residue.md | 13 - .../pre/15196-s10-position-namespace.md | 20 - .../pre/15196-s5a-resolver-grant-name.md | 30 - ...04-7a-permission-set-projection-retired.md | 29 - ...15204-7b-position-write-through-retired.md | 14 - .../pre/15204-8post-catalog-gates-retired.md | 24 - ...catalog-activation-environment-authored.md | 17 - .../pre/15204-grant-by-name-security.md | 18 - .../pre/15204-grant-by-name-spec-tsdoc.md | 5 - .changeset/pre/15204-grant-by-name-writers.md | 11 - .changeset/pre/15204-grant-name-pickers.md | 11 - .../pre/15204-permission-set-clone-door.md | 14 - .../pre/15204-s1-position-permission-sets.md | 52 - .changeset/pre/15204-s2a-security-readers.md | 40 - .../15204-s2b-plugin-auth-catalog-readers.md | 24 - ...-s2b-plugin-sharing-ledger-deactivation.md | 15 - .../pre/15204-s2c-catalog-activation-door.md | 17 - .changeset/pre/15204-s3-boot-report.md | 21 - .changeset/pre/15204-s6a-position-seeders.md | 16 - .../15204-s6b1a-capability-declarations.md | 11 - .../15204-s6b1b-system-capability-seeder.md | 15 - .../15204-s6b1c-declared-capability-seeder.md | 30 - .../15204-s6b2-declared-permission-seeders.md | 34 - ...15204-s6b2a-meta-resync-example-binders.md | 18 - .../pre/15204-s6c-prep-organization-scope.md | 7 - .../15204-s7pre-overlay-discard-retired.md | 34 - .../15204-s8-catalog-row-objects-retired.md | 48 - .changeset/pre/15204-su2-contract-text.md | 17 - .../pre/15204-su2-suggestion-acceptance.md | 30 - ...15204-verify-rls-probe-registry-persona.md | 17 - .../15204-walled-catalog-package-refusal.md | 31 - .../pre/15204-walled-catalog-package-verbs.md | 34 - .../pre/15204-walled-catalog-refusal.md | 28 - .../15205-email-template-org-door-closed.md | 28 - .../15205-email-templates-resolve-registry.md | 36 - .../pre/15206-boot-report-carry-overs.md | 10 - .../pre/15206-managed-content-sealed.md | 28 - .../pre/15206-meta-doors-environment-only.md | 38 - .../pre/15206-protocol-environment-only.md | 44 - .../pre/15206-reads-environment-only.md | 47 - .../pre/15206-sys-view-definition-retired.md | 27 - .../pre/15207-audit-log-attribution-field.md | 30 - ...loyment-plumbing-no-organization-column.md | 28 - ...lifecycle-no-tenant-column-no-partition.md | 9 - ...-platform-global-no-organization-column.md | 30 - ...7-settings-global-rung-platform-setting.md | 28 - .../pre/15211-ceremony-metadata-promotion.md | 43 - .../pre/15211-cli-apply-reviewed-plan.md | 37 - .../pre/15212-180-guards-fresh-single-only.md | 16 - .changeset/pre/15212-8a-one-predicate.md | 25 - .../pre/15212-8b0h-share-link-stamped.md | 12 - .../15212-8c0-unattributable-rows-block.md | 28 - .changeset/pre/15213-install-modes.md | 24 - .../pre/15213-readonly-package-remedy.md | 13 - .changeset/pre/15213-unmanaged-import-verb.md | 18 - .changeset/pre/15213-unmanaged-lock-strip.md | 20 - .../pre/15952-blank-template-test-story.md | 17 - .../15953-s8-cli-dashboard-windows-flag.md | 9 - .changeset/pre/15953-s8-window-sum-derived.md | 16 - .../15953-verify-dashboard-window-family.md | 38 - ...953-verify-dashboard-window-now-removed.md | 17 - .../15953-verify-delete-behavior-family.md | 37 - .../15953-verify-parent-write-reach-family.md | 43 - .../15953-verify-reference-shape-family.md | 30 - .../pre/15953-verify-unique-scope-family.md | 35 - .../19939-flow-callee-input-value-slots.md | 39 - .../pre/19939-flow-collection-value-slots.md | 46 - .../pre/19939-flow-crud-filter-operands.md | 53 - .changeset/pre/19939-flow-http-slots.md | 52 - .../pre/19939-flow-notify-value-slots.md | 52 - .../pre/19939-flow-screen-value-slots.md | 48 - ...9939-flow-single-brace-resolver-deleted.md | 45 - ...939-flow-value-slot-date-macros-refused.md | 54 - .../19939-flow-value-slot-run-user-refused.md | 45 - ...low-value-slot-template-dialect-refused.md | 42 - .../pre/20471-component-row-anchor-quotes.md | 16 - .../pre/20471-component-row-anchor-requote.md | 19 - .../pre/20749-reader-needles-in-words.md | 16 - ...86-add-member-platform-admin-visibility.md | 11 - ...flow-builtin-node-config-values-refused.md | 43 - ...03-platform-admin-affordance-visibility.md | 16 - .../pre/21908-by-id-producers-opt-in.md | 13 - .../21908-principal-less-producers-final.md | 18 - .../pre/21908-principal-less-strict-mode.md | 35 - ...-meta-read-declines-code-datasource-row.md | 14 - ...21923-datasource-origin-from-provenance.md | 17 - .../pre/21967-view-expansion-per-package.md | 18 - .../pre/21968-spec-default-agent-chat-door.md | 13 - .../21972-caller-scoped-views-not-first.md | 13 - .../pre/21972-record-shares-all-first.md | 12 - .changeset/pre/21973-nav-view-name-default.md | 12 - .../21978-checksum-less-row-served-version.md | 12 - .../pre/21980-copy-overlays-shipped-names.md | 27 - ...tin-node-config-undeclared-keys-refused.md | 51 - ...-subflow-config-undeclared-keys-refused.md | 43 - .../pre/21984-approval-requests-all-first.md | 13 - ...ata-protocol-declines-stored-row-public.md | 12 - ...986-rest-published-door-code-datasource.md | 10 - ...-runtime-published-door-code-datasource.md | 10 - .../pre/21995-measure-column-aggregate.md | 14 - .../pre/22012-isplatformadmin-standing.md | 13 - .../22019-object-save-door-formula-verdict.md | 29 - .../pre/22019-objectql-formula-fault-log.md | 11 - ...24-no-package-read-wears-served-package.md | 12 - .../pre/22026-read-receipt-recipient-only.md | 17 - .../pre/22028-control-plane-url-docblock.md | 11 - ...2032-object-save-door-action-predicates.md | 38 - ...22032-object-save-door-field-rule-slots.md | 31 - ...32-object-save-door-option-visible-when.md | 30 - ...-object-save-door-validation-predicates.md | 29 - ...042-nested-validation-predicate-verdict.md | 30 - ...044-shared-entry-drops-conversion-table.md | 11 - .../pre/22046-upload-doors-uploader-only.md | 18 - .../22047-spec-ui-anonymous-form-intake.md | 13 - .../22049-share-password-header-encoding.md | 18 - .../pre/22054-notify-title-template-input.md | 25 - ...57-bound-row-not-under-shared-bare-name.md | 12 - .../pre/22062-email-template-boot-sweep.md | 27 - .../pre/22067-seed-claim-skip-automations.md | 16 - ...2070-audit-stamp-under-skip-automations.md | 22 - .../pre/22071-runtime-assets-boot-warning.md | 13 - .../22072-migrate-meta-relevance-predicate.md | 25 - .../22073-boot-warning-one-line-per-class.md | 13 - .../pre/22074-auth-identity-objects-plugin.md | 15 - ...-security-identity-objects-boot-refusal.md | 19 - .changeset/pre/22079-forms-slug-redirect.md | 13 - .changeset/pre/22079-publiclink-describe.md | 17 - .changeset/pre/22080-v18-line-opens.md | 14 - .../pre/22081-notify-refusal-single-brace.md | 13 - ...low-edge-unresolved-or-repeated-refused.md | 39 - ...metadata-protocol-revert-stored-package.md | 13 - .../22090-runtime-revert-studio-package.md | 12 - .../pre/22093-author-strings-internal-refs.md | 32 - ...093-submit-behavior-help-no-ruling-date.md | 9 - ...2093-sys-email-field-help-product-words.md | 9 - .changeset/pre/22099-owner-bind-in-flight.md | 15 - .../pre/22110-flow-text-slot-double-brace.md | 39 - ...113-revert-package-code-shipped-members.md | 19 - .../pre/22114-meta-read-version-token.md | 21 - .../pre/22116-migrate-meta-out-snapshot.md | 14 - .../pre/22118-gate-baseline-stored-self.md | 15 - .changeset/pre/22128-draft-head-resolution.md | 17 - .changeset/pre/22130-protocol-18.md | 18 - .../pre/22135-security-catalog-one-holder.md | 27 - .../pre/22136-settings-timezone-help-plain.md | 11 - .../22141-rest-meta-save-request-options.md | 10 - .../22141-runtime-meta-save-preconditions.md | 11 - .../pre/22149-define-seed-record-keys.md | 26 - .../pre/22150-scaffold-dockerfile-lockfile.md | 14 - .../22154-analytics-security-absence-info.md | 11 - .changeset/pre/22155-console-dist-remedy.md | 14 - ...22156-cli-port-help-and-init-next-steps.md | 11 - .../pre/22157-option-visible-when-parent.md | 31 - .changeset/pre/22158-print-page-spec.md | 64 - .../22160-boot-diagnostics-noise-budget.md | 19 - .../22161-functional-completeness-one-line.md | 13 - .../pre/22161-lint-slice-10-one-line.md | 24 - .../pre/22161-lint-slice-11-one-line-fix.md | 30 - .../pre/22161-lint-slice-12-one-line-fix.md | 32 - .../pre/22161-lint-slice-13-one-line-fix.md | 28 - .../pre/22161-lint-slice-14-one-line-fix.md | 29 - .../pre/22161-lint-slice-15-one-line-fix.md | 28 - .../pre/22161-lint-slice-16-one-line-fix.md | 33 - .changeset/pre/22161-lint-slice-2-one-line.md | 18 - .changeset/pre/22161-lint-slice-3-one-line.md | 18 - .changeset/pre/22161-lint-slice-4-one-line.md | 16 - .changeset/pre/22161-lint-slice-5-one-line.md | 18 - .changeset/pre/22161-lint-slice-6-one-line.md | 20 - .changeset/pre/22161-lint-slice-7-one-line.md | 21 - .changeset/pre/22161-lint-slice-8-one-line.md | 18 - .changeset/pre/22161-lint-slice-9-one-line.md | 22 - .changeset/pre/22161-rule-message-one-line.md | 25 - .../pre/22161-s17-widget-binding-fix-lines.md | 24 - .../pre/22161-s18-data-model-fix-lines.md | 22 - .../pre/22161-s19-five-file-fix-lines.md | 16 - .../pre/22161-s20-flow-patterns-fix-lines.md | 13 - .../pre/22161-s21-validate-fix-lines.md | 18 - .../pre/22161-spec-approver-type-describe.md | 10 - .../pre/22162-scaffold-workspace-minimal.md | 13 - .../22163-doctor-node-env-source-posture.md | 11 - .../pre/22168-settings-user-rung-owner.md | 19 - .../22169-permission-set-heal-converges.md | 15 - .changeset/pre/22175-upload-org-change-4xx.md | 14 - ...178-analytics-picklist-dimension-labels.md | 15 - .../pre/22182-approval-approve-description.md | 9 - .../pre/22182-invite-user-action-copy.md | 11 - .../pre/22183-import-kept-option-values.md | 16 - .../pre/22188-rest-meta-package-all-fold.md | 14 - .../22188-runtime-meta-package-all-fold.md | 12 - .../pre/22190-flat-docs-owner-package.md | 15 - .changeset/pre/22200-drafts-header-label.md | 14 - .changeset/pre/22201-granted-by-provenance.md | 26 - .../pre/22202-migrate-exit-schema-parity.md | 13 - .changeset/pre/22203-position-package-door.md | 13 - .../pre/22212-lint-sibling-spellings.md | 18 - .../22218-chunk-progress-org-change-4xx.md | 14 - .../pre/22219-migrate-meta-write-range.md | 16 - .../pre/22220-package-door-before-gates.md | 11 - .../pre/22225-console-not-built-loud.md | 16 - ...rant-holder-must-be-organization-member.md | 22 - .../pre/22227-field-deadline-due-like.md | 15 - .../pre/22228-field-conditional-formatting.md | 17 - .changeset/pre/22237-console-asset-cache.md | 11 - .changeset/pre/22238-package-union-readers.md | 18 - .../pre/22249-doctor-env-files-loader.md | 10 - .../pre/22250-meta-fls-runtime-sortability.md | 16 - .../pre/22255-policy-unbound-info-glyph.md | 11 - .../22256-composed-load-path-conversions.md | 14 - ...2256-migrate-meta-load-path-conversions.md | 12 - .../22257-auth-settings-read-after-bind.md | 14 - ...s-session-read-no-renewal-behind-cookie.md | 22 - .../22258-services-in-process-session-read.md | 19 - ...2261-settings-organization-row-identity.md | 22 - .changeset/pre/22269-print-render-service.md | 24 - .changeset/pre/22270-print-record-door.md | 14 - .../22272-kernel-boot-phase-hooks-option.md | 13 - .../pre/22274-option-visible-when-members.md | 36 - .changeset/pre/22277-cel-timestamp-string.md | 14 - ...tenant-wall-judges-emptied-organization.md | 23 - ...2279-analytics-absent-security-comments.md | 11 - .../pre/22283-storage-limits-honoured.md | 36 - .../pre/22286-kernel-signal-listeners.md | 12 - .../pre/22288-cli-package-owned-readers.md | 24 - .../22289-migrate-meta-composed-project.md | 11 - ...2290-value-slot-remedy-cel-claimed-head.md | 13 - .../pre/22300-formula-projection-trim.md | 13 - .../pre/22301-seed-temporal-stored-form.md | 16 - .../22301-spec-ledger-verify-provenance.md | 9 - .../22301-verify-boots-the-served-slate.md | 32 - .../22301-verify-boots-what-serve-boots.md | 31 - .../22301-verify-evaluate-condition-door.md | 14 - .../pre/22301-verify-lowered-body-door.md | 16 - .../pre/22301-verify-observe-writes-door.md | 18 - .../pre/22301-verify-public-form-door.md | 16 - .../22301-verify-system-insert-delete-door.md | 14 - .changeset/pre/22301-verify-update-doors.md | 16 - ...02-security-service-principal-less-text.md | 15 - .../pre/22305-cascade-sibling-restrict.md | 13 - ...rt-readonly-withheld-from-before-insert.md | 41 - .../pre/22307-cold-boot-catalog-refusal.md | 31 - .../pre/22310-elevated-flow-trigger-door.md | 20 - ...chunked-completion-assembles-held-parts.md | 27 - ...14-storage-payload-too-large-provenance.md | 11 - .../pre/22318-audit-plugin-host-locale.md | 12 - ...28-auth-seeded-hook-registered-at-ready.md | 12 - ...22332-chunk-part-record-compare-and-set.md | 13 - .../22335-graceful-shutdown-false-no-exit.md | 16 - ...2340-registry-allow-environment-overlay.md | 39 - .../pre/22340-s3-governed-text-rename.md | 9 - ...olicy-try-catch-undeclared-keys-refused.md | 48 - .../pre/22344-get-record-id-projection.md | 11 - .../22345-run-provenance-context-retired.md | 23 - ...60-declared-position-package-provenance.md | 32 - .changeset/pre/22362-unscoped-run-strings.md | 15 - .../22371-security-catalog-overlays-step.md | 24 - ...auth-session-api-getsession-input-query.md | 11 - .../pre/22386-spec-object-attached-on-read.md | 12 - .../22386-validator-attached-on-read-leaf.md | 12 - ...lugin-approvals-viewer-attached-on-read.md | 11 - ...94-option-visible-when-evaluser-members.md | 36 - .../22398-plugin-auth-session-redispatch.md | 17 - .../pre/22402-option-gate-fails-closed.md | 29 - .changeset/pre/22405-serve-flat-docs-owner.md | 14 - .../pre/22407-flow-door-elevated-start.md | 22 - .changeset/pre/22410-dev-banner-order.md | 11 - .changeset/pre/22411-hatch-legacy-spelling.md | 19 - .../pre/22420-console-drift-responder.md | 18 - .changeset/pre/22423-mcp-stdio-package-cwd.md | 12 - .../22428-field-existence-every-spelling.md | 29 - .../22430-api-description-anonymous-deny.md | 19 - .../pre/22431-unclaimed-download-signed-in.md | 18 - .changeset/pre/22432-i18n-anonymous-deny.md | 25 - .../22434-connector-provider-package-path.md | 14 - .../22437-public-form-submit-answers-id.md | 25 - .../pre/22443-storage-scope-public-retired.md | 39 - .../22443-sys-file-public-scope-rewrite.md | 55 - .../22444-list-view-component-props-row.md | 12 - .../pre/22445-update-preview-stored-row.md | 21 - .../pre/22450-flow-refusal-translation.md | 19 - .../22455-cbp-parent-gates-judge-master.md | 22 - ...urity-serves-controlled-by-parent-write.md | 12 - ...22459-hook-lowering-destructuring-alias.md | 11 - ...so-domain-verification-unknown-provider.md | 12 - ...rity-service-controlled-by-parent-write.md | 13 - .../pre/22466-refused-attach-tombstone.md | 17 - .../pre/22470-upload-scope-vocabulary.md | 40 - .../22472-spec-record-approval-decision.md | 13 - .changeset/pre/22473-approval-request-page.md | 18 - .changeset/pre/22474-preview-binds-parent.md | 14 - ...2477-flow-text-slot-dollar-root-refused.md | 43 - .../pre/22481-attached-block-served-rows.md | 17 - ...2-spec-projections-generated-at-publish.md | 19 - .../pre/22483-upgrade-guide-docs-address.md | 15 - .../22491-chart-list-view-binds-a-dataset.md | 33 - .changeset/pre/22497-master-chain-docblock.md | 13 - ...ow-binding-variable-dollar-name-refused.md | 40 - .changeset/pre/22506-migrate-composes-boot.md | 26 - .../22507-flow-options-toasts-translation.md | 22 - ...2507-screen-copy-engine-pick-automation.md | 12 - .../pre/22507-screen-copy-engine-pick-cli.md | 10 - .../pre/22507-screen-copy-engine-pick-lint.md | 9 - .../pre/22507-screen-copy-engine-pick-spec.md | 14 - .../22508-page-header-title-keeps-authored.md | 17 - .changeset/pre/22510-activity-actor-name.md | 30 - .../22514-explain-cbp-update-master-check.md | 10 - .../pre/22519-unreadable-header-write.md | 35 - .changeset/pre/22521-config-boot-residual.md | 18 - .changeset/pre/22527-activity-actor-avatar.md | 26 - .../22530-explain-cbp-write-verb-parity.md | 11 - .../pre/22536-record-page-header-title.md | 17 - ...22537-spec-record-approvals-attachments.md | 15 - ...50-explain-lifecycle-verb-rls-operation.md | 9 - ...4-migration-registry-generated-at-build.md | 26 - .../pre/22558-empty-approver-no-slot.md | 20 - .../pre/22559-approval-request-read-gate.md | 20 - ...s-approvals-visible-to-readers-form-row.md | 7 - ...n-approvals-declared-record-reader-tier.md | 13 - ...22560-spec-approvals-visible-to-readers.md | 13 - .../22565-flow-cel-unbound-root-refused.md | 54 - .../pre/22566-comment-reaction-record.md | 17 - ...-page-slots-details-beside-tabs-refused.md | 35 - ...plain-transfer-unreadable-row-not-found.md | 9 - .../22572-flow-binding-name-dollar-refused.md | 48 - .../22573-sys-comment-reactions-retired.md | 30 - ...22575-spec-approvals-action-page-member.md | 13 - .changeset/pre/22576-approvals-act-domain.md | 21 - .../pre/22577-approvals-act-allowlist.md | 11 - .../pre/22578-approvals-action-page-member.md | 12 - .../pre/22579-migrate-telemetry-sibling.md | 15 - .../pre/22580-unmanaged-tables-served-boot.md | 11 - .../pre/22581-migrate-reads-env-files.md | 13 - .../pre/22585-automation-skill-value-slots.md | 17 - ...-nested-refusal-sentence-zero-row-total.md | 12 - .../pre/22589-approval-sibling-read-gates.md | 20 - .changeset/pre/22590-run-summary-acted.md | 9 - .changeset/pre/22591-sealed-remedy-kind.md | 25 - .../pre/22593-file-field-hydration-refused.md | 18 - .../22599-verify-hooks-run-delete-answer.md | 21 - ...5-spec-list-view-edit-inline-default-on.md | 38 - .changeset/pre/22607-approval-opened-topic.md | 13 - ...ist-view-dashboard-required-permissions.md | 12 - .../pre/22614-meta-view-container-read.md | 15 - .../22615-objectql-residual-objects-served.md | 16 - .../pre/22616-approval-token-generic-door.md | 24 - .../22617-cli-organization-ownership-plan.md | 21 - ...flow-screen-title-translation-text-slot.md | 29 - .../pre/22634-analytics-exposure-gate.md | 33 - ...34-spec-ledger-analytics-exposure-codes.md | 7 - ...flow-cel-record-trigger-no-object-opens.md | 13 - ...2637-file-field-metadata-parent-derived.md | 18 - ...9-lint-capability-references-list-views.md | 10 - .../pre/22639-rest-meta-read-gate-audience.md | 16 - .../22639-runtime-meta-legacy-object-exit.md | 9 - .../pre/22639-spec-audience-gate-enforced.md | 11 - .changeset/pre/22640-search-api-exposure.md | 23 - .../pre/22642-flow-cel-record-binding.md | 38 - ...2646-internal-field-data-door-positions.md | 39 - .../pre/22655-submit-redirect-docblock.md | 17 - .changeset/pre/22658-skills-package.md | 8 - .../pre/22659-scaffold-skills-from-package.md | 13 - .../pre/22661-second-object-exposure.md | 34 - ...3-analytics-unexposed-cube-registration.md | 31 - .../22664-spec-ledger-mcp-exposure-codes.md | 12 - .changeset/pre/22668-skills-readme-install.md | 13 - .../pre/22677-flow-cel-record-entrance.md | 31 - ...82-position-form-permission-sets-picker.md | 11 - .../22682-spec-position-bindings-by-name.md | 18 - .../pre/22683-approver-remedy-bulk-import.md | 11 - .../22689-registry-package-version-stamp.md | 13 - .../pre/22694-import-row-hook-sentence.md | 9 - .../pre/22695-install-local-hotloaded.md | 11 - .../pre/22698-file-delegate-metadata-text.md | 16 - .../pre/22718-import-row-sandbox-fault.md | 9 - .../22719-rest-write-hook-refusal-sentence.md | 27 - .changeset/pre/22726-write-answer-warnings.md | 20 - .../pre/22727-formula-currency-result.md | 16 - .../pre/22729-package-install-hotloaded.md | 11 - ...2737-relation-condition-target-exposure.md | 31 - ...ledger-metadata-protocol-exposure-codes.md | 7 - .../22737-types-exposure-arm-names-target.md | 11 - .changeset/pre/22738-lookup-title-exposure.md | 29 - .../22739-import-reference-target-exposure.md | 33 - .../pre/22741-sandbox-fault-import-row.md | 13 - .../22743-field-types-currency-returntype.md | 9 - .changeset/pre/22748-stack-email-sms-door.md | 19 - .../22754-spec-webhooks-redeliver-member.md | 14 - .../pre/22755-webhooks-redeliver-domain.md | 18 - .../pre/22756-webhooks-redeliver-member.md | 13 - ...22759-runtime-authoring-issue-path-rule.md | 13 - .../pre/22761-explain-read-absent-layers.md | 10 - .../pre/22764-rest-write-fault-withheld.md | 29 - .../pre/22769-versioned-http-signature.md | 16 - .../pre/22771-milestone-activity-kind.md | 29 - ...72-spec-trigger-api-inbound-hook-member.md | 15 - .../pre/22773-automation-hooks-domain.md | 18 - .changeset/pre/22774-inbound-hook-member.md | 13 - .../22777-core-relation-condition-sites.md | 10 - ...tdio-relation-condition-target-exposure.md | 31 - ...2780-bulk-param-promoted-route-guidance.md | 14 - ...82-objectql-after-hook-abort-rolls-back.md | 19 - .../pre/22782-spec-hook-onerror-abort-text.md | 12 - .../pre/22785-import-user-field-target.md | 34 - .../pre/22786-withhold-milestone-row.md | 25 - .changeset/pre/22787-readable-ids-leaf.md | 14 - .../pre/22789-sms-configured-provider.md | 14 - .changeset/pre/22790-search-total-counted.md | 17 - .changeset/pre/22792-explain-asker-gate.md | 17 - .../pre/22792-explain-read-master-leg.md | 8 - .../pre/22800-identity-import-exposure.md | 21 - .../pre/22804-redeliver-inactive-veto.md | 13 - .changeset/pre/22806-uniform-hook-refusal.md | 16 - ...10-lowered-body-scope-submitted-refused.md | 25 - .../pre/22817-action-activation-unpackaged.md | 13 - .changeset/pre/22821-admin-password-choice.md | 16 - ...823-calldata-fallback-relation-exposure.md | 38 - ...2823-spec-ledger-runtime-exposure-codes.md | 7 - .../pre/22824-admin-rescue-named-slate.md | 26 - ...22825-inbound-hook-timestamped-guidance.md | 16 - .../pre/22826-refuse-body-only-signature.md | 25 - ...22835-datasource-reads-environment-only.md | 27 - .../22837-lowered-hook-parameter-rebind.md | 26 - .changeset/pre/22839-strict-body-count.md | 24 - .../22841-cli-rule-ids-one-line-verdicts.md | 18 - ...ice-slot-contracts-webhooks-trigger-api.md | 21 - .../pre/22853-runtime-bundle-bodyless.md | 28 - .../pre/22855-scoped-transaction-ambient.md | 16 - .../22856-log-hook-unevaluable-condition.md | 23 - ...ulk-param-promoted-route-migration-text.md | 14 - .../pre/22860-lowered-body-parse-peel.md | 22 - .../pre/22861-readable-ids-composition.md | 25 - .../22868-sandbox-fault-not-unknown-object.md | 13 - .../pre/22870-field-mail-association.md | 19 - .changeset/pre/22870-service-mailbox.md | 27 - .changeset/pre/22871-imap-mailbox-sync.md | 27 - .changeset/pre/22871-mail-association-live.md | 9 - .changeset/pre/22871-mail-ingest-seam.md | 18 - ...ail-template-category-is-system-retired.md | 48 - .changeset/pre/22877-member-selector.md | 49 - .changeset/pre/22877-overlay-locale-member.md | 28 - ...885-sandbox-own-fault-rest-write-routes.md | 11 - ...22886-functions-key-keeps-authored-name.md | 18 - .../22892-predicate-related-master-detail.md | 32 - ...-functions-array-duplicate-name-refused.md | 42 - .../22908-multi-reference-element-shape.md | 26 - .../pre/22911-blank-prefixed-project-name.md | 14 - .../pre/22912-scaffold-vitest-range-npm10.md | 13 - .../22913-audit-provision-failure-error.md | 13 - ...tack-collection-duplicate-entry-refused.md | 49 - .../pre/22937-cli-ceremony-promotions.md | 22 - .../22937-cli-organization-ownership-apply.md | 25 - ...2937-core-email-template-row-definition.md | 13 - .../pre/22937-core-row-definition-mappers.md | 12 - .../pre/22944-runtime-request-kernel-first.md | 7 - .changeset/pre/22957-tree-delete-behavior.md | 18 - ...1-plugin-audit-own-ui-state-no-activity.md | 14 - .changeset/pre/22972-host-bundle-wins.md | 18 - .../22984-boot-provision-honours-skip-sync.md | 15 - .../pre/22985-schema-sync-refusal-error.md | 13 - .changeset/pre/22989-config-boot-api-items.md | 13 - .../pre/22989-host-config-metadata-door.md | 23 - .../22994-app-plugin-converted-collections.md | 15 - .../pre/23000-branding-logo-describe.md | 5 - .../pre/23001-prerelease-artifact-window.md | 17 - .changeset/pre/23004-empty-state-link.md | 15 - .../23008-org-seed-claim-skip-automations.md | 16 - ...rls-deny-sentinel-composed-readable-ids.md | 13 - .../pre/23010-url-action-target-scheme-set.md | 38 - .../pre/23012-lookup-snake-tolerance-text.md | 15 - .changeset/pre/23013-section-heading-count.md | 22 - ...3015-one-default-record-page-per-object.md | 47 - .../pre/23016-page-element-api-data-source.md | 14 - .../23022-sys-packages-managed-then-flag.md | 14 - .../23026-retired-component-prop-refused.md | 29 - .../pre/23027-package-body-conversion.md | 17 - .../pre/23029-app-plugin-converted-readers.md | 13 - .../23032-page-header-actions-whole-list.md | 5 - .changeset/pre/23033-metadata-row-actor.md | 11 - .../pre/23036-visiblewhen-no-data-root.md | 13 - .../23039-indexing-partial-example-recut.md | 9 - .../23042-retired-after-prerelease-stamp.md | 5 - ...23047-me-permissions-outer-catch-closed.md | 18 - .../pre/23050-s11-form-liveness-citations.md | 11 - .../pre/23050-s12-crud-liveness-citations.md | 10 - .changeset/pre/23050-s12-crud-routes-move.md | 10 - ...s13a-meta-collection-liveness-citations.md | 10 - .../23050-s13a-meta-collection-routes-move.md | 10 - ...-s13b-meta-item-read-liveness-citations.md | 12 - .../23050-s13b-meta-item-read-routes-move.md | 10 - .../23050-s13c-meta-item-write-routes-move.md | 10 - ...3050-s13d-meta-item-history-routes-move.md | 10 - ...0-s13e-meta-object-state-published-move.md | 10 - .changeset/pre/23054-org-seed-claim-paged.md | 17 - .changeset/pre/23065-referenceless-tree.md | 23 - .changeset/pre/23068-migrate-actor.md | 16 - .../23072-dispatcher-meta-query-decision.md | 23 - .../pre/23072-meta-doors-closed-sets.md | 23 - .changeset/pre/23073-list-member.md | 16 - .changeset/pre/23074-member-envelope.md | 19 - .../pre/23077-url-target-doors-scheme-set.md | 46 - .../23082-view-data-api-provider-retired.md | 39 - .../pre/23083-openapi-read-decorations.md | 17 - .changeset/pre/23086-actor-family.md | 13 - .../23088-verify-bootstack-metadata-plugin.md | 13 - .../23090-app-plugin-hooks-translations.md | 15 - .../pre/23093-cbp-middle-master-floor.md | 18 - .changeset/pre/23096-save-door-protection.md | 29 - .../pre/23100-cbp-one-required-master.md | 37 - ...page-action-row-on-success-and-executor.md | 38 - .../pre/23119-save-door-lock-envelope.md | 26 - .../23120-server-composed-protection-drop.md | 15 - ...23122-outbox-claim-credential-read-back.md | 13 - .changeset/pre/23124-comment-edit-stamp.md | 16 - .../23134-related-list-parent-data-source.md | 29 - .../pre/23142-settings-anonymous-floor.md | 18 - .../pre/23143-storage-token-refusal-401.md | 17 - .../pre/23145-tombstone-removal-version.md | 32 - ...7-component-props-every-finding-refused.md | 36 - ...9-view-container-reset-heals-expansions.md | 13 - .../pre/23152-import-invite-redirect-to.md | 14 - ...ver-authored-ui-text-names-what-and-who.md | 22 - .../pre/23158-ai-store-caller-context.md | 16 - .../pre/23165-direct-save-resolves-package.md | 35 - ...168-page-action-row-name-leg-url-target.md | 31 - ...e-action-row-on-success-beside-executor.md | 35 - .../23170-rolling-presets-close-on-today.md | 19 - .changeset/pre/23174-nested-fault-writer.md | 18 - .../pre/23175-dev-seed-workspace-timezone.md | 20 - .../pre/23178-auth-gate-raw-host-families.md | 26 - .changeset/pre/23178-settings-auth-gate.md | 22 - .../pre/23183-app-item-read-by-package-id.md | 21 - ...9-identity-objects-default-organization.md | 22 - .changeset/pre/23190-auth-gate-cli-sites.md | 24 - .../23198-container-expansion-reconcile.md | 14 - .../23198-orphan-adoption-keeps-overlays.md | 14 - .../pre/23207-related-list-binding-split.md | 12 - .../pre/23210-cbp-extension-merged-master.md | 40 - .../pre/23214-dispatcher-query-arity.md | 30 - .../pre/23214-hono-query-multiplicity.md | 18 - .changeset/pre/23215-inline-forward-parse.md | 11 - .changeset/pre/23216-routes-retire-text.md | 13 - .../23217-core-request-execution-context.md | 13 - .changeset/pre/23217-s2-rest-door.md | 12 - .changeset/pre/23217-s3-settings-door.md | 11 - .changeset/pre/23217-s4-storage-door.md | 12 - .changeset/pre/23217-s5-auth-me-door.md | 12 - .../pre/23221-cascade-delete-hook-signal.md | 26 - .changeset/pre/23224-guest-grants-channel.md | 31 - ...225-d2b-anonymous-elevated-flow-refused.md | 43 - .../pre/23225-domain-floor-anonymous-deny.md | 21 - .changeset/pre/23225-ui-data-domain-floor.md | 20 - .changeset/pre/23226-guest-owner-stamp.md | 21 - ...28-record-share-guest-recipient-retired.md | 25 - ...3231-walled-environment-metadata-writes.md | 31 - ...3-permission-echo-names-runtime-package.md | 13 - .../pre/23235-export-group-key-classifier.md | 16 - .../pre/23245-dispatcher-auth-gate-outage.md | 18 - .../pre/23254-auth-org-record-page-option.md | 14 - .../pre/23255-verify-flows-system-arm.md | 24 - ...9-permission-set-owner-mismatch-refused.md | 27 - .../pre/23260-builtin-token-value-judged.md | 42 - ...3265-dispatcher-delete-expected-version.md | 21 - .../23268-external-sharing-model-retired.md | 42 - .changeset/pre/23268-skills-lint-rules-row.md | 9 - .changeset/pre/23269-v18-stored-row-fates.md | 25 - .../pre/23272-export-cbp-master-predicate.md | 19 - .../pre/23276-upgrade-skill-skills-step.md | 11 - .../pre/23279-parent-read-gate-silent-skip.md | 13 - .changeset/pre/23280-host-fate-supplement.md | 21 - ...81-comment-reactions-upgrade-conversion.md | 27 - ...-notification-takes-record-organization.md | 30 - .../pre/23287-api-key-account-posture.md | 37 - ...91-packageless-read-serves-save-address.md | 25 - .changeset/pre/23293-client-delete-package.md | 23 - .../pre/23297-package-axis-delete-draft.md | 21 - ...23298-env-kernel-registry-write-through.md | 12 - .changeset/pre/23306-storage-attach-verb.md | 17 - .changeset/pre/23307-mail-attachments.md | 12 - .changeset/pre/23308-security-catalog-echo.md | 21 - .changeset/pre/23310-serve-org-record-page.md | 17 - ...et-owner-binding-build-and-registration.md | 38 - ...23316-refused-flow-registration-verdict.md | 15 - .changeset/pre/23317-stored-preview-gaps.md | 14 - .../pre/23325-record-share-remedy-text.md | 19 - .../pre/23328-share-link-refusal-order.md | 15 - .../pre/23332-page-action-row-operation.md | 32 - .../23335-held-admin-vendor-doors-refused.md | 25 - .../pre/23335-held-platform-admin-refused.md | 19 - ...23338-flow-binding-d3-replacement-reads.md | 17 - ...3-plugin-auth-import-account-invitation.md | 14 - ...lugin-email-account-invitation-template.md | 15 - .../pre/23347-i18n-plugin-mounts-retired.md | 17 - .changeset/pre/23350-cli-meta-package-flag.md | 21 - .changeset/pre/23354-seed-claim-seed-rows.md | 16 - .changeset/pre/23354-seed-landed-rows.md | 14 - ...57-page-component-view-data-api-refused.md | 26 - .../pre/23359-uncomposed-security-remedy.md | 22 - .../pre/23365-hono-declared-envelope.md | 55 - .changeset/pre/23367-api-key-lifecycle.md | 41 - ...371-share-link-messages-audience-inputs.md | 14 - ...are-link-email-audience-uniform-refusal.md | 15 - .../23374-automation-references-envelopes.md | 9 - .../pre/23385-walled-accepted-defaults.md | 26 - .../pre/23387-two-master-names-release.md | 5 - ...23388-page-component-rules-runtime-door.md | 29 - .changeset/pre/23391-stored-failed-truth.md | 15 - .changeset/pre/23394-branding-logo-live.md | 16 - .changeset/pre/23395-page-form-empty-state.md | 13 - .../pre/23397-ceremony-builtin-positions.md | 17 - ...23399-ceremony-not-declared-column-drop.md | 17 - .../pre/23400-ownership-occupancy-gate.md | 19 - ...03-audience-anchor-binding-gate-retired.md | 11 - ...404-permission-set-description-liveness.md | 10 - .../pre/23406-sealed-first-draft-remedy.md | 15 - .../pre/23415-drop-storage-other-rows.md | 21 - .../23416-runtime-owner-overlay-refusal.md | 31 - .../pre/23421-retire-page-aliases-at-18.md | 20 - ...age-action-row-declarative-update-rules.md | 43 - ...data-family-organization-column-retired.md | 60 - .changeset/pre/23429-v18-boot-refusal.md | 29 - .../23430-grant-permission-set-id-retired.md | 36 - .../pre/23434-component-read-points-part-1.md | 11 - .../pre/23434-component-read-points-part-2.md | 11 - .../pre/23434-component-read-points-part-3.md | 13 - .../23434-objectui-read-points-to-ledger.md | 12 - .changeset/pre/23434-sha-less-read-points.md | 13 - ...-page-component-view-data-judge-deleted.md | 34 - .../23455-key-admission-posture-required.md | 29 - ...23462-dispatcher-patch-expected-version.md | 21 - .../23473-uninstall-shared-object-refusal.md | 21 - .changeset/pre/23474-email-refusal-cost.md | 11 - .../pre/23477-revoke-reason-vocabulary.md | 5 - .changeset/pre/23479-meta-delete-noop-line.md | 18 - .../pre/23480-cli-meta-register-force.md | 19 - .../pre/23487-retire-overdue-conversions.md | 29 - ...8-admin-scope-binding-authoring-retired.md | 51 - .../pre/23491-patch-data-request-gate.md | 27 - .../pre/23492-empty-version-token-refused.md | 27 - .../pre/23495-draft-doors-one-address.md | 37 - .../pre/23497-public-form-section-i18n.md | 16 - .../23501-automation-door-package-remedy.md | 17 - .changeset/pre/23505-extension-refusal-409.md | 17 - .changeset/pre/23509-mcp-coded-refusal.md | 31 - ...511-duplicate-package-unprefixed-object.md | 17 - .../pre/23513-grant-remedy-plan-file.md | 15 - .changeset/pre/23514-sentinel-owner-save.md | 34 - .../23527-duplicate-permission-field-keys.md | 15 - .changeset/pre/23528-exposure-refusal-code.md | 27 - ...ction-tools-withhold-permission-details.md | 11 - .changeset/pre/23531-hono-conflict-body.md | 19 - .../pre/23531-hono-conflict-current-record.md | 17 - .../23532-hono-undeclared-500-leak-filter.md | 17 - ...7-duplicate-package-reference-collision.md | 15 - .../pre/23538-duplicate-names-in-step.md | 23 - ...23539-references-permission-fields-keys.md | 11 - .changeset/pre/23542-walled-binding-remedy.md | 32 - .../pre/23543-f1-retire-mongo-driver-alias.md | 24 - ...b-retry-policy-without-attempts-refused.md | 29 - ...43-f3-retire-wait-connector-config-lift.md | 26 - .../23543-f4-visibility-alias-keys-retired.md | 46 - ...543-f5-http-callout-node-types-reserved.md | 44 - .../pre/23543-f5-jsx-retire-page-kind-jsx.md | 28 - .../pre/23546-trail-doors-setup-access.md | 37 - .changeset/pre/23549-fls-empty-object-part.md | 32 - .../pre/23553-default-record-page-remedy.md | 17 - .../23556-approval-request-names-record.md | 12 - .../pre/23558-ceremony-dialect-resolver.md | 31 - .../pre/23561-duplicate-object-name-token.md | 5 - .../23562-read-only-arms-journal-mode-note.md | 19 - .../pre/23566-picklist-translation-refs.md | 14 - .changeset/pre/23573-serve-no-storage-loud.md | 16 - .../23574-data-request-contract-remainder.md | 29 - .../pre/23577-sealed-write-one-sentence.md | 24 - .../23586-occ-conflict-caller-visibility.md | 16 - .../pre/23587-hono-structured-arm-fields.md | 16 - .changeset/pre/23587-hono-unique-violation.md | 16 - .../pre/23594-subject-title-reader-fls.md | 12 - ...gin-auth-import-invite-delivery-failure.md | 15 - .changeset/pre/23609-legacy-org-read-seam.md | 21 - .../23610-page-isdefault-template-slots.md | 41 - .changeset/pre/23610-skills-pages-contract.md | 9 - .../pre/23615-grant-unique-index-skill.md | 9 - ...618-approval-title-family-served-fields.md | 12 - ...2-read-only-arms-journal-note-remainder.md | 18 - .changeset/pre/23623-no-server-banner-urls.md | 13 - .changeset/pre/23630-promote-doors-d99.md | 31 - .changeset/pre/23632-form-section-labels.md | 19 - .../23633-unrecognised-database-url-scheme.md | 17 - .../23634-stack-datasource-default-refused.md | 34 - ...35-sqlite-wasm-background-flush-failure.md | 11 - .../pre/23636-no-storage-hydration-error.md | 13 - .../23637-lookup-target-title-row-access.md | 12 - .changeset/pre/23639-readable-ids-drivers.md | 23 - .changeset/pre/23644-failed-boot-rollback.md | 20 - .../pre/23645-delete-heal-boot-hydrated.md | 19 - ...646-adopt-orphans-object-registry-owner.md | 18 - .../pre/23648-sqlite-wasm-sql-driver.md | 21 - .../23649-datasource-mapping-before-sync.md | 29 - .../23650-page-header-show-star-retired.md | 38 - .../pre/23657-draft-save-one-package-draft.md | 38 - .../pre/23658-client-publish-package.md | 23 - .../pre/23661-runtime-object-metadata-copy.md | 22 - ...66-ceremony-platform-global-column-drop.md | 15 - .../pre/23673-parent-gate-readable-ids.md | 18 - .../23679-memory-readable-ids-hashed-once.md | 12 - .changeset/pre/23684-drop-gate-host-tables.md | 21 - .../pre/23686-dispatcher-unique-violation.md | 16 - .../23689-env-kernel-runtime-object-copy.md | 21 - .../pre/23690-adopt-orphans-draft-view.md | 16 - .../pre/23696-sqlite-uncreatable-path.md | 16 - .changeset/pre/23701-mcp-unique-violation.md | 16 - .../pre/23702-sandbox-relay-duplicate.md | 20 - .../pre/23710-liveness-print-flow-messages.md | 17 - .../pre/23712-draft-save-d99-address.md | 32 - .../pre/23714-unique-violation-arm-relay.md | 14 - .../pre/23715-strict-draft-read-address.md | 14 - .../pre/23718-sandbox-plain-object-throw.md | 27 - .../pre/23723-sandbox-unreadable-rejection.md | 26 - .../pre/23726-read-only-arms-remainder.md | 19 - .../pre/23731-activity-changed-fields.md | 25 - .../pre/23732-attachment-readable-ids.md | 22 - .../pre/23734-heavy-flow-bounded-modal.md | 22 - .../pre/23735-reference-deleted-when.md | 27 - .../pre/23736-record-id-deleted-when.md | 12 - .../pre/23737-uncreatable-db-dir-refusal.md | 20 - .../23740-form-section-columns-no-default.md | 26 - .../pre/23743-no-server-announcements.md | 12 - .changeset/pre/23744-kernel-only-lifetime.md | 13 - .changeset/pre/23745-record-json-block.md | 16 - .../pre/23753-navigation-record-surface.md | 9 - .../pre/23756-section-translation-tsdoc.md | 7 - .../pre/23764-unsupported-scheme-no-url.md | 21 - .changeset/pre/23765-opaque-scheme-refusal.md | 16 - ...23768-datasources-default-host-reserved.md | 9 - .../pre/23772-principal-session-mint.md | 30 - .../pre/23775-flow-save-door-new-refusal.md | 34 - .../pre/23787-api-skill-retired-route-door.md | 9 - ...88-blank-scaffold-npm10-vitest-override.md | 21 - .../23789-page-props-save-door-sentences.md | 9 - .../pre/23792-memory-url-refusal-no-url.md | 18 - .../pre/23794-file-url-filename-rule.md | 13 - .changeset/pre/23802-setup-copy.md | 18 - .../pre/23803-setup-nav-user-entries.md | 13 - .../pre/23804-own-ui-state-no-ledger-row.md | 15 - .../pre/23805-scaffold-vitest-override.md | 19 - .../pre/23806-metadata-core-vitest-peer.md | 22 - .../pre/23808-type-descriptions-plain.md | 18 - .changeset/pre/23809-select-option-kind.md | 34 - .../pre/23813-minted-session-no-refresh.md | 14 - .../23816-turso-remote-ownership-ceremony.md | 23 - .../pre/23820-dev-watch-compile-refusal.md | 14 - .../23826-ceremony-report-file-reserved.md | 18 - .changeset/pre/23829-setup-copy-services.md | 22 - .changeset/pre/23838-identity-feeds-off.md | 23 - .../pre/23839-settings-warning-banners.md | 18 - .../23842-automation-stamps-unbound-cause.md | 9 - .../23842-flow-runtime-state-unbound-cause.md | 18 - .changeset/pre/23847-turso-remote-not-null.md | 16 - .../pre/23848-migrate-apply-turso-remote.md | 21 - .changeset/pre/23852-setup-empty-states.md | 19 - .changeset/pre/23853-form-help-texts-plain.md | 14 - .../23855-settings-banner-translation-slot.md | 18 - .changeset/pre/23857-comment-parent-pair.md | 25 - .../pre/23858-serve-state-file-directory.md | 15 - .../23858-uncreatable-directory-refusal.md | 25 - .../pre/23859-client-runtime-status-type.md | 11 - .../23863-explicit-sqlite-non-file-refusal.md | 17 - .../23865-next-steps-after-failed-install.md | 12 - .../23877-visibility-alias-fold-unexported.md | 26 - .../23887-anchor-docblock-guest-no-door.md | 13 - .../pre/23889-approval-list-display-labels.md | 38 - .changeset/pre/23890-one-audit-entry.md | 13 - ...23891-permission-set-managed-by-retired.md | 40 - .changeset/pre/23898-sqlite-refusal-remedy.md | 16 - .../pre/23899-read-only-arms-four-more.md | 20 - .../23903-flow-save-reintroduced-retired.md | 32 - ...ge-retired-declarations-convert-at-load.md | 32 - .../23909-keys-every-verb-anonymous-floor.md | 16 - .../pre/23910-validation-previous-insert.md | 30 - .../23914-cloud-connection-body-refusal.md | 19 - .changeset/pre/23914-hono-body-refusal.md | 27 - .../pre/23914-hono-server-body-refusal.md | 29 - .changeset/pre/23919-builtin-position-copy.md | 15 - .changeset/pre/23925-remote-plan-remedy.md | 15 - .../pre/23928-parallel-failed-branch-steps.md | 9 - .../pre/23932-declared-client-refusal-log.md | 11 - .../23933-installed-apps-packages-filter.md | 20 - .../pre/23944-revoked-key-refusal-text.md | 7 - .changeset/pre/23945-invite-role-cap-codes.md | 17 - .../23946-email-template-loader-boot-order.md | 9 - .../23948-declared-rule-mirrors-advisory.md | 19 - .../23952-declared-sharing-rules-registry.md | 35 - .../23957-invite-role-options-per-caller.md | 25 - .../pre/23963-resume-gone-run-settled.md | 12 - .../pre/23964-record-surface-docblock.md | 10 - .../pre/23967-view-label-not-invented.md | 22 - .../pre/23968-print-local-loopback-host.md | 13 - .../pre/23972-installed-package-source.md | 47 - .../23974-package-doors-convert-forward.md | 18 - ...3978-approval-notification-subject-name.md | 11 - .../pre/23983-previous-null-on-insert.md | 9 - .../pre/23993-reset-revoked-key-restore.md | 28 - ...001-conversion-conflict-channel-retired.md | 39 - .../24002-start-mcp-stdio-early-initialize.md | 11 - .../pre/24007-approval-request-title-label.md | 12 - .changeset/pre/24016-org-invite-action.md | 12 - .../pre/24022-datasource-admin-no-server.md | 7 - .../pre/24023-keys-actions-405-allow.md | 16 - ...tifications-share-links-anonymous-floor.md | 20 - .changeset/pre/24025-put-patch-request-id.md | 15 - .changeset/pre/24029-protocol-range-union.md | 12 - ...4030-install-overwrite-readonly-package.md | 18 - .../24031-exposure-gate-one-object-read.md | 15 - .../pre/24032-plugin-auth-body-refusal.md | 33 - .changeset/pre/24040-reseed-converted-body.md | 19 - .../pre/24041-duplicate-install-refusal.md | 14 - .../pre/24042-hono-undeclared-body-refusal.md | 30 - .../pre/24043-http-request-body-tsdoc.md | 12 - .../24047-action-param-options-comments.md | 14 - .../pre/24059-unmanaged-sweep-sys-packages.md | 14 - .../pre/24060-activity-gate-named-parents.md | 12 - .changeset/pre/24061-auth-prelude-queries.md | 24 - .../24062-authored-install-scope-project.md | 20 - .../pre/24063-view-liveness-evidence.md | 11 - .../24065-ooo-notification-person-names.md | 9 - .../pre/24070-withdraw-meta-object-copies.md | 12 - .changeset/pre/24082-invite-role-cap-codes.md | 15 - ...24086-install-primitive-parses-manifest.md | 19 - .../pre/24087-search-title-namefield-first.md | 13 - .../pre/24090-db-dir-dangling-symlink.md | 14 - ...24091-serve-runtime-state-write-warning.md | 11 - ...adata-protocol-by-name-disabled-package.md | 19 - ...097-rest-exposure-gate-disabled-package.md | 19 - ...97-runtime-meta-object-disabled-package.md | 18 - ...24120-migrate-plan-declared-datasources.md | 15 - .../24126-system-overview-metric-height.md | 14 - .../pre/24129-blueprint-view-filters-sort.md | 16 - .changeset/pre/24130-form-submit-docstring.md | 11 - .../pre/24131-core-required-emptiness.md | 11 - ...4131-objectql-required-emptiness-import.md | 10 - .../pre/24131-rest-public-form-required.md | 14 - .../pre/24134-console-pin-5e15928b4631.md | 130 - .../pre/24137-oauth-token-organization.md | 12 - .../pre/24140-line-items-columns-hydrate.md | 11 - .../pre/24142-settings-organization-noun.md | 13 - .changeset/pre/24143-export-hidden-columns.md | 18 - .../24148-cloud-connection-setup-access.md | 20 - .../24150-cloud-connection-runtime-version.md | 15 - .../pre/24151-body-after-anonymous-floor.md | 17 - .changeset/pre/24154-invitation-role-label.md | 14 - .changeset/pre/24162-record-json-retired.md | 41 - .../pre/24165-mcp-token-organization-id.md | 12 - .../pre/24166-oauth-consent-organization.md | 16 - .../pre/24179-step18-rationale-tracker-ids.md | 10 - ...4182-extender-withdrawal-restores-owner.md | 12 - .changeset/pre/24183-export-json-fields.md | 18 - .changeset/pre/24186-mcp-verifier-keyring.md | 11 - .changeset/pre/24187-docblock-mechanisms.md | 14 - .../pre/24190-line-items-hydrated-check.md | 28 - .../24191-serve-mcp-stdio-early-initialize.md | 11 - .../pre/24192-start-compile-stdout-purity.md | 11 - .../pre/24195-page-template-entry-reason.md | 11 - .../24196-auth-session-api-declared-fields.md | 13 - .../pre/24197-formula-traversal-subject.md | 14 - .changeset/pre/24197-lint-formula-one-hop.md | 17 - .../pre/24197-objectql-formula-one-hop.md | 19 - .../pre/24197-objectql-serve-formula-field.md | 13 - .../pre/24197-runtime-hook-title-one-hop.md | 13 - .../pre/24197-spec-blueprint-one-hop-title.md | 7 - ...24198-blueprint-option-kind-stage-field.md | 14 - ...ta-protocol-by-name-disabled-non-object.md | 19 - ...tadata-protocol-drafts-disabled-package.md | 18 - ...-security-echo-binding-disabled-package.md | 7 - ...-rest-event-diff-doors-disabled-package.md | 18 - ...210-rest-layered-doors-disabled-package.md | 17 - ...ished-state-event-body-disabled-package.md | 18 - ...-runtime-layered-doors-disabled-package.md | 16 - ...210-runtime-meta-disabled-package-doors.md | 20 - ...4210-runtime-published-disabled-package.md | 16 - .../pre/24222-get-session-readers-declared.md | 17 - ...4228-actions-every-verb-anonymous-floor.md | 14 - ...4230-marketplace-installed-list-retired.md | 41 - .../pre/24235-audit-write-locale-seam.md | 11 - .../pre/24239-public-form-grant-skill.md | 9 - .../24243-plugin-auth-hook-refusal-codes.md | 15 - .../pre/24245-head-dispatched-as-get.md | 13 - .changeset/pre/24251-mcp-anonymous-first.md | 13 - ...4252-endpoint-fallback-body-after-match.md | 16 - .../24253-client-publish-drafts-backfill.md | 9 - ...4253-metadata-protocol-publish-backfill.md | 12 - .../24253-runtime-publish-drafts-backfill.md | 10 - ...53-spec-publish-drafts-backfill-applied.md | 9 - .../pre/24254-plugin-auth-hook-codes.md | 16 - ...24264-approval-request-strip-flow-label.md | 13 - .../pre/24265-app-page-record-binding.md | 13 - .../pre/24266-record-blocks-empty-state.md | 12 - ...rative-update-undeclared-params-refused.md | 21 - .changeset/pre/24271-org-rename-door.md | 12 - .../pre/24273-transfer-ownership-honest.md | 19 - ...24281-approval-request-strip-step-label.md | 14 - .../pre/24282-ceremony-null-org-guard.md | 21 - .../pre/24283-console-pin-0707b4ae3712.md | 120 - .../pre/24284-search-all-internal-skip.md | 9 - .../pre/24284-search-empty-set-refused.md | 16 - .../pre/24287-hono-stream-string-verbatim.md | 11 - .../pre/24289-inprocess-hook-ctx-title.md | 9 - .../pre/24289-named-hook-handler-ctx-title.md | 13 - .changeset/pre/24291-cbp-docblock-tense.md | 13 - .changeset/pre/24293-filter-token-message.md | 15 - .changeset/pre/24298-draft-key-provenance.md | 12 - .changeset/pre/24299-api-key-read-outage.md | 22 - .changeset/pre/24300-api-binding-rendered.md | 12 - ...1-seed-null-organization-holder-refused.md | 19 - .../pre/24309-approval-current-step-label.md | 11 - .../24314-extension-no-injected-columns.md | 20 - .../pre/24316-formula-hop-family-refusal.md | 19 - .../pre/24326-main-region-width-refused.md | 29 - .../pre/24327-runtime-this-system-copy.md | 16 - .../pre/24328-history-source-describe.md | 14 - .../pre/24332-nav-item-api-data-source.md | 13 - .../pre/24335-file-hydration-no-denial.md | 17 - .../pre/24338-object-default-row-scope.md | 17 - .../pre/24340-default-row-scope-live.md | 10 - .../pre/24340-rls-object-default-scope.md | 20 - .../pre/24343-8b0c-activity-never-null.md | 11 - .../24343-8b0f-grant-on-first-membership.md | 14 - .../pre/24343-producers-name-organization.md | 17 - .../pre/24348-import-control-opt-out-gate.md | 19 - .../pre/24348-import-opt-out-describes.md | 7 - .../pre/24349-page-walk-panel-children.md | 31 - .../pre/24358-family-hydration-refusal.md | 18 - .../24360-metadata-protocol-this-system.md | 10 - .changeset/pre/24360-objectql-this-system.md | 10 - .../pre/24360-platform-objects-this-system.md | 10 - .../pre/24366-storage-gate-credential.md | 18 - .../pre/24368-services-this-system-copy.md | 29 - .../pre/24371-data-read-one-overlay-read.md | 11 - .changeset/pre/24380-import-undo-controls.md | 20 - .../pre/24380-undo-response-failures.md | 15 - .../24384-platform-objects-org-admin-reach.md | 12 - .changeset/pre/24384-spec-org-reach-rows.md | 7 - .../pre/24387-port-zero-busy-warning.md | 10 - .../24394-page-action-row-patch-retired.md | 43 - .changeset/pre/24395-draft-changes-surface.md | 11 - .changeset/pre/24395-draft-changes-writer.md | 16 - .changeset/pre/24395-http-save-surface.md | 12 - .../pre/24400-bound-action-foreign-object.md | 18 - .../pre/24404-cloud-connection-this-system.md | 10 - .changeset/pre/24404-mcp-this-system.md | 10 - .../pre/24405-mcp-connect-agent-subtitle.md | 10 - .../pre/24405-platform-objects-this-system.md | 11 - .changeset/pre/24406-spec-this-system-copy.md | 17 - .../pre/24409-migrate-boot-no-dev-seed.md | 12 - ...24410-lint-formula-hop-undeclared-field.md | 19 - ...-objectql-formula-hop-sibling-isolation.md | 17 - .../pre/24411-unstored-field-removal.md | 13 - .../24421-sharing-rule-declared-name-guard.md | 26 - ...456-client-platform-objects-this-system.md | 13 - .../pre/24457-console-pin-4016370af3c9.md | 70 - .../pre/5082-declared-index-unique-scope.md | 52 - .../9139-cbp-master-detail-required-error.md | 33 - .../pre/9272-grant-window-order-refused.md | 16 - .../9272-user-page-grant-window-columns.md | 7 - .changeset/pre/9591-migrate-meta-write.md | 15 - .../pre/9591-retirement-sentence-write.md | 17 - .changeset/pre/console-088e0f705520.md | 15 - .changeset/pre/console-0df67f237c5a.md | 72 - .changeset/pre/console-1f23d5b070fa.md | 126 - .changeset/pre/console-1fb1ced36243.md | 62 - .changeset/pre/console-20c6d351ad74.md | 92 - .changeset/pre/console-47b1f0bb7174.md | 23 - .changeset/pre/console-4997995022f8.md | 20 - .changeset/pre/console-7578323a544e.md | 87 - .changeset/pre/console-9646e2ed2bf3.md | 29 - .changeset/pre/console-a58626c88dc8.md | 82 - .changeset/pre/console-b9814b334d8a.md | 85 - .changeset/pre/console-daeb77fc84dd.md | 20 - .changeset/pre/console-e5311165a8d6.md | 72 - .changeset/pre/console-f0268ad78485.md | 154 - .changeset/pre/console-f8267df93aba.md | 91 - .changeset/pre/console-f96f771140f1.md | 41 - .../objectui-pin-citations-088e0f705520.md | 19 - .../objectui-pin-citations-0df67f237c5a.md | 21 - .../objectui-pin-citations-1fb1ced36243.md | 17 - .../objectui-pin-citations-20c6d351ad74.md | 21 - .../objectui-pin-citations-47b1f0bb7174.md | 15 - .../objectui-pin-citations-4997995022f8.md | 18 - .../objectui-pin-citations-7578323a544e.md | 19 - .../objectui-pin-citations-9646e2ed2bf3.md | 20 - .../objectui-pin-citations-a58626c88dc8.md | 13 - .../objectui-pin-citations-b9814b334d8a.md | 22 - .../objectui-pin-citations-e5311165a8d6.md | 18 - .../objectui-pin-citations-f0268ad78485.md | 19 - .../org-ownership-scim-provider-inventory.md | 13 - .changeset/pre/request-scoped-grants-memo.md | 22 - .changeset/pre/v18-ceremony-dev-databases.md | 18 - content/docs/deployment/self-hosting.mdx | 8 +- content/docs/upgrading.mdx | 2 +- docker/README.md | 8 +- examples/app-crm/CHANGELOG.md | 442 ++ examples/app-crm/package.json | 2 +- examples/app-multi-package/CHANGELOG.md | 339 + examples/app-multi-package/package.json | 2 +- examples/app-showcase/CHANGELOG.md | 473 ++ examples/app-showcase/package.json | 2 +- examples/app-todo/CHANGELOG.md | 518 ++ examples/app-todo/package.json | 2 +- examples/embed-objectql/CHANGELOG.md | 398 ++ examples/embed-objectql/package.json | 2 +- packages/adapters/hono/CHANGELOG.md | 339 + packages/adapters/hono/package.json | 2 +- packages/apps/account/CHANGELOG.md | 371 + packages/apps/account/package.json | 2 +- packages/apps/setup/CHANGELOG.md | 371 + packages/apps/setup/package.json | 2 +- packages/apps/studio/CHANGELOG.md | 371 + packages/apps/studio/package.json | 2 +- packages/cli/CHANGELOG.md | 3030 ++++++++ packages/cli/package.json | 2 +- packages/client-react/CHANGELOG.md | 382 + packages/client-react/package.json | 2 +- packages/client/CHANGELOG.md | 546 ++ packages/client/package.json | 2 +- packages/cloud-connection/CHANGELOG.md | 982 +++ packages/cloud-connection/package.json | 2 +- .../connectors/connector-imap/CHANGELOG.md | 27 + .../connectors/connector-imap/package.json | 2 +- .../connectors/connector-mcp/CHANGELOG.md | 386 ++ .../connectors/connector-mcp/package.json | 2 +- .../connectors/connector-openapi/CHANGELOG.md | 375 + .../connectors/connector-openapi/package.json | 2 +- .../connectors/connector-rest/CHANGELOG.md | 375 + .../connectors/connector-rest/package.json | 2 +- .../connectors/connector-slack/CHANGELOG.md | 375 + .../connectors/connector-slack/package.json | 2 +- packages/console/CHANGELOG.md | 1388 ++++ packages/console/package.json | 2 +- packages/core/CHANGELOG.md | 1160 ++++ packages/core/package.json | 2 +- packages/create-objectstack/CHANGELOG.md | 83 + packages/create-objectstack/package.json | 2 +- packages/drivers/driver-memory/CHANGELOG.md | 405 ++ packages/drivers/driver-memory/package.json | 2 +- packages/drivers/driver-mongodb/CHANGELOG.md | 416 ++ packages/drivers/driver-mongodb/package.json | 2 +- packages/drivers/driver-sql/CHANGELOG.md | 537 ++ packages/drivers/driver-sql/package.json | 2 +- .../drivers/driver-sqlite-wasm/CHANGELOG.md | 403 ++ .../drivers/driver-sqlite-wasm/package.json | 2 +- packages/drivers/driver-turso/CHANGELOG.md | 505 ++ packages/drivers/driver-turso/package.json | 2 +- packages/formula/CHANGELOG.md | 461 ++ packages/formula/package.json | 2 +- packages/lint/CHANGELOG.md | 2452 +++++++ packages/lint/package.json | 2 +- packages/mcp/CHANGELOG.md | 558 ++ packages/mcp/package.json | 2 +- packages/metadata-core/CHANGELOG.md | 876 +++ packages/metadata-core/package.json | 2 +- packages/metadata-fs/CHANGELOG.md | 41 + packages/metadata-fs/package.json | 2 +- packages/metadata-protocol/CHANGELOG.md | 2677 +++++++ packages/metadata-protocol/package.json | 2 +- packages/metadata/CHANGELOG.md | 611 ++ packages/metadata/package.json | 2 +- packages/objectql/CHANGELOG.md | 1952 ++++++ packages/objectql/package.json | 2 +- packages/observability/CHANGELOG.md | 339 + packages/observability/package.json | 2 +- packages/platform-objects/CHANGELOG.md | 1408 ++++ packages/platform-objects/package.json | 2 +- packages/plugins/embedder-openai/CHANGELOG.md | 339 + packages/plugins/embedder-openai/package.json | 2 +- .../plugins/knowledge-memory/CHANGELOG.md | 376 + .../plugins/knowledge-memory/package.json | 2 +- .../plugins/knowledge-ragflow/CHANGELOG.md | 376 + .../plugins/knowledge-ragflow/package.json | 2 +- packages/plugins/organizations/CHANGELOG.md | 476 ++ packages/plugins/organizations/package.json | 2 +- .../plugins/plugin-approvals/CHANGELOG.md | 845 +++ .../plugins/plugin-approvals/package.json | 2 +- packages/plugins/plugin-audit/CHANGELOG.md | 883 +++ packages/plugins/plugin-audit/package.json | 2 +- packages/plugins/plugin-auth/CHANGELOG.md | 1205 ++++ packages/plugins/plugin-auth/package.json | 2 +- packages/plugins/plugin-dev/CHANGELOG.md | 692 ++ packages/plugins/plugin-dev/package.json | 2 +- packages/plugins/plugin-email/CHANGELOG.md | 625 ++ packages/plugins/plugin-email/package.json | 2 +- .../plugins/plugin-hono-server/CHANGELOG.md | 590 ++ .../plugins/plugin-hono-server/package.json | 2 +- .../plugins/plugin-pinyin-search/CHANGELOG.md | 137 + .../plugins/plugin-pinyin-search/package.json | 2 +- packages/plugins/plugin-security/CHANGELOG.md | 1868 +++++ packages/plugins/plugin-security/package.json | 2 +- packages/plugins/plugin-sharing/CHANGELOG.md | 923 +++ packages/plugins/plugin-sharing/package.json | 2 +- packages/plugins/plugin-webhooks/CHANGELOG.md | 500 ++ packages/plugins/plugin-webhooks/package.json | 2 +- packages/qa/dogfood/CHANGELOG.md | 758 ++ packages/qa/dogfood/package.json | 2 +- packages/qa/downstream-contract/CHANGELOG.md | 339 + packages/qa/downstream-contract/package.json | 2 +- packages/qa/http-conformance/CHANGELOG.md | 56 + packages/qa/http-conformance/package.json | 2 +- packages/rest/CHANGELOG.md | 1394 ++++ packages/rest/package.json | 2 +- packages/runtime/CHANGELOG.md | 2479 +++++++ packages/runtime/package.json | 2 +- packages/sdui-parser/CHANGELOG.md | 4 + packages/sdui-parser/package.json | 2 +- .../services/service-analytics/CHANGELOG.md | 525 ++ .../services/service-analytics/package.json | 2 +- .../services/service-automation/CHANGELOG.md | 1620 +++++ .../services/service-automation/package.json | 2 +- packages/services/service-cache/CHANGELOG.md | 376 + packages/services/service-cache/package.json | 2 +- .../service-cluster-redis/CHANGELOG.md | 340 + .../service-cluster-redis/package.json | 2 +- .../services/service-cluster/CHANGELOG.md | 375 + .../services/service-cluster/package.json | 2 +- .../services/service-datasource/CHANGELOG.md | 575 ++ .../services/service-datasource/package.json | 2 +- packages/services/service-i18n/CHANGELOG.md | 407 ++ packages/services/service-i18n/package.json | 2 +- packages/services/service-job/CHANGELOG.md | 429 ++ packages/services/service-job/package.json | 2 +- .../services/service-knowledge/CHANGELOG.md | 375 + .../services/service-knowledge/package.json | 2 +- .../services/service-mailbox/CHANGELOG.md | 483 ++ .../services/service-mailbox/package.json | 2 +- .../services/service-messaging/CHANGELOG.md | 558 ++ .../services/service-messaging/package.json | 2 +- .../services/service-package/CHANGELOG.md | 450 ++ .../services/service-package/package.json | 2 +- packages/services/service-print/CHANGELOG.md | 450 ++ packages/services/service-print/package.json | 2 +- packages/services/service-queue/CHANGELOG.md | 405 ++ packages/services/service-queue/package.json | 2 +- .../services/service-realtime/CHANGELOG.md | 467 ++ .../services/service-realtime/package.json | 2 +- .../services/service-settings/CHANGELOG.md | 722 ++ .../services/service-settings/package.json | 2 +- packages/services/service-sms/CHANGELOG.md | 449 ++ packages/services/service-sms/package.json | 2 +- .../services/service-storage/CHANGELOG.md | 960 +++ .../services/service-storage/package.json | 2 +- packages/skills/CHANGELOG.md | 116 + packages/skills/package.json | 2 +- packages/spec/CHANGELOG.md | 6159 +++++++++++++++++ packages/spec/package.json | 2 +- packages/triggers/trigger-api/CHANGELOG.md | 432 ++ packages/triggers/trigger-api/package.json | 2 +- .../trigger-record-change/CHANGELOG.md | 375 + .../trigger-record-change/package.json | 2 +- .../triggers/trigger-schedule/CHANGELOG.md | 394 ++ .../triggers/trigger-schedule/package.json | 2 +- packages/types/CHANGELOG.md | 414 ++ packages/types/package.json | 2 +- packages/verify/CHANGELOG.md | 1298 ++++ packages/verify/package.json | 2 +- skills/objectstack-ai/SKILL.md | 2 +- skills/objectstack-api/SKILL.md | 2 +- skills/objectstack-automation/SKILL.md | 2 +- skills/objectstack-data/SKILL.md | 2 +- skills/objectstack-formula/SKILL.md | 2 +- skills/objectstack-i18n/SKILL.md | 2 +- skills/objectstack-platform/SKILL.md | 2 +- skills/objectstack-query/SKILL.md | 2 +- skills/objectstack-ui/SKILL.md | 2 +- skills/objectstack-upgrade/SKILL.md | 2 +- 1240 files changed, 60491 insertions(+), 22003 deletions(-) delete mode 100644 .changeset/10757-request-rounds-core.md delete mode 100644 .changeset/10757-request-rounds-metadata-protocol.md delete mode 100644 .changeset/15212-8b-single-platform-insert-stamped.md delete mode 100644 .changeset/15214-c11-fresh-install-messages.md delete mode 100644 .changeset/23585-related-list-parent-data-source-rendered.md delete mode 100644 .changeset/24289-hook-ctx-title-doors.md delete mode 100644 .changeset/24318-platform-objects-change-slug-to-cloud.md delete mode 100644 .changeset/24318-spec-change-slug-gated-input.md delete mode 100644 .changeset/24321-resume-as-decider.md delete mode 100644 .changeset/24375-approval-object-chip.md delete mode 100644 .changeset/24414-capability-predicate.md delete mode 100644 .changeset/24426-measure-total-hint-stored-number.md delete mode 100644 .changeset/24448-run-context-org-user-ids.md delete mode 100644 .changeset/24450-nav-visible-data-needs-data-source.md delete mode 100644 .changeset/24452-default-row-scope-lint.md delete mode 100644 .changeset/24459-seed-blank-reference.md delete mode 100644 .changeset/24464-doctor-tool-probe-failure.md delete mode 100644 .changeset/24468-lint-rule-hop-undeclared-field.md delete mode 100644 .changeset/24468-objectql-rule-hop-sibling-isolation.md delete mode 100644 .changeset/24471-extension-guidance-hooks-list-views.md delete mode 100644 .changeset/24472-bound-action-collision-report.md delete mode 100644 .changeset/24486-spec-cloud-adr-0028-citation.md delete mode 100644 .changeset/24491-page-save-door-visibility-predicates.md delete mode 100644 .changeset/24497-hook-runas-org-peers.md delete mode 100644 .changeset/24498-verify-validation-verdict-whole-body.md delete mode 100644 .changeset/24501-list-object-column.md delete mode 100644 .changeset/24519-approvals-context-identity.md delete mode 100644 .changeset/24523-default-organization-memo.md delete mode 100644 .changeset/24529-declared-webhooks-from-registry.md delete mode 100644 .changeset/24530-settings-audit-global-rung-sibling.md delete mode 100644 .changeset/24531-identity-mail-sibling.md delete mode 100644 .changeset/24534-export-nav-visible-data-check.md delete mode 100644 .changeset/24536-cel-env-reuse.md delete mode 100644 .changeset/24537-findfile-memo.md delete mode 100644 .changeset/24546-resume-inputs-variables-both-refused.md delete mode 100644 .changeset/24548-complete-holds-bytes.md delete mode 100644 .changeset/24549-parent-gate-parity.md delete mode 100644 .changeset/24550-sharing-rule-meta-door-closed.md delete mode 100644 .changeset/24561-batch-hook-refusal-sentence.md delete mode 100644 .changeset/24562-seed-replay-time-equality.md delete mode 100644 .changeset/24563-list-view-out-of-closure.md delete mode 100644 .changeset/24564-reminder-action-link-at-rest.md delete mode 100644 .changeset/24565-audit-def-cache-follows-metadata.md delete mode 100644 .changeset/24567-drop-gate-refusal-18-0.md delete mode 100644 .changeset/24576-try-catch-help-words.md delete mode 100644 .changeset/24577-mirror-declared-values.md delete mode 100644 .changeset/24597-field-code-catalog.md delete mode 100644 .changeset/24613-run-identity-email.md delete mode 100644 .changeset/24614-system-run-user-grants.md delete mode 100644 .changeset/24618-date-bucket-calendar-day.md delete mode 100644 .changeset/24637-spec-upgrade-entries-fresh-install-only.md delete mode 100644 .changeset/24644-console-pin-12dc7b47c340.md delete mode 100644 .changeset/24652-report-v18-upgrade-text.md delete mode 100644 .changeset/pre.json delete mode 100644 .changeset/pre/11509-element-flat-binding-retired.md delete mode 100644 .changeset/pre/11979-default-org-admin-grant.md delete mode 100644 .changeset/pre/13458-manifest-permissions-string-list-retired.md delete mode 100644 .changeset/pre/15195-metadata-protocol-seed-organization-stamp.md delete mode 100644 .changeset/pre/15195-objectql-no-organization-refused.md delete mode 100644 .changeset/pre/15195-plugin-auth-default-organization-boot-invariant.md delete mode 100644 .changeset/pre/15195-runtime-app-plugin-after-auth.md delete mode 100644 .changeset/pre/15195-verify-bootstack-production-single.md delete mode 100644 .changeset/pre/15196-builtin-positions-declared-metadata.md delete mode 100644 .changeset/pre/15196-core-security-catalog-read.md delete mode 100644 .changeset/pre/15196-declared-positions-catalog-read.md delete mode 100644 .changeset/pre/15196-grant-permission-set-name-backfill.md delete mode 100644 .changeset/pre/15196-grant-permission-set-name-column.md delete mode 100644 .changeset/pre/15196-grant-readers-by-name-plugin-auth.md delete mode 100644 .changeset/pre/15196-grant-readers-by-name-plugin-security.md delete mode 100644 .changeset/pre/15196-position-environment-write-through.md delete mode 100644 .changeset/pre/15196-retired-catalog-residue.md delete mode 100644 .changeset/pre/15196-s10-position-namespace.md delete mode 100644 .changeset/pre/15196-s5a-resolver-grant-name.md delete mode 100644 .changeset/pre/15204-7a-permission-set-projection-retired.md delete mode 100644 .changeset/pre/15204-7b-position-write-through-retired.md delete mode 100644 .changeset/pre/15204-8post-catalog-gates-retired.md delete mode 100644 .changeset/pre/15204-catalog-activation-environment-authored.md delete mode 100644 .changeset/pre/15204-grant-by-name-security.md delete mode 100644 .changeset/pre/15204-grant-by-name-spec-tsdoc.md delete mode 100644 .changeset/pre/15204-grant-by-name-writers.md delete mode 100644 .changeset/pre/15204-grant-name-pickers.md delete mode 100644 .changeset/pre/15204-permission-set-clone-door.md delete mode 100644 .changeset/pre/15204-s1-position-permission-sets.md delete mode 100644 .changeset/pre/15204-s2a-security-readers.md delete mode 100644 .changeset/pre/15204-s2b-plugin-auth-catalog-readers.md delete mode 100644 .changeset/pre/15204-s2b-plugin-sharing-ledger-deactivation.md delete mode 100644 .changeset/pre/15204-s2c-catalog-activation-door.md delete mode 100644 .changeset/pre/15204-s3-boot-report.md delete mode 100644 .changeset/pre/15204-s6a-position-seeders.md delete mode 100644 .changeset/pre/15204-s6b1a-capability-declarations.md delete mode 100644 .changeset/pre/15204-s6b1b-system-capability-seeder.md delete mode 100644 .changeset/pre/15204-s6b1c-declared-capability-seeder.md delete mode 100644 .changeset/pre/15204-s6b2-declared-permission-seeders.md delete mode 100644 .changeset/pre/15204-s6b2a-meta-resync-example-binders.md delete mode 100644 .changeset/pre/15204-s6c-prep-organization-scope.md delete mode 100644 .changeset/pre/15204-s7pre-overlay-discard-retired.md delete mode 100644 .changeset/pre/15204-s8-catalog-row-objects-retired.md delete mode 100644 .changeset/pre/15204-su2-contract-text.md delete mode 100644 .changeset/pre/15204-su2-suggestion-acceptance.md delete mode 100644 .changeset/pre/15204-verify-rls-probe-registry-persona.md delete mode 100644 .changeset/pre/15204-walled-catalog-package-refusal.md delete mode 100644 .changeset/pre/15204-walled-catalog-package-verbs.md delete mode 100644 .changeset/pre/15204-walled-catalog-refusal.md delete mode 100644 .changeset/pre/15205-email-template-org-door-closed.md delete mode 100644 .changeset/pre/15205-email-templates-resolve-registry.md delete mode 100644 .changeset/pre/15206-boot-report-carry-overs.md delete mode 100644 .changeset/pre/15206-managed-content-sealed.md delete mode 100644 .changeset/pre/15206-meta-doors-environment-only.md delete mode 100644 .changeset/pre/15206-protocol-environment-only.md delete mode 100644 .changeset/pre/15206-reads-environment-only.md delete mode 100644 .changeset/pre/15206-sys-view-definition-retired.md delete mode 100644 .changeset/pre/15207-audit-log-attribution-field.md delete mode 100644 .changeset/pre/15207-deployment-plumbing-no-organization-column.md delete mode 100644 .changeset/pre/15207-lifecycle-no-tenant-column-no-partition.md delete mode 100644 .changeset/pre/15207-platform-global-no-organization-column.md delete mode 100644 .changeset/pre/15207-settings-global-rung-platform-setting.md delete mode 100644 .changeset/pre/15211-ceremony-metadata-promotion.md delete mode 100644 .changeset/pre/15211-cli-apply-reviewed-plan.md delete mode 100644 .changeset/pre/15212-180-guards-fresh-single-only.md delete mode 100644 .changeset/pre/15212-8a-one-predicate.md delete mode 100644 .changeset/pre/15212-8b0h-share-link-stamped.md delete mode 100644 .changeset/pre/15212-8c0-unattributable-rows-block.md delete mode 100644 .changeset/pre/15213-install-modes.md delete mode 100644 .changeset/pre/15213-readonly-package-remedy.md delete mode 100644 .changeset/pre/15213-unmanaged-import-verb.md delete mode 100644 .changeset/pre/15213-unmanaged-lock-strip.md delete mode 100644 .changeset/pre/15952-blank-template-test-story.md delete mode 100644 .changeset/pre/15953-s8-cli-dashboard-windows-flag.md delete mode 100644 .changeset/pre/15953-s8-window-sum-derived.md delete mode 100644 .changeset/pre/15953-verify-dashboard-window-family.md delete mode 100644 .changeset/pre/15953-verify-dashboard-window-now-removed.md delete mode 100644 .changeset/pre/15953-verify-delete-behavior-family.md delete mode 100644 .changeset/pre/15953-verify-parent-write-reach-family.md delete mode 100644 .changeset/pre/15953-verify-reference-shape-family.md delete mode 100644 .changeset/pre/15953-verify-unique-scope-family.md delete mode 100644 .changeset/pre/19939-flow-callee-input-value-slots.md delete mode 100644 .changeset/pre/19939-flow-collection-value-slots.md delete mode 100644 .changeset/pre/19939-flow-crud-filter-operands.md delete mode 100644 .changeset/pre/19939-flow-http-slots.md delete mode 100644 .changeset/pre/19939-flow-notify-value-slots.md delete mode 100644 .changeset/pre/19939-flow-screen-value-slots.md delete mode 100644 .changeset/pre/19939-flow-single-brace-resolver-deleted.md delete mode 100644 .changeset/pre/19939-flow-value-slot-date-macros-refused.md delete mode 100644 .changeset/pre/19939-flow-value-slot-run-user-refused.md delete mode 100644 .changeset/pre/19939-flow-value-slot-template-dialect-refused.md delete mode 100644 .changeset/pre/20471-component-row-anchor-quotes.md delete mode 100644 .changeset/pre/20471-component-row-anchor-requote.md delete mode 100644 .changeset/pre/20749-reader-needles-in-words.md delete mode 100644 .changeset/pre/21886-add-member-platform-admin-visibility.md delete mode 100644 .changeset/pre/21898-flow-builtin-node-config-values-refused.md delete mode 100644 .changeset/pre/21903-platform-admin-affordance-visibility.md delete mode 100644 .changeset/pre/21908-by-id-producers-opt-in.md delete mode 100644 .changeset/pre/21908-principal-less-producers-final.md delete mode 100644 .changeset/pre/21908-principal-less-strict-mode.md delete mode 100644 .changeset/pre/21922-metadata-protocol-meta-read-declines-code-datasource-row.md delete mode 100644 .changeset/pre/21923-datasource-origin-from-provenance.md delete mode 100644 .changeset/pre/21967-view-expansion-per-package.md delete mode 100644 .changeset/pre/21968-spec-default-agent-chat-door.md delete mode 100644 .changeset/pre/21972-caller-scoped-views-not-first.md delete mode 100644 .changeset/pre/21972-record-shares-all-first.md delete mode 100644 .changeset/pre/21973-nav-view-name-default.md delete mode 100644 .changeset/pre/21978-checksum-less-row-served-version.md delete mode 100644 .changeset/pre/21980-copy-overlays-shipped-names.md delete mode 100644 .changeset/pre/21982-flow-builtin-node-config-undeclared-keys-refused.md delete mode 100644 .changeset/pre/21982-flow-script-subflow-config-undeclared-keys-refused.md delete mode 100644 .changeset/pre/21984-approval-requests-all-first.md delete mode 100644 .changeset/pre/21986-metadata-protocol-declines-stored-row-public.md delete mode 100644 .changeset/pre/21986-rest-published-door-code-datasource.md delete mode 100644 .changeset/pre/21986-runtime-published-door-code-datasource.md delete mode 100644 .changeset/pre/21995-measure-column-aggregate.md delete mode 100644 .changeset/pre/22012-isplatformadmin-standing.md delete mode 100644 .changeset/pre/22019-object-save-door-formula-verdict.md delete mode 100644 .changeset/pre/22019-objectql-formula-fault-log.md delete mode 100644 .changeset/pre/22024-no-package-read-wears-served-package.md delete mode 100644 .changeset/pre/22026-read-receipt-recipient-only.md delete mode 100644 .changeset/pre/22028-control-plane-url-docblock.md delete mode 100644 .changeset/pre/22032-object-save-door-action-predicates.md delete mode 100644 .changeset/pre/22032-object-save-door-field-rule-slots.md delete mode 100644 .changeset/pre/22032-object-save-door-option-visible-when.md delete mode 100644 .changeset/pre/22032-object-save-door-validation-predicates.md delete mode 100644 .changeset/pre/22042-nested-validation-predicate-verdict.md delete mode 100644 .changeset/pre/22044-shared-entry-drops-conversion-table.md delete mode 100644 .changeset/pre/22046-upload-doors-uploader-only.md delete mode 100644 .changeset/pre/22047-spec-ui-anonymous-form-intake.md delete mode 100644 .changeset/pre/22049-share-password-header-encoding.md delete mode 100644 .changeset/pre/22054-notify-title-template-input.md delete mode 100644 .changeset/pre/22057-bound-row-not-under-shared-bare-name.md delete mode 100644 .changeset/pre/22062-email-template-boot-sweep.md delete mode 100644 .changeset/pre/22067-seed-claim-skip-automations.md delete mode 100644 .changeset/pre/22070-audit-stamp-under-skip-automations.md delete mode 100644 .changeset/pre/22071-runtime-assets-boot-warning.md delete mode 100644 .changeset/pre/22072-migrate-meta-relevance-predicate.md delete mode 100644 .changeset/pre/22073-boot-warning-one-line-per-class.md delete mode 100644 .changeset/pre/22074-auth-identity-objects-plugin.md delete mode 100644 .changeset/pre/22074-security-identity-objects-boot-refusal.md delete mode 100644 .changeset/pre/22079-forms-slug-redirect.md delete mode 100644 .changeset/pre/22079-publiclink-describe.md delete mode 100644 .changeset/pre/22080-v18-line-opens.md delete mode 100644 .changeset/pre/22081-notify-refusal-single-brace.md delete mode 100644 .changeset/pre/22088-flow-edge-unresolved-or-repeated-refused.md delete mode 100644 .changeset/pre/22090-metadata-protocol-revert-stored-package.md delete mode 100644 .changeset/pre/22090-runtime-revert-studio-package.md delete mode 100644 .changeset/pre/22093-author-strings-internal-refs.md delete mode 100644 .changeset/pre/22093-submit-behavior-help-no-ruling-date.md delete mode 100644 .changeset/pre/22093-sys-email-field-help-product-words.md delete mode 100644 .changeset/pre/22099-owner-bind-in-flight.md delete mode 100644 .changeset/pre/22110-flow-text-slot-double-brace.md delete mode 100644 .changeset/pre/22113-revert-package-code-shipped-members.md delete mode 100644 .changeset/pre/22114-meta-read-version-token.md delete mode 100644 .changeset/pre/22116-migrate-meta-out-snapshot.md delete mode 100644 .changeset/pre/22118-gate-baseline-stored-self.md delete mode 100644 .changeset/pre/22128-draft-head-resolution.md delete mode 100644 .changeset/pre/22130-protocol-18.md delete mode 100644 .changeset/pre/22135-security-catalog-one-holder.md delete mode 100644 .changeset/pre/22136-settings-timezone-help-plain.md delete mode 100644 .changeset/pre/22141-rest-meta-save-request-options.md delete mode 100644 .changeset/pre/22141-runtime-meta-save-preconditions.md delete mode 100644 .changeset/pre/22149-define-seed-record-keys.md delete mode 100644 .changeset/pre/22150-scaffold-dockerfile-lockfile.md delete mode 100644 .changeset/pre/22154-analytics-security-absence-info.md delete mode 100644 .changeset/pre/22155-console-dist-remedy.md delete mode 100644 .changeset/pre/22156-cli-port-help-and-init-next-steps.md delete mode 100644 .changeset/pre/22157-option-visible-when-parent.md delete mode 100644 .changeset/pre/22158-print-page-spec.md delete mode 100644 .changeset/pre/22160-boot-diagnostics-noise-budget.md delete mode 100644 .changeset/pre/22161-functional-completeness-one-line.md delete mode 100644 .changeset/pre/22161-lint-slice-10-one-line.md delete mode 100644 .changeset/pre/22161-lint-slice-11-one-line-fix.md delete mode 100644 .changeset/pre/22161-lint-slice-12-one-line-fix.md delete mode 100644 .changeset/pre/22161-lint-slice-13-one-line-fix.md delete mode 100644 .changeset/pre/22161-lint-slice-14-one-line-fix.md delete mode 100644 .changeset/pre/22161-lint-slice-15-one-line-fix.md delete mode 100644 .changeset/pre/22161-lint-slice-16-one-line-fix.md delete mode 100644 .changeset/pre/22161-lint-slice-2-one-line.md delete mode 100644 .changeset/pre/22161-lint-slice-3-one-line.md delete mode 100644 .changeset/pre/22161-lint-slice-4-one-line.md delete mode 100644 .changeset/pre/22161-lint-slice-5-one-line.md delete mode 100644 .changeset/pre/22161-lint-slice-6-one-line.md delete mode 100644 .changeset/pre/22161-lint-slice-7-one-line.md delete mode 100644 .changeset/pre/22161-lint-slice-8-one-line.md delete mode 100644 .changeset/pre/22161-lint-slice-9-one-line.md delete mode 100644 .changeset/pre/22161-rule-message-one-line.md delete mode 100644 .changeset/pre/22161-s17-widget-binding-fix-lines.md delete mode 100644 .changeset/pre/22161-s18-data-model-fix-lines.md delete mode 100644 .changeset/pre/22161-s19-five-file-fix-lines.md delete mode 100644 .changeset/pre/22161-s20-flow-patterns-fix-lines.md delete mode 100644 .changeset/pre/22161-s21-validate-fix-lines.md delete mode 100644 .changeset/pre/22161-spec-approver-type-describe.md delete mode 100644 .changeset/pre/22162-scaffold-workspace-minimal.md delete mode 100644 .changeset/pre/22163-doctor-node-env-source-posture.md delete mode 100644 .changeset/pre/22168-settings-user-rung-owner.md delete mode 100644 .changeset/pre/22169-permission-set-heal-converges.md delete mode 100644 .changeset/pre/22175-upload-org-change-4xx.md delete mode 100644 .changeset/pre/22178-analytics-picklist-dimension-labels.md delete mode 100644 .changeset/pre/22182-approval-approve-description.md delete mode 100644 .changeset/pre/22182-invite-user-action-copy.md delete mode 100644 .changeset/pre/22183-import-kept-option-values.md delete mode 100644 .changeset/pre/22188-rest-meta-package-all-fold.md delete mode 100644 .changeset/pre/22188-runtime-meta-package-all-fold.md delete mode 100644 .changeset/pre/22190-flat-docs-owner-package.md delete mode 100644 .changeset/pre/22200-drafts-header-label.md delete mode 100644 .changeset/pre/22201-granted-by-provenance.md delete mode 100644 .changeset/pre/22202-migrate-exit-schema-parity.md delete mode 100644 .changeset/pre/22203-position-package-door.md delete mode 100644 .changeset/pre/22212-lint-sibling-spellings.md delete mode 100644 .changeset/pre/22218-chunk-progress-org-change-4xx.md delete mode 100644 .changeset/pre/22219-migrate-meta-write-range.md delete mode 100644 .changeset/pre/22220-package-door-before-gates.md delete mode 100644 .changeset/pre/22225-console-not-built-loud.md delete mode 100644 .changeset/pre/22226-grant-holder-must-be-organization-member.md delete mode 100644 .changeset/pre/22227-field-deadline-due-like.md delete mode 100644 .changeset/pre/22228-field-conditional-formatting.md delete mode 100644 .changeset/pre/22237-console-asset-cache.md delete mode 100644 .changeset/pre/22238-package-union-readers.md delete mode 100644 .changeset/pre/22249-doctor-env-files-loader.md delete mode 100644 .changeset/pre/22250-meta-fls-runtime-sortability.md delete mode 100644 .changeset/pre/22255-policy-unbound-info-glyph.md delete mode 100644 .changeset/pre/22256-composed-load-path-conversions.md delete mode 100644 .changeset/pre/22256-migrate-meta-load-path-conversions.md delete mode 100644 .changeset/pre/22257-auth-settings-read-after-bind.md delete mode 100644 .changeset/pre/22258-in-process-session-read-no-renewal-behind-cookie.md delete mode 100644 .changeset/pre/22258-services-in-process-session-read.md delete mode 100644 .changeset/pre/22261-settings-organization-row-identity.md delete mode 100644 .changeset/pre/22269-print-render-service.md delete mode 100644 .changeset/pre/22270-print-record-door.md delete mode 100644 .changeset/pre/22272-kernel-boot-phase-hooks-option.md delete mode 100644 .changeset/pre/22274-option-visible-when-members.md delete mode 100644 .changeset/pre/22277-cel-timestamp-string.md delete mode 100644 .changeset/pre/22278-tenant-wall-judges-emptied-organization.md delete mode 100644 .changeset/pre/22279-analytics-absent-security-comments.md delete mode 100644 .changeset/pre/22283-storage-limits-honoured.md delete mode 100644 .changeset/pre/22286-kernel-signal-listeners.md delete mode 100644 .changeset/pre/22288-cli-package-owned-readers.md delete mode 100644 .changeset/pre/22289-migrate-meta-composed-project.md delete mode 100644 .changeset/pre/22290-value-slot-remedy-cel-claimed-head.md delete mode 100644 .changeset/pre/22300-formula-projection-trim.md delete mode 100644 .changeset/pre/22301-seed-temporal-stored-form.md delete mode 100644 .changeset/pre/22301-spec-ledger-verify-provenance.md delete mode 100644 .changeset/pre/22301-verify-boots-the-served-slate.md delete mode 100644 .changeset/pre/22301-verify-boots-what-serve-boots.md delete mode 100644 .changeset/pre/22301-verify-evaluate-condition-door.md delete mode 100644 .changeset/pre/22301-verify-lowered-body-door.md delete mode 100644 .changeset/pre/22301-verify-observe-writes-door.md delete mode 100644 .changeset/pre/22301-verify-public-form-door.md delete mode 100644 .changeset/pre/22301-verify-system-insert-delete-door.md delete mode 100644 .changeset/pre/22301-verify-update-doors.md delete mode 100644 .changeset/pre/22302-security-service-principal-less-text.md delete mode 100644 .changeset/pre/22305-cascade-sibling-restrict.md delete mode 100644 .changeset/pre/22306-insert-readonly-withheld-from-before-insert.md delete mode 100644 .changeset/pre/22307-cold-boot-catalog-refusal.md delete mode 100644 .changeset/pre/22310-elevated-flow-trigger-door.md delete mode 100644 .changeset/pre/22313-chunked-completion-assembles-held-parts.md delete mode 100644 .changeset/pre/22314-storage-payload-too-large-provenance.md delete mode 100644 .changeset/pre/22318-audit-plugin-host-locale.md delete mode 100644 .changeset/pre/22328-auth-seeded-hook-registered-at-ready.md delete mode 100644 .changeset/pre/22332-chunk-part-record-compare-and-set.md delete mode 100644 .changeset/pre/22335-graceful-shutdown-false-no-exit.md delete mode 100644 .changeset/pre/22340-registry-allow-environment-overlay.md delete mode 100644 .changeset/pre/22340-s3-governed-text-rename.md delete mode 100644 .changeset/pre/22343-retry-policy-try-catch-undeclared-keys-refused.md delete mode 100644 .changeset/pre/22344-get-record-id-projection.md delete mode 100644 .changeset/pre/22345-run-provenance-context-retired.md delete mode 100644 .changeset/pre/22360-declared-position-package-provenance.md delete mode 100644 .changeset/pre/22362-unscoped-run-strings.md delete mode 100644 .changeset/pre/22371-security-catalog-overlays-step.md delete mode 100644 .changeset/pre/22384-auth-session-api-getsession-input-query.md delete mode 100644 .changeset/pre/22386-spec-object-attached-on-read.md delete mode 100644 .changeset/pre/22386-validator-attached-on-read-leaf.md delete mode 100644 .changeset/pre/22387-plugin-approvals-viewer-attached-on-read.md delete mode 100644 .changeset/pre/22394-option-visible-when-evaluser-members.md delete mode 100644 .changeset/pre/22398-plugin-auth-session-redispatch.md delete mode 100644 .changeset/pre/22402-option-gate-fails-closed.md delete mode 100644 .changeset/pre/22405-serve-flat-docs-owner.md delete mode 100644 .changeset/pre/22407-flow-door-elevated-start.md delete mode 100644 .changeset/pre/22410-dev-banner-order.md delete mode 100644 .changeset/pre/22411-hatch-legacy-spelling.md delete mode 100644 .changeset/pre/22420-console-drift-responder.md delete mode 100644 .changeset/pre/22423-mcp-stdio-package-cwd.md delete mode 100644 .changeset/pre/22428-field-existence-every-spelling.md delete mode 100644 .changeset/pre/22430-api-description-anonymous-deny.md delete mode 100644 .changeset/pre/22431-unclaimed-download-signed-in.md delete mode 100644 .changeset/pre/22432-i18n-anonymous-deny.md delete mode 100644 .changeset/pre/22434-connector-provider-package-path.md delete mode 100644 .changeset/pre/22437-public-form-submit-answers-id.md delete mode 100644 .changeset/pre/22443-storage-scope-public-retired.md delete mode 100644 .changeset/pre/22443-sys-file-public-scope-rewrite.md delete mode 100644 .changeset/pre/22444-list-view-component-props-row.md delete mode 100644 .changeset/pre/22445-update-preview-stored-row.md delete mode 100644 .changeset/pre/22450-flow-refusal-translation.md delete mode 100644 .changeset/pre/22455-cbp-parent-gates-judge-master.md delete mode 100644 .changeset/pre/22455-security-serves-controlled-by-parent-write.md delete mode 100644 .changeset/pre/22459-hook-lowering-destructuring-alias.md delete mode 100644 .changeset/pre/22463-sso-domain-verification-unknown-provider.md delete mode 100644 .changeset/pre/22464-security-service-controlled-by-parent-write.md delete mode 100644 .changeset/pre/22466-refused-attach-tombstone.md delete mode 100644 .changeset/pre/22470-upload-scope-vocabulary.md delete mode 100644 .changeset/pre/22472-spec-record-approval-decision.md delete mode 100644 .changeset/pre/22473-approval-request-page.md delete mode 100644 .changeset/pre/22474-preview-binds-parent.md delete mode 100644 .changeset/pre/22477-flow-text-slot-dollar-root-refused.md delete mode 100644 .changeset/pre/22481-attached-block-served-rows.md delete mode 100644 .changeset/pre/22482-spec-projections-generated-at-publish.md delete mode 100644 .changeset/pre/22483-upgrade-guide-docs-address.md delete mode 100644 .changeset/pre/22491-chart-list-view-binds-a-dataset.md delete mode 100644 .changeset/pre/22497-master-chain-docblock.md delete mode 100644 .changeset/pre/22502-flow-binding-variable-dollar-name-refused.md delete mode 100644 .changeset/pre/22506-migrate-composes-boot.md delete mode 100644 .changeset/pre/22507-flow-options-toasts-translation.md delete mode 100644 .changeset/pre/22507-screen-copy-engine-pick-automation.md delete mode 100644 .changeset/pre/22507-screen-copy-engine-pick-cli.md delete mode 100644 .changeset/pre/22507-screen-copy-engine-pick-lint.md delete mode 100644 .changeset/pre/22507-screen-copy-engine-pick-spec.md delete mode 100644 .changeset/pre/22508-page-header-title-keeps-authored.md delete mode 100644 .changeset/pre/22510-activity-actor-name.md delete mode 100644 .changeset/pre/22514-explain-cbp-update-master-check.md delete mode 100644 .changeset/pre/22519-unreadable-header-write.md delete mode 100644 .changeset/pre/22521-config-boot-residual.md delete mode 100644 .changeset/pre/22527-activity-actor-avatar.md delete mode 100644 .changeset/pre/22530-explain-cbp-write-verb-parity.md delete mode 100644 .changeset/pre/22536-record-page-header-title.md delete mode 100644 .changeset/pre/22537-spec-record-approvals-attachments.md delete mode 100644 .changeset/pre/22550-explain-lifecycle-verb-rls-operation.md delete mode 100644 .changeset/pre/22554-migration-registry-generated-at-build.md delete mode 100644 .changeset/pre/22558-empty-approver-no-slot.md delete mode 100644 .changeset/pre/22559-approval-request-read-gate.md delete mode 100644 .changeset/pre/22560-platform-objects-approvals-visible-to-readers-form-row.md delete mode 100644 .changeset/pre/22560-plugin-approvals-declared-record-reader-tier.md delete mode 100644 .changeset/pre/22560-spec-approvals-visible-to-readers.md delete mode 100644 .changeset/pre/22565-flow-cel-unbound-root-refused.md delete mode 100644 .changeset/pre/22566-comment-reaction-record.md delete mode 100644 .changeset/pre/22568-page-slots-details-beside-tabs-refused.md delete mode 100644 .changeset/pre/22571-explain-transfer-unreadable-row-not-found.md delete mode 100644 .changeset/pre/22572-flow-binding-name-dollar-refused.md delete mode 100644 .changeset/pre/22573-sys-comment-reactions-retired.md delete mode 100644 .changeset/pre/22575-spec-approvals-action-page-member.md delete mode 100644 .changeset/pre/22576-approvals-act-domain.md delete mode 100644 .changeset/pre/22577-approvals-act-allowlist.md delete mode 100644 .changeset/pre/22578-approvals-action-page-member.md delete mode 100644 .changeset/pre/22579-migrate-telemetry-sibling.md delete mode 100644 .changeset/pre/22580-unmanaged-tables-served-boot.md delete mode 100644 .changeset/pre/22581-migrate-reads-env-files.md delete mode 100644 .changeset/pre/22585-automation-skill-value-slots.md delete mode 100644 .changeset/pre/22588-nested-refusal-sentence-zero-row-total.md delete mode 100644 .changeset/pre/22589-approval-sibling-read-gates.md delete mode 100644 .changeset/pre/22590-run-summary-acted.md delete mode 100644 .changeset/pre/22591-sealed-remedy-kind.md delete mode 100644 .changeset/pre/22593-file-field-hydration-refused.md delete mode 100644 .changeset/pre/22599-verify-hooks-run-delete-answer.md delete mode 100644 .changeset/pre/22605-spec-list-view-edit-inline-default-on.md delete mode 100644 .changeset/pre/22607-approval-opened-topic.md delete mode 100644 .changeset/pre/22611-list-view-dashboard-required-permissions.md delete mode 100644 .changeset/pre/22614-meta-view-container-read.md delete mode 100644 .changeset/pre/22615-objectql-residual-objects-served.md delete mode 100644 .changeset/pre/22616-approval-token-generic-door.md delete mode 100644 .changeset/pre/22617-cli-organization-ownership-plan.md delete mode 100644 .changeset/pre/22627-flow-screen-title-translation-text-slot.md delete mode 100644 .changeset/pre/22634-analytics-exposure-gate.md delete mode 100644 .changeset/pre/22634-spec-ledger-analytics-exposure-codes.md delete mode 100644 .changeset/pre/22636-flow-cel-record-trigger-no-object-opens.md delete mode 100644 .changeset/pre/22637-file-field-metadata-parent-derived.md delete mode 100644 .changeset/pre/22639-lint-capability-references-list-views.md delete mode 100644 .changeset/pre/22639-rest-meta-read-gate-audience.md delete mode 100644 .changeset/pre/22639-runtime-meta-legacy-object-exit.md delete mode 100644 .changeset/pre/22639-spec-audience-gate-enforced.md delete mode 100644 .changeset/pre/22640-search-api-exposure.md delete mode 100644 .changeset/pre/22642-flow-cel-record-binding.md delete mode 100644 .changeset/pre/22646-internal-field-data-door-positions.md delete mode 100644 .changeset/pre/22655-submit-redirect-docblock.md delete mode 100644 .changeset/pre/22658-skills-package.md delete mode 100644 .changeset/pre/22659-scaffold-skills-from-package.md delete mode 100644 .changeset/pre/22661-second-object-exposure.md delete mode 100644 .changeset/pre/22663-analytics-unexposed-cube-registration.md delete mode 100644 .changeset/pre/22664-spec-ledger-mcp-exposure-codes.md delete mode 100644 .changeset/pre/22668-skills-readme-install.md delete mode 100644 .changeset/pre/22677-flow-cel-record-entrance.md delete mode 100644 .changeset/pre/22682-position-form-permission-sets-picker.md delete mode 100644 .changeset/pre/22682-spec-position-bindings-by-name.md delete mode 100644 .changeset/pre/22683-approver-remedy-bulk-import.md delete mode 100644 .changeset/pre/22689-registry-package-version-stamp.md delete mode 100644 .changeset/pre/22694-import-row-hook-sentence.md delete mode 100644 .changeset/pre/22695-install-local-hotloaded.md delete mode 100644 .changeset/pre/22698-file-delegate-metadata-text.md delete mode 100644 .changeset/pre/22718-import-row-sandbox-fault.md delete mode 100644 .changeset/pre/22719-rest-write-hook-refusal-sentence.md delete mode 100644 .changeset/pre/22726-write-answer-warnings.md delete mode 100644 .changeset/pre/22727-formula-currency-result.md delete mode 100644 .changeset/pre/22729-package-install-hotloaded.md delete mode 100644 .changeset/pre/22737-relation-condition-target-exposure.md delete mode 100644 .changeset/pre/22737-spec-ledger-metadata-protocol-exposure-codes.md delete mode 100644 .changeset/pre/22737-types-exposure-arm-names-target.md delete mode 100644 .changeset/pre/22738-lookup-title-exposure.md delete mode 100644 .changeset/pre/22739-import-reference-target-exposure.md delete mode 100644 .changeset/pre/22741-sandbox-fault-import-row.md delete mode 100644 .changeset/pre/22743-field-types-currency-returntype.md delete mode 100644 .changeset/pre/22748-stack-email-sms-door.md delete mode 100644 .changeset/pre/22754-spec-webhooks-redeliver-member.md delete mode 100644 .changeset/pre/22755-webhooks-redeliver-domain.md delete mode 100644 .changeset/pre/22756-webhooks-redeliver-member.md delete mode 100644 .changeset/pre/22759-runtime-authoring-issue-path-rule.md delete mode 100644 .changeset/pre/22761-explain-read-absent-layers.md delete mode 100644 .changeset/pre/22764-rest-write-fault-withheld.md delete mode 100644 .changeset/pre/22769-versioned-http-signature.md delete mode 100644 .changeset/pre/22771-milestone-activity-kind.md delete mode 100644 .changeset/pre/22772-spec-trigger-api-inbound-hook-member.md delete mode 100644 .changeset/pre/22773-automation-hooks-domain.md delete mode 100644 .changeset/pre/22774-inbound-hook-member.md delete mode 100644 .changeset/pre/22777-core-relation-condition-sites.md delete mode 100644 .changeset/pre/22777-mcp-stdio-relation-condition-target-exposure.md delete mode 100644 .changeset/pre/22780-bulk-param-promoted-route-guidance.md delete mode 100644 .changeset/pre/22782-objectql-after-hook-abort-rolls-back.md delete mode 100644 .changeset/pre/22782-spec-hook-onerror-abort-text.md delete mode 100644 .changeset/pre/22785-import-user-field-target.md delete mode 100644 .changeset/pre/22786-withhold-milestone-row.md delete mode 100644 .changeset/pre/22787-readable-ids-leaf.md delete mode 100644 .changeset/pre/22789-sms-configured-provider.md delete mode 100644 .changeset/pre/22790-search-total-counted.md delete mode 100644 .changeset/pre/22792-explain-asker-gate.md delete mode 100644 .changeset/pre/22792-explain-read-master-leg.md delete mode 100644 .changeset/pre/22800-identity-import-exposure.md delete mode 100644 .changeset/pre/22804-redeliver-inactive-veto.md delete mode 100644 .changeset/pre/22806-uniform-hook-refusal.md delete mode 100644 .changeset/pre/22810-lowered-body-scope-submitted-refused.md delete mode 100644 .changeset/pre/22817-action-activation-unpackaged.md delete mode 100644 .changeset/pre/22821-admin-password-choice.md delete mode 100644 .changeset/pre/22823-calldata-fallback-relation-exposure.md delete mode 100644 .changeset/pre/22823-spec-ledger-runtime-exposure-codes.md delete mode 100644 .changeset/pre/22824-admin-rescue-named-slate.md delete mode 100644 .changeset/pre/22825-inbound-hook-timestamped-guidance.md delete mode 100644 .changeset/pre/22826-refuse-body-only-signature.md delete mode 100644 .changeset/pre/22835-datasource-reads-environment-only.md delete mode 100644 .changeset/pre/22837-lowered-hook-parameter-rebind.md delete mode 100644 .changeset/pre/22839-strict-body-count.md delete mode 100644 .changeset/pre/22841-cli-rule-ids-one-line-verdicts.md delete mode 100644 .changeset/pre/22848-service-slot-contracts-webhooks-trigger-api.md delete mode 100644 .changeset/pre/22853-runtime-bundle-bodyless.md delete mode 100644 .changeset/pre/22855-scoped-transaction-ambient.md delete mode 100644 .changeset/pre/22856-log-hook-unevaluable-condition.md delete mode 100644 .changeset/pre/22858-bulk-param-promoted-route-migration-text.md delete mode 100644 .changeset/pre/22860-lowered-body-parse-peel.md delete mode 100644 .changeset/pre/22861-readable-ids-composition.md delete mode 100644 .changeset/pre/22868-sandbox-fault-not-unknown-object.md delete mode 100644 .changeset/pre/22870-field-mail-association.md delete mode 100644 .changeset/pre/22870-service-mailbox.md delete mode 100644 .changeset/pre/22871-imap-mailbox-sync.md delete mode 100644 .changeset/pre/22871-mail-association-live.md delete mode 100644 .changeset/pre/22871-mail-ingest-seam.md delete mode 100644 .changeset/pre/22876-email-template-category-is-system-retired.md delete mode 100644 .changeset/pre/22877-member-selector.md delete mode 100644 .changeset/pre/22877-overlay-locale-member.md delete mode 100644 .changeset/pre/22885-sandbox-own-fault-rest-write-routes.md delete mode 100644 .changeset/pre/22886-functions-key-keeps-authored-name.md delete mode 100644 .changeset/pre/22892-predicate-related-master-detail.md delete mode 100644 .changeset/pre/22902-stack-functions-array-duplicate-name-refused.md delete mode 100644 .changeset/pre/22908-multi-reference-element-shape.md delete mode 100644 .changeset/pre/22911-blank-prefixed-project-name.md delete mode 100644 .changeset/pre/22912-scaffold-vitest-range-npm10.md delete mode 100644 .changeset/pre/22913-audit-provision-failure-error.md delete mode 100644 .changeset/pre/22934-stack-collection-duplicate-entry-refused.md delete mode 100644 .changeset/pre/22937-cli-ceremony-promotions.md delete mode 100644 .changeset/pre/22937-cli-organization-ownership-apply.md delete mode 100644 .changeset/pre/22937-core-email-template-row-definition.md delete mode 100644 .changeset/pre/22937-core-row-definition-mappers.md delete mode 100644 .changeset/pre/22944-runtime-request-kernel-first.md delete mode 100644 .changeset/pre/22957-tree-delete-behavior.md delete mode 100644 .changeset/pre/22961-plugin-audit-own-ui-state-no-activity.md delete mode 100644 .changeset/pre/22972-host-bundle-wins.md delete mode 100644 .changeset/pre/22984-boot-provision-honours-skip-sync.md delete mode 100644 .changeset/pre/22985-schema-sync-refusal-error.md delete mode 100644 .changeset/pre/22989-config-boot-api-items.md delete mode 100644 .changeset/pre/22989-host-config-metadata-door.md delete mode 100644 .changeset/pre/22994-app-plugin-converted-collections.md delete mode 100644 .changeset/pre/23000-branding-logo-describe.md delete mode 100644 .changeset/pre/23001-prerelease-artifact-window.md delete mode 100644 .changeset/pre/23004-empty-state-link.md delete mode 100644 .changeset/pre/23008-org-seed-claim-skip-automations.md delete mode 100644 .changeset/pre/23009-rls-deny-sentinel-composed-readable-ids.md delete mode 100644 .changeset/pre/23010-url-action-target-scheme-set.md delete mode 100644 .changeset/pre/23012-lookup-snake-tolerance-text.md delete mode 100644 .changeset/pre/23013-section-heading-count.md delete mode 100644 .changeset/pre/23015-one-default-record-page-per-object.md delete mode 100644 .changeset/pre/23016-page-element-api-data-source.md delete mode 100644 .changeset/pre/23022-sys-packages-managed-then-flag.md delete mode 100644 .changeset/pre/23026-retired-component-prop-refused.md delete mode 100644 .changeset/pre/23027-package-body-conversion.md delete mode 100644 .changeset/pre/23029-app-plugin-converted-readers.md delete mode 100644 .changeset/pre/23032-page-header-actions-whole-list.md delete mode 100644 .changeset/pre/23033-metadata-row-actor.md delete mode 100644 .changeset/pre/23036-visiblewhen-no-data-root.md delete mode 100644 .changeset/pre/23039-indexing-partial-example-recut.md delete mode 100644 .changeset/pre/23042-retired-after-prerelease-stamp.md delete mode 100644 .changeset/pre/23047-me-permissions-outer-catch-closed.md delete mode 100644 .changeset/pre/23050-s11-form-liveness-citations.md delete mode 100644 .changeset/pre/23050-s12-crud-liveness-citations.md delete mode 100644 .changeset/pre/23050-s12-crud-routes-move.md delete mode 100644 .changeset/pre/23050-s13a-meta-collection-liveness-citations.md delete mode 100644 .changeset/pre/23050-s13a-meta-collection-routes-move.md delete mode 100644 .changeset/pre/23050-s13b-meta-item-read-liveness-citations.md delete mode 100644 .changeset/pre/23050-s13b-meta-item-read-routes-move.md delete mode 100644 .changeset/pre/23050-s13c-meta-item-write-routes-move.md delete mode 100644 .changeset/pre/23050-s13d-meta-item-history-routes-move.md delete mode 100644 .changeset/pre/23050-s13e-meta-object-state-published-move.md delete mode 100644 .changeset/pre/23054-org-seed-claim-paged.md delete mode 100644 .changeset/pre/23065-referenceless-tree.md delete mode 100644 .changeset/pre/23068-migrate-actor.md delete mode 100644 .changeset/pre/23072-dispatcher-meta-query-decision.md delete mode 100644 .changeset/pre/23072-meta-doors-closed-sets.md delete mode 100644 .changeset/pre/23073-list-member.md delete mode 100644 .changeset/pre/23074-member-envelope.md delete mode 100644 .changeset/pre/23077-url-target-doors-scheme-set.md delete mode 100644 .changeset/pre/23082-view-data-api-provider-retired.md delete mode 100644 .changeset/pre/23083-openapi-read-decorations.md delete mode 100644 .changeset/pre/23086-actor-family.md delete mode 100644 .changeset/pre/23088-verify-bootstack-metadata-plugin.md delete mode 100644 .changeset/pre/23090-app-plugin-hooks-translations.md delete mode 100644 .changeset/pre/23093-cbp-middle-master-floor.md delete mode 100644 .changeset/pre/23096-save-door-protection.md delete mode 100644 .changeset/pre/23100-cbp-one-required-master.md delete mode 100644 .changeset/pre/23103-page-action-row-on-success-and-executor.md delete mode 100644 .changeset/pre/23119-save-door-lock-envelope.md delete mode 100644 .changeset/pre/23120-server-composed-protection-drop.md delete mode 100644 .changeset/pre/23122-outbox-claim-credential-read-back.md delete mode 100644 .changeset/pre/23124-comment-edit-stamp.md delete mode 100644 .changeset/pre/23134-related-list-parent-data-source.md delete mode 100644 .changeset/pre/23142-settings-anonymous-floor.md delete mode 100644 .changeset/pre/23143-storage-token-refusal-401.md delete mode 100644 .changeset/pre/23145-tombstone-removal-version.md delete mode 100644 .changeset/pre/23147-component-props-every-finding-refused.md delete mode 100644 .changeset/pre/23149-view-container-reset-heals-expansions.md delete mode 100644 .changeset/pre/23152-import-invite-redirect-to.md delete mode 100644 .changeset/pre/23157-server-authored-ui-text-names-what-and-who.md delete mode 100644 .changeset/pre/23158-ai-store-caller-context.md delete mode 100644 .changeset/pre/23165-direct-save-resolves-package.md delete mode 100644 .changeset/pre/23168-page-action-row-name-leg-url-target.md delete mode 100644 .changeset/pre/23168-page-action-row-on-success-beside-executor.md delete mode 100644 .changeset/pre/23170-rolling-presets-close-on-today.md delete mode 100644 .changeset/pre/23174-nested-fault-writer.md delete mode 100644 .changeset/pre/23175-dev-seed-workspace-timezone.md delete mode 100644 .changeset/pre/23178-auth-gate-raw-host-families.md delete mode 100644 .changeset/pre/23178-settings-auth-gate.md delete mode 100644 .changeset/pre/23183-app-item-read-by-package-id.md delete mode 100644 .changeset/pre/23189-identity-objects-default-organization.md delete mode 100644 .changeset/pre/23190-auth-gate-cli-sites.md delete mode 100644 .changeset/pre/23198-container-expansion-reconcile.md delete mode 100644 .changeset/pre/23198-orphan-adoption-keeps-overlays.md delete mode 100644 .changeset/pre/23207-related-list-binding-split.md delete mode 100644 .changeset/pre/23210-cbp-extension-merged-master.md delete mode 100644 .changeset/pre/23214-dispatcher-query-arity.md delete mode 100644 .changeset/pre/23214-hono-query-multiplicity.md delete mode 100644 .changeset/pre/23215-inline-forward-parse.md delete mode 100644 .changeset/pre/23216-routes-retire-text.md delete mode 100644 .changeset/pre/23217-core-request-execution-context.md delete mode 100644 .changeset/pre/23217-s2-rest-door.md delete mode 100644 .changeset/pre/23217-s3-settings-door.md delete mode 100644 .changeset/pre/23217-s4-storage-door.md delete mode 100644 .changeset/pre/23217-s5-auth-me-door.md delete mode 100644 .changeset/pre/23221-cascade-delete-hook-signal.md delete mode 100644 .changeset/pre/23224-guest-grants-channel.md delete mode 100644 .changeset/pre/23225-d2b-anonymous-elevated-flow-refused.md delete mode 100644 .changeset/pre/23225-domain-floor-anonymous-deny.md delete mode 100644 .changeset/pre/23225-ui-data-domain-floor.md delete mode 100644 .changeset/pre/23226-guest-owner-stamp.md delete mode 100644 .changeset/pre/23228-record-share-guest-recipient-retired.md delete mode 100644 .changeset/pre/23231-walled-environment-metadata-writes.md delete mode 100644 .changeset/pre/23233-permission-echo-names-runtime-package.md delete mode 100644 .changeset/pre/23235-export-group-key-classifier.md delete mode 100644 .changeset/pre/23245-dispatcher-auth-gate-outage.md delete mode 100644 .changeset/pre/23254-auth-org-record-page-option.md delete mode 100644 .changeset/pre/23255-verify-flows-system-arm.md delete mode 100644 .changeset/pre/23259-permission-set-owner-mismatch-refused.md delete mode 100644 .changeset/pre/23260-builtin-token-value-judged.md delete mode 100644 .changeset/pre/23265-dispatcher-delete-expected-version.md delete mode 100644 .changeset/pre/23268-external-sharing-model-retired.md delete mode 100644 .changeset/pre/23268-skills-lint-rules-row.md delete mode 100644 .changeset/pre/23269-v18-stored-row-fates.md delete mode 100644 .changeset/pre/23272-export-cbp-master-predicate.md delete mode 100644 .changeset/pre/23276-upgrade-skill-skills-step.md delete mode 100644 .changeset/pre/23279-parent-read-gate-silent-skip.md delete mode 100644 .changeset/pre/23280-host-fate-supplement.md delete mode 100644 .changeset/pre/23281-comment-reactions-upgrade-conversion.md delete mode 100644 .changeset/pre/23284-notification-takes-record-organization.md delete mode 100644 .changeset/pre/23287-api-key-account-posture.md delete mode 100644 .changeset/pre/23291-packageless-read-serves-save-address.md delete mode 100644 .changeset/pre/23293-client-delete-package.md delete mode 100644 .changeset/pre/23297-package-axis-delete-draft.md delete mode 100644 .changeset/pre/23298-env-kernel-registry-write-through.md delete mode 100644 .changeset/pre/23306-storage-attach-verb.md delete mode 100644 .changeset/pre/23307-mail-attachments.md delete mode 100644 .changeset/pre/23308-security-catalog-echo.md delete mode 100644 .changeset/pre/23310-serve-org-record-page.md delete mode 100644 .changeset/pre/23312-permission-set-owner-binding-build-and-registration.md delete mode 100644 .changeset/pre/23316-refused-flow-registration-verdict.md delete mode 100644 .changeset/pre/23317-stored-preview-gaps.md delete mode 100644 .changeset/pre/23325-record-share-remedy-text.md delete mode 100644 .changeset/pre/23328-share-link-refusal-order.md delete mode 100644 .changeset/pre/23332-page-action-row-operation.md delete mode 100644 .changeset/pre/23335-held-admin-vendor-doors-refused.md delete mode 100644 .changeset/pre/23335-held-platform-admin-refused.md delete mode 100644 .changeset/pre/23338-flow-binding-d3-replacement-reads.md delete mode 100644 .changeset/pre/23343-plugin-auth-import-account-invitation.md delete mode 100644 .changeset/pre/23343-plugin-email-account-invitation-template.md delete mode 100644 .changeset/pre/23347-i18n-plugin-mounts-retired.md delete mode 100644 .changeset/pre/23350-cli-meta-package-flag.md delete mode 100644 .changeset/pre/23354-seed-claim-seed-rows.md delete mode 100644 .changeset/pre/23354-seed-landed-rows.md delete mode 100644 .changeset/pre/23357-page-component-view-data-api-refused.md delete mode 100644 .changeset/pre/23359-uncomposed-security-remedy.md delete mode 100644 .changeset/pre/23365-hono-declared-envelope.md delete mode 100644 .changeset/pre/23367-api-key-lifecycle.md delete mode 100644 .changeset/pre/23371-share-link-messages-audience-inputs.md delete mode 100644 .changeset/pre/23372-share-link-email-audience-uniform-refusal.md delete mode 100644 .changeset/pre/23374-automation-references-envelopes.md delete mode 100644 .changeset/pre/23385-walled-accepted-defaults.md delete mode 100644 .changeset/pre/23387-two-master-names-release.md delete mode 100644 .changeset/pre/23388-page-component-rules-runtime-door.md delete mode 100644 .changeset/pre/23391-stored-failed-truth.md delete mode 100644 .changeset/pre/23394-branding-logo-live.md delete mode 100644 .changeset/pre/23395-page-form-empty-state.md delete mode 100644 .changeset/pre/23397-ceremony-builtin-positions.md delete mode 100644 .changeset/pre/23399-ceremony-not-declared-column-drop.md delete mode 100644 .changeset/pre/23400-ownership-occupancy-gate.md delete mode 100644 .changeset/pre/23403-audience-anchor-binding-gate-retired.md delete mode 100644 .changeset/pre/23404-permission-set-description-liveness.md delete mode 100644 .changeset/pre/23406-sealed-first-draft-remedy.md delete mode 100644 .changeset/pre/23415-drop-storage-other-rows.md delete mode 100644 .changeset/pre/23416-runtime-owner-overlay-refusal.md delete mode 100644 .changeset/pre/23421-retire-page-aliases-at-18.md delete mode 100644 .changeset/pre/23427-page-action-row-declarative-update-rules.md delete mode 100644 .changeset/pre/23429-sys-metadata-family-organization-column-retired.md delete mode 100644 .changeset/pre/23429-v18-boot-refusal.md delete mode 100644 .changeset/pre/23430-grant-permission-set-id-retired.md delete mode 100644 .changeset/pre/23434-component-read-points-part-1.md delete mode 100644 .changeset/pre/23434-component-read-points-part-2.md delete mode 100644 .changeset/pre/23434-component-read-points-part-3.md delete mode 100644 .changeset/pre/23434-objectui-read-points-to-ledger.md delete mode 100644 .changeset/pre/23434-sha-less-read-points.md delete mode 100644 .changeset/pre/23447-page-component-view-data-judge-deleted.md delete mode 100644 .changeset/pre/23455-key-admission-posture-required.md delete mode 100644 .changeset/pre/23462-dispatcher-patch-expected-version.md delete mode 100644 .changeset/pre/23473-uninstall-shared-object-refusal.md delete mode 100644 .changeset/pre/23474-email-refusal-cost.md delete mode 100644 .changeset/pre/23477-revoke-reason-vocabulary.md delete mode 100644 .changeset/pre/23479-meta-delete-noop-line.md delete mode 100644 .changeset/pre/23480-cli-meta-register-force.md delete mode 100644 .changeset/pre/23487-retire-overdue-conversions.md delete mode 100644 .changeset/pre/23488-admin-scope-binding-authoring-retired.md delete mode 100644 .changeset/pre/23491-patch-data-request-gate.md delete mode 100644 .changeset/pre/23492-empty-version-token-refused.md delete mode 100644 .changeset/pre/23495-draft-doors-one-address.md delete mode 100644 .changeset/pre/23497-public-form-section-i18n.md delete mode 100644 .changeset/pre/23501-automation-door-package-remedy.md delete mode 100644 .changeset/pre/23505-extension-refusal-409.md delete mode 100644 .changeset/pre/23509-mcp-coded-refusal.md delete mode 100644 .changeset/pre/23511-duplicate-package-unprefixed-object.md delete mode 100644 .changeset/pre/23513-grant-remedy-plan-file.md delete mode 100644 .changeset/pre/23514-sentinel-owner-save.md delete mode 100644 .changeset/pre/23527-duplicate-permission-field-keys.md delete mode 100644 .changeset/pre/23528-exposure-refusal-code.md delete mode 100644 .changeset/pre/23529-mcp-action-tools-withhold-permission-details.md delete mode 100644 .changeset/pre/23531-hono-conflict-body.md delete mode 100644 .changeset/pre/23531-hono-conflict-current-record.md delete mode 100644 .changeset/pre/23532-hono-undeclared-500-leak-filter.md delete mode 100644 .changeset/pre/23537-duplicate-package-reference-collision.md delete mode 100644 .changeset/pre/23538-duplicate-names-in-step.md delete mode 100644 .changeset/pre/23539-references-permission-fields-keys.md delete mode 100644 .changeset/pre/23542-walled-binding-remedy.md delete mode 100644 .changeset/pre/23543-f1-retire-mongo-driver-alias.md delete mode 100644 .changeset/pre/23543-f2-job-retry-policy-without-attempts-refused.md delete mode 100644 .changeset/pre/23543-f3-retire-wait-connector-config-lift.md delete mode 100644 .changeset/pre/23543-f4-visibility-alias-keys-retired.md delete mode 100644 .changeset/pre/23543-f5-http-callout-node-types-reserved.md delete mode 100644 .changeset/pre/23543-f5-jsx-retire-page-kind-jsx.md delete mode 100644 .changeset/pre/23546-trail-doors-setup-access.md delete mode 100644 .changeset/pre/23549-fls-empty-object-part.md delete mode 100644 .changeset/pre/23553-default-record-page-remedy.md delete mode 100644 .changeset/pre/23556-approval-request-names-record.md delete mode 100644 .changeset/pre/23558-ceremony-dialect-resolver.md delete mode 100644 .changeset/pre/23561-duplicate-object-name-token.md delete mode 100644 .changeset/pre/23562-read-only-arms-journal-mode-note.md delete mode 100644 .changeset/pre/23566-picklist-translation-refs.md delete mode 100644 .changeset/pre/23573-serve-no-storage-loud.md delete mode 100644 .changeset/pre/23574-data-request-contract-remainder.md delete mode 100644 .changeset/pre/23577-sealed-write-one-sentence.md delete mode 100644 .changeset/pre/23586-occ-conflict-caller-visibility.md delete mode 100644 .changeset/pre/23587-hono-structured-arm-fields.md delete mode 100644 .changeset/pre/23587-hono-unique-violation.md delete mode 100644 .changeset/pre/23594-subject-title-reader-fls.md delete mode 100644 .changeset/pre/23598-plugin-auth-import-invite-delivery-failure.md delete mode 100644 .changeset/pre/23609-legacy-org-read-seam.md delete mode 100644 .changeset/pre/23610-page-isdefault-template-slots.md delete mode 100644 .changeset/pre/23610-skills-pages-contract.md delete mode 100644 .changeset/pre/23615-grant-unique-index-skill.md delete mode 100644 .changeset/pre/23618-approval-title-family-served-fields.md delete mode 100644 .changeset/pre/23622-read-only-arms-journal-note-remainder.md delete mode 100644 .changeset/pre/23623-no-server-banner-urls.md delete mode 100644 .changeset/pre/23630-promote-doors-d99.md delete mode 100644 .changeset/pre/23632-form-section-labels.md delete mode 100644 .changeset/pre/23633-unrecognised-database-url-scheme.md delete mode 100644 .changeset/pre/23634-stack-datasource-default-refused.md delete mode 100644 .changeset/pre/23635-sqlite-wasm-background-flush-failure.md delete mode 100644 .changeset/pre/23636-no-storage-hydration-error.md delete mode 100644 .changeset/pre/23637-lookup-target-title-row-access.md delete mode 100644 .changeset/pre/23639-readable-ids-drivers.md delete mode 100644 .changeset/pre/23644-failed-boot-rollback.md delete mode 100644 .changeset/pre/23645-delete-heal-boot-hydrated.md delete mode 100644 .changeset/pre/23646-adopt-orphans-object-registry-owner.md delete mode 100644 .changeset/pre/23648-sqlite-wasm-sql-driver.md delete mode 100644 .changeset/pre/23649-datasource-mapping-before-sync.md delete mode 100644 .changeset/pre/23650-page-header-show-star-retired.md delete mode 100644 .changeset/pre/23657-draft-save-one-package-draft.md delete mode 100644 .changeset/pre/23658-client-publish-package.md delete mode 100644 .changeset/pre/23661-runtime-object-metadata-copy.md delete mode 100644 .changeset/pre/23666-ceremony-platform-global-column-drop.md delete mode 100644 .changeset/pre/23673-parent-gate-readable-ids.md delete mode 100644 .changeset/pre/23679-memory-readable-ids-hashed-once.md delete mode 100644 .changeset/pre/23684-drop-gate-host-tables.md delete mode 100644 .changeset/pre/23686-dispatcher-unique-violation.md delete mode 100644 .changeset/pre/23689-env-kernel-runtime-object-copy.md delete mode 100644 .changeset/pre/23690-adopt-orphans-draft-view.md delete mode 100644 .changeset/pre/23696-sqlite-uncreatable-path.md delete mode 100644 .changeset/pre/23701-mcp-unique-violation.md delete mode 100644 .changeset/pre/23702-sandbox-relay-duplicate.md delete mode 100644 .changeset/pre/23710-liveness-print-flow-messages.md delete mode 100644 .changeset/pre/23712-draft-save-d99-address.md delete mode 100644 .changeset/pre/23714-unique-violation-arm-relay.md delete mode 100644 .changeset/pre/23715-strict-draft-read-address.md delete mode 100644 .changeset/pre/23718-sandbox-plain-object-throw.md delete mode 100644 .changeset/pre/23723-sandbox-unreadable-rejection.md delete mode 100644 .changeset/pre/23726-read-only-arms-remainder.md delete mode 100644 .changeset/pre/23731-activity-changed-fields.md delete mode 100644 .changeset/pre/23732-attachment-readable-ids.md delete mode 100644 .changeset/pre/23734-heavy-flow-bounded-modal.md delete mode 100644 .changeset/pre/23735-reference-deleted-when.md delete mode 100644 .changeset/pre/23736-record-id-deleted-when.md delete mode 100644 .changeset/pre/23737-uncreatable-db-dir-refusal.md delete mode 100644 .changeset/pre/23740-form-section-columns-no-default.md delete mode 100644 .changeset/pre/23743-no-server-announcements.md delete mode 100644 .changeset/pre/23744-kernel-only-lifetime.md delete mode 100644 .changeset/pre/23745-record-json-block.md delete mode 100644 .changeset/pre/23753-navigation-record-surface.md delete mode 100644 .changeset/pre/23756-section-translation-tsdoc.md delete mode 100644 .changeset/pre/23764-unsupported-scheme-no-url.md delete mode 100644 .changeset/pre/23765-opaque-scheme-refusal.md delete mode 100644 .changeset/pre/23768-datasources-default-host-reserved.md delete mode 100644 .changeset/pre/23772-principal-session-mint.md delete mode 100644 .changeset/pre/23775-flow-save-door-new-refusal.md delete mode 100644 .changeset/pre/23787-api-skill-retired-route-door.md delete mode 100644 .changeset/pre/23788-blank-scaffold-npm10-vitest-override.md delete mode 100644 .changeset/pre/23789-page-props-save-door-sentences.md delete mode 100644 .changeset/pre/23792-memory-url-refusal-no-url.md delete mode 100644 .changeset/pre/23794-file-url-filename-rule.md delete mode 100644 .changeset/pre/23802-setup-copy.md delete mode 100644 .changeset/pre/23803-setup-nav-user-entries.md delete mode 100644 .changeset/pre/23804-own-ui-state-no-ledger-row.md delete mode 100644 .changeset/pre/23805-scaffold-vitest-override.md delete mode 100644 .changeset/pre/23806-metadata-core-vitest-peer.md delete mode 100644 .changeset/pre/23808-type-descriptions-plain.md delete mode 100644 .changeset/pre/23809-select-option-kind.md delete mode 100644 .changeset/pre/23813-minted-session-no-refresh.md delete mode 100644 .changeset/pre/23816-turso-remote-ownership-ceremony.md delete mode 100644 .changeset/pre/23820-dev-watch-compile-refusal.md delete mode 100644 .changeset/pre/23826-ceremony-report-file-reserved.md delete mode 100644 .changeset/pre/23829-setup-copy-services.md delete mode 100644 .changeset/pre/23838-identity-feeds-off.md delete mode 100644 .changeset/pre/23839-settings-warning-banners.md delete mode 100644 .changeset/pre/23842-automation-stamps-unbound-cause.md delete mode 100644 .changeset/pre/23842-flow-runtime-state-unbound-cause.md delete mode 100644 .changeset/pre/23847-turso-remote-not-null.md delete mode 100644 .changeset/pre/23848-migrate-apply-turso-remote.md delete mode 100644 .changeset/pre/23852-setup-empty-states.md delete mode 100644 .changeset/pre/23853-form-help-texts-plain.md delete mode 100644 .changeset/pre/23855-settings-banner-translation-slot.md delete mode 100644 .changeset/pre/23857-comment-parent-pair.md delete mode 100644 .changeset/pre/23858-serve-state-file-directory.md delete mode 100644 .changeset/pre/23858-uncreatable-directory-refusal.md delete mode 100644 .changeset/pre/23859-client-runtime-status-type.md delete mode 100644 .changeset/pre/23863-explicit-sqlite-non-file-refusal.md delete mode 100644 .changeset/pre/23865-next-steps-after-failed-install.md delete mode 100644 .changeset/pre/23877-visibility-alias-fold-unexported.md delete mode 100644 .changeset/pre/23887-anchor-docblock-guest-no-door.md delete mode 100644 .changeset/pre/23889-approval-list-display-labels.md delete mode 100644 .changeset/pre/23890-one-audit-entry.md delete mode 100644 .changeset/pre/23891-permission-set-managed-by-retired.md delete mode 100644 .changeset/pre/23898-sqlite-refusal-remedy.md delete mode 100644 .changeset/pre/23899-read-only-arms-four-more.md delete mode 100644 .changeset/pre/23903-flow-save-reintroduced-retired.md delete mode 100644 .changeset/pre/23904-installed-page-retired-declarations-convert-at-load.md delete mode 100644 .changeset/pre/23909-keys-every-verb-anonymous-floor.md delete mode 100644 .changeset/pre/23910-validation-previous-insert.md delete mode 100644 .changeset/pre/23914-cloud-connection-body-refusal.md delete mode 100644 .changeset/pre/23914-hono-body-refusal.md delete mode 100644 .changeset/pre/23914-hono-server-body-refusal.md delete mode 100644 .changeset/pre/23919-builtin-position-copy.md delete mode 100644 .changeset/pre/23925-remote-plan-remedy.md delete mode 100644 .changeset/pre/23928-parallel-failed-branch-steps.md delete mode 100644 .changeset/pre/23932-declared-client-refusal-log.md delete mode 100644 .changeset/pre/23933-installed-apps-packages-filter.md delete mode 100644 .changeset/pre/23944-revoked-key-refusal-text.md delete mode 100644 .changeset/pre/23945-invite-role-cap-codes.md delete mode 100644 .changeset/pre/23946-email-template-loader-boot-order.md delete mode 100644 .changeset/pre/23948-declared-rule-mirrors-advisory.md delete mode 100644 .changeset/pre/23952-declared-sharing-rules-registry.md delete mode 100644 .changeset/pre/23957-invite-role-options-per-caller.md delete mode 100644 .changeset/pre/23963-resume-gone-run-settled.md delete mode 100644 .changeset/pre/23964-record-surface-docblock.md delete mode 100644 .changeset/pre/23967-view-label-not-invented.md delete mode 100644 .changeset/pre/23968-print-local-loopback-host.md delete mode 100644 .changeset/pre/23972-installed-package-source.md delete mode 100644 .changeset/pre/23974-package-doors-convert-forward.md delete mode 100644 .changeset/pre/23978-approval-notification-subject-name.md delete mode 100644 .changeset/pre/23983-previous-null-on-insert.md delete mode 100644 .changeset/pre/23993-reset-revoked-key-restore.md delete mode 100644 .changeset/pre/24001-conversion-conflict-channel-retired.md delete mode 100644 .changeset/pre/24002-start-mcp-stdio-early-initialize.md delete mode 100644 .changeset/pre/24007-approval-request-title-label.md delete mode 100644 .changeset/pre/24016-org-invite-action.md delete mode 100644 .changeset/pre/24022-datasource-admin-no-server.md delete mode 100644 .changeset/pre/24023-keys-actions-405-allow.md delete mode 100644 .changeset/pre/24024-notifications-share-links-anonymous-floor.md delete mode 100644 .changeset/pre/24025-put-patch-request-id.md delete mode 100644 .changeset/pre/24029-protocol-range-union.md delete mode 100644 .changeset/pre/24030-install-overwrite-readonly-package.md delete mode 100644 .changeset/pre/24031-exposure-gate-one-object-read.md delete mode 100644 .changeset/pre/24032-plugin-auth-body-refusal.md delete mode 100644 .changeset/pre/24040-reseed-converted-body.md delete mode 100644 .changeset/pre/24041-duplicate-install-refusal.md delete mode 100644 .changeset/pre/24042-hono-undeclared-body-refusal.md delete mode 100644 .changeset/pre/24043-http-request-body-tsdoc.md delete mode 100644 .changeset/pre/24047-action-param-options-comments.md delete mode 100644 .changeset/pre/24059-unmanaged-sweep-sys-packages.md delete mode 100644 .changeset/pre/24060-activity-gate-named-parents.md delete mode 100644 .changeset/pre/24061-auth-prelude-queries.md delete mode 100644 .changeset/pre/24062-authored-install-scope-project.md delete mode 100644 .changeset/pre/24063-view-liveness-evidence.md delete mode 100644 .changeset/pre/24065-ooo-notification-person-names.md delete mode 100644 .changeset/pre/24070-withdraw-meta-object-copies.md delete mode 100644 .changeset/pre/24082-invite-role-cap-codes.md delete mode 100644 .changeset/pre/24086-install-primitive-parses-manifest.md delete mode 100644 .changeset/pre/24087-search-title-namefield-first.md delete mode 100644 .changeset/pre/24090-db-dir-dangling-symlink.md delete mode 100644 .changeset/pre/24091-serve-runtime-state-write-warning.md delete mode 100644 .changeset/pre/24097-metadata-protocol-by-name-disabled-package.md delete mode 100644 .changeset/pre/24097-rest-exposure-gate-disabled-package.md delete mode 100644 .changeset/pre/24097-runtime-meta-object-disabled-package.md delete mode 100644 .changeset/pre/24120-migrate-plan-declared-datasources.md delete mode 100644 .changeset/pre/24126-system-overview-metric-height.md delete mode 100644 .changeset/pre/24129-blueprint-view-filters-sort.md delete mode 100644 .changeset/pre/24130-form-submit-docstring.md delete mode 100644 .changeset/pre/24131-core-required-emptiness.md delete mode 100644 .changeset/pre/24131-objectql-required-emptiness-import.md delete mode 100644 .changeset/pre/24131-rest-public-form-required.md delete mode 100644 .changeset/pre/24134-console-pin-5e15928b4631.md delete mode 100644 .changeset/pre/24137-oauth-token-organization.md delete mode 100644 .changeset/pre/24140-line-items-columns-hydrate.md delete mode 100644 .changeset/pre/24142-settings-organization-noun.md delete mode 100644 .changeset/pre/24143-export-hidden-columns.md delete mode 100644 .changeset/pre/24148-cloud-connection-setup-access.md delete mode 100644 .changeset/pre/24150-cloud-connection-runtime-version.md delete mode 100644 .changeset/pre/24151-body-after-anonymous-floor.md delete mode 100644 .changeset/pre/24154-invitation-role-label.md delete mode 100644 .changeset/pre/24162-record-json-retired.md delete mode 100644 .changeset/pre/24165-mcp-token-organization-id.md delete mode 100644 .changeset/pre/24166-oauth-consent-organization.md delete mode 100644 .changeset/pre/24179-step18-rationale-tracker-ids.md delete mode 100644 .changeset/pre/24182-extender-withdrawal-restores-owner.md delete mode 100644 .changeset/pre/24183-export-json-fields.md delete mode 100644 .changeset/pre/24186-mcp-verifier-keyring.md delete mode 100644 .changeset/pre/24187-docblock-mechanisms.md delete mode 100644 .changeset/pre/24190-line-items-hydrated-check.md delete mode 100644 .changeset/pre/24191-serve-mcp-stdio-early-initialize.md delete mode 100644 .changeset/pre/24192-start-compile-stdout-purity.md delete mode 100644 .changeset/pre/24195-page-template-entry-reason.md delete mode 100644 .changeset/pre/24196-auth-session-api-declared-fields.md delete mode 100644 .changeset/pre/24197-formula-traversal-subject.md delete mode 100644 .changeset/pre/24197-lint-formula-one-hop.md delete mode 100644 .changeset/pre/24197-objectql-formula-one-hop.md delete mode 100644 .changeset/pre/24197-objectql-serve-formula-field.md delete mode 100644 .changeset/pre/24197-runtime-hook-title-one-hop.md delete mode 100644 .changeset/pre/24197-spec-blueprint-one-hop-title.md delete mode 100644 .changeset/pre/24198-blueprint-option-kind-stage-field.md delete mode 100644 .changeset/pre/24210-metadata-protocol-by-name-disabled-non-object.md delete mode 100644 .changeset/pre/24210-metadata-protocol-drafts-disabled-package.md delete mode 100644 .changeset/pre/24210-plugin-security-echo-binding-disabled-package.md delete mode 100644 .changeset/pre/24210-rest-event-diff-doors-disabled-package.md delete mode 100644 .changeset/pre/24210-rest-layered-doors-disabled-package.md delete mode 100644 .changeset/pre/24210-rest-published-state-event-body-disabled-package.md delete mode 100644 .changeset/pre/24210-runtime-layered-doors-disabled-package.md delete mode 100644 .changeset/pre/24210-runtime-meta-disabled-package-doors.md delete mode 100644 .changeset/pre/24210-runtime-published-disabled-package.md delete mode 100644 .changeset/pre/24222-get-session-readers-declared.md delete mode 100644 .changeset/pre/24228-actions-every-verb-anonymous-floor.md delete mode 100644 .changeset/pre/24230-marketplace-installed-list-retired.md delete mode 100644 .changeset/pre/24235-audit-write-locale-seam.md delete mode 100644 .changeset/pre/24239-public-form-grant-skill.md delete mode 100644 .changeset/pre/24243-plugin-auth-hook-refusal-codes.md delete mode 100644 .changeset/pre/24245-head-dispatched-as-get.md delete mode 100644 .changeset/pre/24251-mcp-anonymous-first.md delete mode 100644 .changeset/pre/24252-endpoint-fallback-body-after-match.md delete mode 100644 .changeset/pre/24253-client-publish-drafts-backfill.md delete mode 100644 .changeset/pre/24253-metadata-protocol-publish-backfill.md delete mode 100644 .changeset/pre/24253-runtime-publish-drafts-backfill.md delete mode 100644 .changeset/pre/24253-spec-publish-drafts-backfill-applied.md delete mode 100644 .changeset/pre/24254-plugin-auth-hook-codes.md delete mode 100644 .changeset/pre/24264-approval-request-strip-flow-label.md delete mode 100644 .changeset/pre/24265-app-page-record-binding.md delete mode 100644 .changeset/pre/24266-record-blocks-empty-state.md delete mode 100644 .changeset/pre/24268-declarative-update-undeclared-params-refused.md delete mode 100644 .changeset/pre/24271-org-rename-door.md delete mode 100644 .changeset/pre/24273-transfer-ownership-honest.md delete mode 100644 .changeset/pre/24281-approval-request-strip-step-label.md delete mode 100644 .changeset/pre/24282-ceremony-null-org-guard.md delete mode 100644 .changeset/pre/24283-console-pin-0707b4ae3712.md delete mode 100644 .changeset/pre/24284-search-all-internal-skip.md delete mode 100644 .changeset/pre/24284-search-empty-set-refused.md delete mode 100644 .changeset/pre/24287-hono-stream-string-verbatim.md delete mode 100644 .changeset/pre/24289-inprocess-hook-ctx-title.md delete mode 100644 .changeset/pre/24289-named-hook-handler-ctx-title.md delete mode 100644 .changeset/pre/24291-cbp-docblock-tense.md delete mode 100644 .changeset/pre/24293-filter-token-message.md delete mode 100644 .changeset/pre/24298-draft-key-provenance.md delete mode 100644 .changeset/pre/24299-api-key-read-outage.md delete mode 100644 .changeset/pre/24300-api-binding-rendered.md delete mode 100644 .changeset/pre/24301-seed-null-organization-holder-refused.md delete mode 100644 .changeset/pre/24309-approval-current-step-label.md delete mode 100644 .changeset/pre/24314-extension-no-injected-columns.md delete mode 100644 .changeset/pre/24316-formula-hop-family-refusal.md delete mode 100644 .changeset/pre/24326-main-region-width-refused.md delete mode 100644 .changeset/pre/24327-runtime-this-system-copy.md delete mode 100644 .changeset/pre/24328-history-source-describe.md delete mode 100644 .changeset/pre/24332-nav-item-api-data-source.md delete mode 100644 .changeset/pre/24335-file-hydration-no-denial.md delete mode 100644 .changeset/pre/24338-object-default-row-scope.md delete mode 100644 .changeset/pre/24340-default-row-scope-live.md delete mode 100644 .changeset/pre/24340-rls-object-default-scope.md delete mode 100644 .changeset/pre/24343-8b0c-activity-never-null.md delete mode 100644 .changeset/pre/24343-8b0f-grant-on-first-membership.md delete mode 100644 .changeset/pre/24343-producers-name-organization.md delete mode 100644 .changeset/pre/24348-import-control-opt-out-gate.md delete mode 100644 .changeset/pre/24348-import-opt-out-describes.md delete mode 100644 .changeset/pre/24349-page-walk-panel-children.md delete mode 100644 .changeset/pre/24358-family-hydration-refusal.md delete mode 100644 .changeset/pre/24360-metadata-protocol-this-system.md delete mode 100644 .changeset/pre/24360-objectql-this-system.md delete mode 100644 .changeset/pre/24360-platform-objects-this-system.md delete mode 100644 .changeset/pre/24366-storage-gate-credential.md delete mode 100644 .changeset/pre/24368-services-this-system-copy.md delete mode 100644 .changeset/pre/24371-data-read-one-overlay-read.md delete mode 100644 .changeset/pre/24380-import-undo-controls.md delete mode 100644 .changeset/pre/24380-undo-response-failures.md delete mode 100644 .changeset/pre/24384-platform-objects-org-admin-reach.md delete mode 100644 .changeset/pre/24384-spec-org-reach-rows.md delete mode 100644 .changeset/pre/24387-port-zero-busy-warning.md delete mode 100644 .changeset/pre/24394-page-action-row-patch-retired.md delete mode 100644 .changeset/pre/24395-draft-changes-surface.md delete mode 100644 .changeset/pre/24395-draft-changes-writer.md delete mode 100644 .changeset/pre/24395-http-save-surface.md delete mode 100644 .changeset/pre/24400-bound-action-foreign-object.md delete mode 100644 .changeset/pre/24404-cloud-connection-this-system.md delete mode 100644 .changeset/pre/24404-mcp-this-system.md delete mode 100644 .changeset/pre/24405-mcp-connect-agent-subtitle.md delete mode 100644 .changeset/pre/24405-platform-objects-this-system.md delete mode 100644 .changeset/pre/24406-spec-this-system-copy.md delete mode 100644 .changeset/pre/24409-migrate-boot-no-dev-seed.md delete mode 100644 .changeset/pre/24410-lint-formula-hop-undeclared-field.md delete mode 100644 .changeset/pre/24410-objectql-formula-hop-sibling-isolation.md delete mode 100644 .changeset/pre/24411-unstored-field-removal.md delete mode 100644 .changeset/pre/24421-sharing-rule-declared-name-guard.md delete mode 100644 .changeset/pre/24456-client-platform-objects-this-system.md delete mode 100644 .changeset/pre/24457-console-pin-4016370af3c9.md delete mode 100644 .changeset/pre/5082-declared-index-unique-scope.md delete mode 100644 .changeset/pre/9139-cbp-master-detail-required-error.md delete mode 100644 .changeset/pre/9272-grant-window-order-refused.md delete mode 100644 .changeset/pre/9272-user-page-grant-window-columns.md delete mode 100644 .changeset/pre/9591-migrate-meta-write.md delete mode 100644 .changeset/pre/9591-retirement-sentence-write.md delete mode 100644 .changeset/pre/console-088e0f705520.md delete mode 100644 .changeset/pre/console-0df67f237c5a.md delete mode 100644 .changeset/pre/console-1f23d5b070fa.md delete mode 100644 .changeset/pre/console-1fb1ced36243.md delete mode 100644 .changeset/pre/console-20c6d351ad74.md delete mode 100644 .changeset/pre/console-47b1f0bb7174.md delete mode 100644 .changeset/pre/console-4997995022f8.md delete mode 100644 .changeset/pre/console-7578323a544e.md delete mode 100644 .changeset/pre/console-9646e2ed2bf3.md delete mode 100644 .changeset/pre/console-a58626c88dc8.md delete mode 100644 .changeset/pre/console-b9814b334d8a.md delete mode 100644 .changeset/pre/console-daeb77fc84dd.md delete mode 100644 .changeset/pre/console-e5311165a8d6.md delete mode 100644 .changeset/pre/console-f0268ad78485.md delete mode 100644 .changeset/pre/console-f8267df93aba.md delete mode 100644 .changeset/pre/console-f96f771140f1.md delete mode 100644 .changeset/pre/objectui-pin-citations-088e0f705520.md delete mode 100644 .changeset/pre/objectui-pin-citations-0df67f237c5a.md delete mode 100644 .changeset/pre/objectui-pin-citations-1fb1ced36243.md delete mode 100644 .changeset/pre/objectui-pin-citations-20c6d351ad74.md delete mode 100644 .changeset/pre/objectui-pin-citations-47b1f0bb7174.md delete mode 100644 .changeset/pre/objectui-pin-citations-4997995022f8.md delete mode 100644 .changeset/pre/objectui-pin-citations-7578323a544e.md delete mode 100644 .changeset/pre/objectui-pin-citations-9646e2ed2bf3.md delete mode 100644 .changeset/pre/objectui-pin-citations-a58626c88dc8.md delete mode 100644 .changeset/pre/objectui-pin-citations-b9814b334d8a.md delete mode 100644 .changeset/pre/objectui-pin-citations-e5311165a8d6.md delete mode 100644 .changeset/pre/objectui-pin-citations-f0268ad78485.md delete mode 100644 .changeset/pre/org-ownership-scim-provider-inventory.md delete mode 100644 .changeset/pre/request-scoped-grants-memo.md delete mode 100644 .changeset/pre/v18-ceremony-dev-databases.md diff --git a/.changeset/10757-request-rounds-core.md b/.changeset/10757-request-rounds-core.md deleted file mode 100644 index 9bbe5d0d32b..00000000000 --- a/.changeset/10757-request-rounds-core.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/core': patch ---- - -perf(core): an authenticated request resolves its grants in one round fewer, and a write to an unrelated object no longer retires the localization cache - -Clause-②: no (no accept set, served body or authz answer moves: fewer queries and rounds for the same answer; owes the seat's review of record before the queue, at `CONTRACT_REVIEW_TIER` by the seat's choice, since it changes when authz and localization caches are trusted) - -- **The Default Organization read and the activation-ledger read are one wave.** `resolveUserAuthzGrants` read `sys_organization` (by slug) and then `sys_metadata_activation`, each awaited after the other. Both depend only on the first wave of reads, so they now run together. While the organization read is still open, the ledger is asked about every permission-set name that may apply. In one case that adds the name `admin_full_access` to the ledger's `name IN` list: an organization-owned `admin_full_access` grant that turns out not to anchor platform standing. The envelope is unchanged in every case: positions, permissions, `org_user_ids`, `accessible_org_ids` and posture. The organization read's filter is unchanged, and so are the organization-less grant readings. -- **The localization success cache is retired only by a write that can change its answer.** That is a write to `sys_setting` or `sys_platform_setting`, observed after it settles by an engine middleware. A non-`write` epoch reason (a peer node's `remote` hint, `metadata`, `manual`), a `localization` settings change or `OS_LOCALIZATION_CACHE_TTL_MS` also retires it. Before, any engine write to any object retired it, so on an idle boot the outbox pollers' claim updates made warm requests read the settings again. A success is cached only on an engine that has both the write epoch and `registerMiddleware`. With `OS_LOCALIZATION_CACHE_TTL_MS=0`, nothing is registered on the engine. The failure memo for a missing `sys_setting` table is unchanged: it is retired only by its TTL. diff --git a/.changeset/10757-request-rounds-metadata-protocol.md b/.changeset/10757-request-rounds-metadata-protocol.md deleted file mode 100644 index c81c0d36b06..00000000000 --- a/.changeset/10757-request-rounds-metadata-protocol.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -perf(metadata-protocol): the served by-name read takes its stored rows from the overlay cache, and only a `sys_metadata` write retires that cache - -Clause-②: no (no accept set, served body or authz answer moves: fewer queries and rounds for the same answer; owes the seat's review of record before the queue, at `CONTRACT_REVIEW_TIER` by the seat's choice, since it changes when authz and localization caches are trusted) - -- **`getMetaItem` reads the item's active `sys_metadata` rows through the overlay cache.** The list read already used that cache. The REST API-exposure gate reads the object by name on every `/data` request, and that read was its one uncached `sys_metadata` round trip. The cached value is the raw row set. Parsing, merging and registry hydration still run on every call, hit or miss. Only the served face reads through the cache. The protocol's own authoring checks, such as the destructive-change check before an object save, still read the store on every call. -- **Only a write to `sys_metadata` retires the cache.** The write is observed by an engine middleware after it settles. Every non-`write` epoch reason also retires it, including the metadata cluster bridge's `remote` bump, and `OS_METADATA_OVERLAY_CACHE_TTL_MS` still bounds it. Before, any engine write to any object retired it. A row set is cached only on an engine that has both the write epoch and `registerMiddleware`. With the TTL set to `0`, nothing is registered on the engine. diff --git a/.changeset/15212-8b-single-platform-insert-stamped.md b/.changeset/15212-8b-single-platform-insert-stamped.md deleted file mode 100644 index 482d7ecf440..00000000000 --- a/.changeset/15212-8b-single-platform-insert-stamped.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -feat(objectql)!: under the `single` posture, a system insert into a platform object that names no organization takes the Default Organization, or is refused; it no longer lands with `organization_id` NULL - -Clause-②: no (narrowing) - - - -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, D11). - -- **Before.** A system-context insert (`isSystem`, no `tenantId`) into a `sys_` / `cloud_` / `ai_` object that carries an organization column, but that the tenancy inventory had not admitted, skipped the organization rule entirely, under every posture. The row landed with `organization_id` NULL. Thirty-five platform objects in this repository were in that population, among them `sys_business_unit`, `sys_business_unit_member`, `sys_member`, `sys_team`, `sys_user_permission_set`, `sys_user_position`, `sys_sharing_rule`, `sys_share_link`, `sys_comment`, `sys_attachment`, `sys_activity`, `sys_email`, `sys_inbox_message`, `sys_secret`, `sys_setting`, `sys_setting_audit`, `sys_webhook` and `sys_flow_credential`. -- **Now, under `single`.** That insert goes through the same decision every application object already gets. With exactly one organization (the Default Organization), the row is stamped with it. With none, or with several, the insert is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), and the message names the missing organization. Nothing is written. -- **Unchanged.** Under a walled posture (`group` / `isolated`) the per-object exclusion stands as before; the walled half of this change ships in a later 18.x release. A write that carries an organization, on the execution context or on the record, is never re-stamped or refused. Objects with no organization column, objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. Updates are untouched. The engine's diagnostics-only tenant-audit mute is unchanged. - -**The remedy.** If a refusal names your insert, carry the organization on the write: `ql.insert(object, { ...row, organization_id: orgId }, { context: { isSystem: true } })`, or `{ context: { isSystem: true, tenantId: orgId } }`. On a fresh `single` deployment the auth plugin creates the Default Organization at boot, so a refusal reading `no-organization` means the write ran before it, or in a composition that registers `sys_organization` but mounts no auth plugin to create the Default Organization. diff --git a/.changeset/15214-c11-fresh-install-messages.md b/.changeset/15214-c11-fresh-install-messages.md deleted file mode 100644 index d3e3a6b38ee..00000000000 --- a/.changeset/15214-c11-fresh-install-messages.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/runtime': patch ---- - -fix(cli, runtime): the 18.0 operator messages say that 18.0 supports fresh `single` installs only - -Clause-②: no - -- **The v18 boot refusal on a v17-shaped database** now says first that 18.0 does not upgrade a v17 database: keep it on a 17.x release until the 18.x release that carries the upgrade path. Before, it named only `os migrate organization-ownership`, which refuses in 18.0 as well, so the operator reached the answer in two hops. -- **The fix list for an unrecognized `OS_TENANCY_POSTURE`** (printed by `os serve` and `os doctor`) still lists `group` and `isolated`, and now says beside each that 18.0 refuses it at boot. -- **`os doctor` reports a walled posture.** Under `group` or `isolated` it prints a `Tenancy posture` warning: 18.0 supports `single` only, so the next `os serve` (and so `os dev` and `os start`) refuses the posture at boot. The row is a warning, so doctor's exit code does not change. Before, doctor printed nothing about a valid posture. - -Nothing else changes. The refusals themselves, their codes and their exit codes are as before. diff --git a/.changeset/23585-related-list-parent-data-source-rendered.md b/.changeset/23585-related-list-parent-data-source-rendered.md deleted file mode 100644 index 42bd25476fe..00000000000 --- a/.changeset/23585-related-list-parent-data-source-rendered.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`record:related_list`'s `parentDataSource` is described as rendered - -Clause-②: no (describe and TSDoc text only: `RecordRelatedListProps` accepts exactly what it accepted before, and every refusal keeps its issue `code` and `path`) - -- The `parentDataSource` describe no longer opens with `[PLANNED — not yet rendered]`. The console renders the key: a related list reads its parent record from the binding, the first row by its `sort`, and draws a notice in its place when no row answers, never an empty list. Without the key, the record page's record is the parent, as before. The rest of the describe is unchanged. -- The key's TSDoc names the console's read points in place of the planned note, and no longer calls the `api` data source unrendered. diff --git a/.changeset/24289-hook-ctx-title-doors.md b/.changeset/24289-hook-ctx-title-doors.md deleted file mode 100644 index 561aee21325..00000000000 --- a/.changeset/24289-hook-ctx-title-doors.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -docs(skills): `objectstack-data` says which hook doors provide `ctx.title` — a `body` has it, a deprecated `handler` gets it at runtime without `HookContext` declaring it, and `ql.registerHook` and a bare `ObjectQL` `bindHooks` do not provide it (#24289) - -Clause-②: no - -`skills/objectstack-data/references/data-hooks.md` listed `ctx.title(field?)` in the sandbox `ctx` table with no qualifier, so it read as a member of every hook context. It is not: a sandboxed `body` has it (the recommended form); an inline or named `handler`, which is deprecated, is handed it at runtime when the runtime binds the stack's hooks, but `HookContext` does not declare it, so a handler annotated with that type fails to compile; a hook registered with `ql.registerHook(...)`, or bound through `ObjectQL.bindHooks` with no runtime, gets no `ctx.title` at all. The row now says so. Text only; nothing an author writes changes, and no hook context changes. diff --git a/.changeset/24318-platform-objects-change-slug-to-cloud.md b/.changeset/24318-platform-objects-change-slug-to-cloud.md deleted file mode 100644 index 44c60b779b4..00000000000 --- a/.changeset/24318-platform-objects-change-slug-to-cloud.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/platform-objects': minor ---- - -fix(platform-objects)!: `sys_organization` no longer declares `change_slug`, whose door only the cloud orchestrator mounts (#24318) - -Clause-②: no (narrowing) (a platform object stops declaring an action whose door exists only in cloud; owes a `CONTRACT_REVIEW_TIER` review on the narrowing face before the queue, and the `repo:cloud#2` seat's pointer before landing) - -`sys_organization` declared a `change_slug` action ("Change Slug") at `record_header`, gated on `multiOrgEnabled`, whose target, `/api/v1/cloud/organizations/{id}/change-slug`, no package in this repository serves. So on every open-source composition with multi-organization on, the organization record page offered Change Slug in its `⋯` overflow, and the click answered `404 ENDPOINT_NOT_FOUND`. Measured on a booted multi-organization showcase. - -The action now belongs to the composition that serves its route. That composition declares `change_slug` as a top-level action bound by `objectName: 'sys_organization'`, and the registry attaches such an action to the served object, whichever package registers first. - -**BREAKING — what moves for consumers.** A declared action leaves a published platform object, shipped as `minor` under the launch-window convention for breaking changes (pre mode was exited at `c46b5f14cf`, so the launch-window guard is armed and a breaking change ships as `minor`). - -- FROM: `GET /api/v1/meta/object/sys_organization` served `change_slug` on every composition, and the four generated locale bundles carried its `label`, `description` and `successMessage`. -- TO: the object serves it only where a composition binds it. The open-source record header offers Invite User, Set Active and Rename Organization inline, and Leave Organization in the overflow; each of them is a better-auth door this composition mounts. -- Fix: cloud declares its own `change_slug`, the same declaration bound by `objectName: 'sys_organization'`, with its own translations. An open-source composition changes an organization's slug through `update_organization` (better-auth's `organization/update`, list row), as before. - -**Unchanged.** `rename_organization` (name and logo, no slug) and `update_organization` (name, slug and logo, list row only) keep their declarations, gates and order. No stored row moves. - - diff --git a/.changeset/24318-spec-change-slug-gated-input.md b/.changeset/24318-spec-change-slug-gated-input.md deleted file mode 100644 index ef23fd429b5..00000000000 --- a/.changeset/24318-spec-change-slug-gated-input.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec)!: `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs` no longer lists `sys_organization.actions.change_slug` (#24318) - -Clause-②: no (narrowing) (a platform object stops declaring an action whose door exists only in cloud; owes a `CONTRACT_REVIEW_TIER` review on the narrowing face before the queue, and the `repo:cloud#2` seat's pointer before landing) - -`gatedInputs` is the census of platform-object inputs gated on a public auth feature, which the platform objects' completeness guard holds equal to their declarations in both directions. `sys_organization` no longer declares `change_slug` (its door is the cloud orchestrator's, and the composition that serves it declares the action), so its row leaves the census. - -**BREAKING — what moves for consumers.** One string leaves a published constant, shipped as `minor` under the launch-window convention for breaking changes. - -- FROM: `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs` listed seven `sys_organization` actions, `'sys_organization.actions.change_slug'` among them. -- TO: it lists six: `create_organization`, `update_organization`, `rename_organization`, `delete_organization`, `set_active_organization` and `leave_organization`. -- Fix: none for an action declared outside the platform objects. The census is not a gate on other packages: a top-level action bound to `sys_organization` that declares `requiresFeature: 'multiOrgEnabled'` is lowered to the same `features.multiOrgEnabled != false` predicate and served with it, measured on a booted composition. - -**Unchanged.** The `multiOrgEnabled` key, its surface (`crud`) and its semantics (`default-on`); every other feature's row. The constant is declared `as const`, so its literal type loses the one tuple member too. - - diff --git a/.changeset/24321-resume-as-decider.md b/.changeset/24321-resume-as-decider.md deleted file mode 100644 index eb527329711..00000000000 --- a/.changeset/24321-resume-as-decider.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-automation': minor -'@objectstack/plugin-approvals': minor ---- - -feat(automation)!: an approval decision resumes a flow as the person who decided - -Clause-②: yes (narrowing). The narrowing: under `runAs: 'user'`, a post-decision node that the submitter's rights admitted and the decider's do not is now refused (ruling A item 3). The widening: three published interfaces each gain one optional member, `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`) and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`). No schema shape moves: `flow.runAs` keeps its two values and only its describe changes. - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -A resumed flow run kept its trigger user for its whole life. In an approval flow that meant the nodes after the manager's decision wrote as the rep who submitted: the record's Last Modified By (`updated_by`) and the audit log named someone who did not act, and under `runAs: 'user'` the writes ran under the rep's grants. - -`flow.runAs: 'user'` now means the human whose act is running the current segment: the trigger user until a human decision, the decider after it. `runAs: 'system'` flows keep system privileges; after the decision their attribution user is the decider. - -- **Now, `runAs: 'user'`:** after an approval decision (approve or reject), the nodes that follow run with the decider's identity and permissions together. Their positions, permission sets and organization peers are resolved for them through the same resolver that seeds the trigger user's run, never copied from the trigger user. `updated_by` and `sys_audit_log` name the decider for those writes. The nodes before the decision keep the submitter. A second decision moves the segment to its own decider, a later pause keeps the identity the segment had, and the run's history still names the user who triggered it. -- **Now, `runAs: 'system'`:** after an approval decision, the flow keeps system privileges and its nodes run as `{ isSystem: true, userId: DECIDER }`, the shape the approvals verdict mirror already writes with. No permission is resolved and elevation is unchanged, so a read-only column still lands. Their writes, `sys_audit_log`, and `ctx.user` in the hooks those writes fire name the decider; the nodes before the decision keep the submitter. -- **Refused now:** under `runAs: 'user'`, a node after the decision that the decider may not perform (an object or field they may not edit, a record outside their reach) is refused, the run stops at that node as a repairable strand, and the decision door answers `RESUME_FAILED` (`repairable: true`) naming the run, the node and the refusal, as for any other failed post-decision node. Before, the same node ran with the submitter's rights. A repair replay (`continueRestoredRun`) continues as the decider too. -- **Unchanged:** a flow with no human decision node; an SLA auto-decision, which names no human, so the run keeps the identity it had (on both postures); a subflow parent's own nodes; and the send-back, resubmit and recall doors. - -`@objectstack/plugin-approvals` hands the decider to the engine on the service-stamped resume signal: the person the verdict mirror already writes as. The engine honours a decider only on a signal that carries the owning service's `RESUME_AUTHORITY_SERVICE` marker, so a resume built from an HTTP body cannot name one. The published types gain one optional member each: `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`), and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`). - -What changes for you: if a `runAs: 'user'` flow's nodes after an approval decision must write something the approver may not, such as a read-only field or a field the approver's permission set marks non-editable, the flow needs `runAs: 'system'`; its writes then keep system privileges and are attributed to the approver. A hook that reads `ctx.user` on a write a `runAs: 'system'` approval flow makes after the decision now sees the decider instead of the submitter. A run that was suspended before this release and is decided after it continues as the decider. diff --git a/.changeset/24375-approval-object-chip.md b/.changeset/24375-approval-object-chip.md deleted file mode 100644 index e9c00fe3b0e..00000000000 --- a/.changeset/24375-approval-object-chip.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -fix(plugin-approvals): the approval request page's highlights strip no longer shows the target object's API name - -Clause-②: no - -- **The strip draws no Object chip.** `sys_approval_request_detail` no longer lists `object_name` on its highlights strip, so a request about an announcement no longer shows `showcase_announcement` there. `object_name` holds the target object's API name, and `sys_approval_request` declares no field that holds that object's label. -- **The page still names the target object, by its label.** The Details tab draws the `record_id` / `object_name` pointer pair as a card of the target record. The card heads the record with its object's label (`Announcement`), translated into the reader's locale and read from the object's definition when the page draws, so a relabelled object shows its new label. Where the reader may not read the target record, or the platform deleted it, the card says so and the page names no object. -- **The object's `highlightFields` drops `object_name` too.** The page's Details tab is a bare `record:details` node, which draws the object's `highlightFields` minus the strip. A change to the strip alone would have moved `object_name` into that tab as a row showing the API name. -- **Unchanged:** `object_name` stays a declared, required field: the approvals machinery queries on it, and it is the type half of the pointer pair. The request list views still show their Object column. - -A patch: one entry fewer in one platform object's field list and in its page. No export, type, accepted value, served key or stored column changes. diff --git a/.changeset/24414-capability-predicate.md b/.changeset/24414-capability-predicate.md deleted file mode 100644 index 3e5398750d9..00000000000 --- a/.changeset/24414-capability-predicate.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/formula': minor -'@objectstack/spec': patch ---- - -feat(formula): `current_user.hasCapability(name)` — a page predicate can ask whether the viewer holds a system capability, from the same list the action gate reads - -Clause-②: yes (widening) - -- **The predicate.** The CEL standard library registers `hasCapability` beside `can`, receiver-only, so the one spelling is `current_user.hasCapability('')` (the `user` / `ctx.user` / `os.user` aliases work too). It answers `true` when the name is in the acting subject's held system capabilities and `false` when it is not. A name no action declares is just a string: there is no registry lookup. -- **The data.** `EvalContext` gains `systemPermissions` (type `EvalSystemPermissions`, a list of capability names): the `systemPermissions` array of the published `/auth/me/permissions` response, unchanged — the list an action's `requiredPermissions` is checked against (ADR-0066 D4). `PermissionBinding` gains the same field, optional. Pass it beside `permissions`: - - ```ts - celEngine.evaluate(expr, { user, permissions: toEvalPermissions(me.objects), systemPermissions: me.systemPermissions }); - ``` - -- **Loud, like `can`.** With no `systemPermissions` on the context the call fails (`ok: false`, `kind: 'runtime'`) rather than answering; an empty list is a real answer and evaluates to `false`. A non-list, a non-string or empty name, or a receiver that is not the acting subject fails the same way. A bare `hasCapability(name)` does not resolve. -- **The authoring gate.** The visibility-predicate rule's function-existence check reads the evaluation environment, so a page `visibleWhen` calling `current_user.hasCapability(…)` now passes `os validate`, `os lint` and `os build`, where before it was refused as an unknown function (`visibility-predicate-unknown-function`). An invented sibling (`hasCapabilities`) is still refused. -- **`can` points at it.** `current_user.can(object, verb)` with a verb outside the object-permission vocabulary still fails, and its message now names `current_user.hasCapability('')` for a capability question. -- **Docs.** The page component `visibleWhen` describe names `current_user.hasCapability('')` as the way to gate a component on a capability: `visibleWhen` hides, `requiredPermissions` refuses. -- **Hosts.** A host that evaluates page predicates supplies the list for the predicate to answer; one that does not makes it fail, and a failing page `visibleWhen` renders the component. diff --git a/.changeset/24426-measure-total-hint-stored-number.md b/.changeset/24426-measure-total-hint-stored-number.md deleted file mode 100644 index 6d4c968a98e..00000000000 --- a/.changeset/24426-measure-total-hint-stored-number.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/lint": patch -"@objectstack/service-analytics": patch ---- - -fix(lint, service-analytics): a total over a formula field names the stored-number remedy, not a count - -Clause-②: no - -- **What changed.** A dataset or cube measure taking `sum`, `avg`, `min` or `max` over a `formula` field is still refused (`measure-aggregate-field-type-refused` in `os validate`, `400 DATASET_INVALID` at the dataset compile). The prescription changed. It used to name `count` / `count_distinct` as the way out, and a count read as a total is a plausible wrong number (3 records shown as "total leave days" where the true total is 10). It now reads: "The field is computed on read and stores no value: store the value as a number field (kept by a hook) and aggregate that. `count` / `count_distinct` answer a different question: how many records." -- **One source.** The sentence lives in `@objectstack/spec/data` as `VIRTUAL_FIELD_AGGREGATE_REMEDY`, returned by `virtualFieldAggregateRemedy(aggregate, fieldType)` for a refused value-reading aggregate over a virtual type (`SEARCH_VIRTUAL_TYPES`, which is `formula`). The function returns `undefined` for every other pair. The lint hint, its `os explain measure-aggregate-field-type-refused` entry and the dataset compile leg all print this one sentence. -- **What stays the same.** Nothing is newly accepted or refused. Every other refused pair keeps its own hint, including `count_distinct` over a JSON-stored or `multiple: true` field. The analytics door's cube-query refusal (`400 INVALID_FIELD`) and the engine's `aggregate` door keep their own wording. diff --git a/.changeset/24448-run-context-org-user-ids.md b/.changeset/24448-run-context-org-user-ids.md deleted file mode 100644 index 0f177647684..00000000000 --- a/.changeset/24448-run-context-org-user-ids.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/service-automation': minor ---- - -fix(service-automation): a `runAs: 'user'` flow now sees the acting user's organization members in `sys_user`, as the same user's own request does - -A `runAs: 'user'` flow's data nodes ran without `current_user.org_user_ids`, the peer list the request path resolves for every user. The shipped `sys_user_org_members` policy (`id in current_user.org_user_ids`) therefore dropped out, and a `get_record` on `sys_user` returned the acting user's own row only, while the same user's REST read returned their organization's members too. - -Run setup now seeds the peer list through the resolver the request path uses (`resolveUserAuthzGrants` from `@objectstack/core`), and every data node passes it on: - -- It is the members of the run's organization (`tenantId`) plus the acting user. A run with no organization gets the acting user alone, which is what an organization-less request gets. A user who belongs only to other organizations is never in it, even one who shares another organization with the acting user. -- It is seeded for every trigger shape: the record-change shape (`userId` and `tenantId` only) and the REST and action doors' shape (positions and permission sets already resolved). For the second shape only the peer list is taken from the resolver; the forwarded positions and permission sets stay exactly as they were, so an agent's narrowed ceiling is not re-broadened. -- A `subflow` or `map` child and a resumed leg keep the list their run was seeded with. -- If the resolver fails, the run proceeds without the list, as before, and the `[runAs]` warning now says which half degraded. Policies that read `org_user_ids` then drop out: the run sees less, never more. -- A run suspended before this release resumes without the list and keeps today's answer (its own `sys_user` row) until it ends. - -A `runAs: 'system'` flow is unchanged. The exported `RunIdentityContext` type gains an optional `org_user_ids` member. - -Clause-②: yes (widening: the exported RunIdentityContext / RunDataContext type gains the optional org_user_ids member, the key ExecutionContext already declares; no accepted input moves) diff --git a/.changeset/24450-nav-visible-data-needs-data-source.md b/.changeset/24450-nav-visible-data-needs-data-source.md deleted file mode 100644 index 1a45b262f99..00000000000 --- a/.changeset/24450-nav-visible-data-needs-data-source.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -A navigation entry whose `visible` reads `data` must declare `dataSource`: an entry with no `dataSource` whose `visible` predicate reads the top-level `data` root is refused at parse, at the entry's own `visible`, with the fix. - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing on a published authoring surface: `NavigationItemSchema`), shipped as `minor` under the launch-window convention for breaking changes (Changesets is in pre mode on `main`, tag `next`). - -**Why.** A navigation entry may declare one API `dataSource`, whose selected value its `visible` reads as `data`. That is the only thing that puts a `data` root in a navigation predicate's scope: the console shell's ambient roots are `current_user`, `user`, `ctx.user`, `os.user` and `features`, and a group's `dataSource` is not published to its children. An entry that reads `data` without declaring a source has nothing to read, so until now it parsed, passed `os validate` and shipped as an entry the console never shows, with the author told nothing. - -**What is refused.** A navigation entry of any type (a `group` included) with no `dataSource` whose `visible`, a CEL predicate, reads the root `data`: `data.total == 1`, `!data.has_more`, `has(data.total)`, `.data.total == 1`, or `${data.total == 1}`. The answer is one `custom` issue at the entry's `visible` (for example `navigation.0.children.2.visible`), whose message begins *This navigation entry's `visible` reads `data`, but the entry declares no `dataSource`, so there is no `data` to read*, and names the fix. It is a check on `NavigationItemSchema` itself, so it reaches every door that parses a navigation entry: `app.navigation`, `app.areas[].navigation`, every `children` array at any depth, and a navigation contribution's `items`, through `defineApp`, `defineStack`, `os validate` / `os build` and the metadata write door. - -**What stays accepted, byte for byte.** An entry that declares `dataSource` and reads `data`; an entry whose `visible` reads only `current_user`, `features` or other ambient roots; a `data` that is a member name (`current_user.data.total`), text inside a string literal or a `//` comment, or a comprehension macro's own iteration variable (`current_user.positions.exists(data, data == 'org_admin')`); an entry with no `visible`; and a separator. - -**FROM → TO.** - -- FROM `{ id: 'nav_environments', type: 'object', objectName: 'sys_environment', visible: 'data.total == 1' }` -- TO `{ id: 'nav_environments', type: 'object', objectName: 'sys_environment', dataSource: { provider: 'api', read: { url: '/api/v1/cloud/home' }, path: 'data.environments' }, visible: 'data.total == 1' }`, or drop the `data.*` read and gate on `current_user` / `features`. - -One-line fix: declare `dataSource` on the entry whose `visible` reads `data`, or drop the `data.*` read. A child of a group that declares a `dataSource` declares its own. No conversion can do this for you: only the author knows which endpoint the entry was meant to read. - -**Who is affected, measured.** No navigation entry in this repository reads `data` without a `dataSource`: an AST census of every tracked `.ts` / `.tsx` / `.mts` file found 11 navigation entries with a `visible`, of which 2 read `data` (both spec tests, both declaring a `dataSource`) and 3 read `current_user`. Deployed metadata was not measured. - -### The kit - -- **The refusal.** A module-private CEL root reader and the refinement that uses it, mounted once on `NavigationItemSchema` in `ui/app.zod.ts`. The `visible` description now says the read is refused without a `dataSource`, and the generated reference page carries it. -- **The ledger.** The D3 semantic entry `nav-item-visible-data-needs-data-source` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion. diff --git a/.changeset/24452-default-row-scope-lint.md b/.changeset/24452-default-row-scope-lint.md deleted file mode 100644 index 0c7f084dbd8..00000000000 --- a/.changeset/24452-default-row-scope-lint.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/lint": patch -"@objectstack/spec": patch ---- - -fix(lint): the RLS predicate and org-axis rules judge an object's `defaultRowScope` - -Clause-②: no - -- **What changed.** `validateRlsPredicateEnforceability` now judges `objects[].defaultRowScope.using` and `.check` with the same passes it runs on a permission set's `rowLevelSecurity[]` policy, with the declaring object as the target. A default whose predicate does not lower, does not parse, overruns a parse bound, names a column the object does not declare, reads a `current_user` key nothing resolves, or (for a `select` / `all` read scope) is refused by the engine's filter admission is reported under the same id, at the same `error` severity, at `objects[N].defaultRowScope.`. `validateOrgAxisRedLines` rule ① (`org-axis-permission-inheritance`) also judges a default that reads `parent_organization_id`. -- **A new authoring-time finding on a shape that was silent.** Until now these rules walked `permissions[].rowLevelSecurity[]` only, so `os validate`, `os build` and `os lint` passed such a default. The security plugin's row-policy collection hands an object's default to the same compiler an authored policy reaches, so a predicate that does not lower is dropped at request time and the object reads as zero rows for every caller the default applies to. A stack that declares such a default now fails these commands; the fix is the same rewrite the finding prescribes for a permission-set policy. A default that lowers, and an object with no default, report nothing new. -- **How the finding reads.** `where` names the default and its object (`default row scope "NAME" of object "OBJECT"`), never a permission set. `os explain` for the RLS predicate ids now says what a dropped `using` does for both sources: a permission set's policy denies the holders of that set, and a default, applied only when the caller's sets carry no row policy for the object and operation, denies every caller it applies to. -- **The key's own text.** `ObjectSchema.defaultRowScope`'s description said authoring checks do not judge its predicate; it now says `os validate`, `os build` and `os lint` judge it as they judge a permission set's policy, and that saving the object through the server does not run that check. diff --git a/.changeset/24459-seed-blank-reference.md b/.changeset/24459-seed-blank-reference.md deleted file mode 100644 index ae5c3e52007..00000000000 --- a/.changeset/24459-seed-blank-reference.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): a seed reference written blank (`''` or whitespace-only) is no reference, by the engine's own `isMissing` - -Clause-②: no (no published accept set or surface moves: the row was already written, and only the false "unresolved" report and its `success: false` go away; owes the seat's `TIER_DEFAULT` review of record before the queue) - -- **What changed.** The seed loader skipped a reference only when its value was `undefined` or `null`, so a lookup written `''` was resolved as a natural key, missed, deferred, and reported after pass 2 as `Deferred reference unresolved … = '' → … not found`: `errored` counted it and the load answered `success: false` for a row that had landed. A replay reported the same error again. Every site that decides whether a reference has a value now asks `isMissing` from `@objectstack/core`, the rule the engine's insert door and required check already use: a single reference, each element of a `multiple: true` reference in pass 1 and in pass 2, and the id half of a `referenceVia` pointer pair (which already skipped `''` and now also skips whitespace-only). -- **A blank single reference** is not resolved and not reported. Its value is left as written, so the engine decides it: an optional lookup's row lands with the field empty and a replay skips it clean; a required lookup is refused by the engine's own required check (`book is required`), and no longer also reported as an unresolved reference. -- **A blank element of a `multiple: true` reference** is dropped like a `null` hole, and the other elements resolve. Before, the blank element made the whole field defer and fail, so the row landed with none of its associations. -- **What stays the same.** A real natural key resolves as before, and one that names no row is still reported. No export, error code, configuration key or result shape changed. Nothing to do for a consumer. diff --git a/.changeset/24464-doctor-tool-probe-failure.md b/.changeset/24464-doctor-tool-probe-failure.md deleted file mode 100644 index 3e4ceafb4b8..00000000000 --- a/.changeset/24464-doctor-tool-probe-failure.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os doctor` tells a tool missing from PATH apart from a tool on PATH that failed when it ran - -Clause-②: no - -- **What was wrong.** `os doctor` runs `pnpm -v`, `tsc -v` and `git --version`, and reported every failure as "Not found". A `pnpm` that is installed but cannot start was reported `✗ pnpm Not found` with `Install pnpm: npm install -g pnpm@10.28.1`. One example is a Corepack-managed `pnpm` that cannot download the version it resolves: offline, behind a proxy, or with the registry answering 429. Reinstalling pnpm does not fix that. -- **What changed.** When a probe fails, doctor checks whether the tool is on the PATH the probe ran with. This is the same lookup the shell does: `/bin/sh` walks `PATH`; on Windows, `cmd.exe` tries each `PATHEXT` extension in the current directory and then in each `PATH` entry. If the tool is not there, the row is unchanged. If it is there, the row's message is the tool's own first error line, such as Corepack's `Error: Server answered with HTTP 429 when performing the request to …`. The fix then matches the cause. For a Corepack download failure, it says to check the network or proxy, or to pin pnpm with a `"packageManager"` field in `package.json`. For any other failure, it says to run the command and read its error. -- **Unchanged.** Each row keeps its status: `pnpm` is `error`, `TypeScript` and `Git` are `warning`. So doctor's exit code does not move. A tool that answers is reported as before. No package export or flag changes, and the report keeps its layout. diff --git a/.changeset/24468-lint-rule-hop-undeclared-field.md b/.changeset/24468-lint-rule-hop-undeclared-field.md deleted file mode 100644 index aaa5c9c0cfc..00000000000 --- a/.changeset/24468-lint-rule-hop-undeclared-field.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -fix(lint)!: a validation rule that reads, through a reference, a field the reference's target does not declare is refused where it is written - -Clause-②: no (narrowing) - - - -**BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The validation-rule pass judges the field a rule's `condition` reads one hop through a reference against the target object's fields: the fields it declares, the system columns the platform adds to it, and `id`. It is the same check the field-formula pass makes for a formula. `os validate`, `os build` and the object save door (`PUT /api/v1/meta/object/:name`) run the same pass, under the existing `EXPRESSION_INVALID` rule. It covers a `script` or `cross_field` rule's `condition`, at the top level and inside a `conditional` rule's `then` / `otherwise`. - -- FROM: `record.account.region == 'north'`, where `account` is a lookup to an object that declares no `region`, saved with a 200, and every write the rule judged was refused. TO: refused (`422 INVALID_METADATA` at the save door). The message names the field and the target: "unknown field `region` on ``". -- `record.account.status == 'closed'`, where the target declares `status`, passes as before. -- A target the stack does not carry is not judged. At the save door the stack carries the live registry's objects in the written package's closure, so a target from a package it depends on is judged. -- A reference read more than one hop deep (`record.account.owner.email`) keeps its own refusal and is not judged a second time. - -What to do. Read a field the target declares, or declare the field on the target object. Do not declare it on the object that carries the rule. At `os build` and `os validate` this pass does not see a target field that an `objectExtensions` entry contributes, while the save door judges the merged object. diff --git a/.changeset/24468-objectql-rule-hop-sibling-isolation.md b/.changeset/24468-objectql-rule-hop-sibling-isolation.md deleted file mode 100644 index c320b5190fb..00000000000 --- a/.changeset/24468-objectql-rule-hop-sibling-isolation.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): a validation rule reading a field its reference's target does not declare no longer refuses every write through the other rules reading the same reference - -Clause-②: no - -A write loads, once per page, every column the object's validation rules read through each reference. When one rule named a field the target does not declare, the whole reference was answered as undeclared. Every rule reading through it then had no verdict and refused the write, each naming the other rule's field. - -- The undeclared fields are left out of that shared read and judged for each rule on its own. -- A rule that names one has no verdict and refuses the write, naming only its own undeclared fields. -- A rule that reads only declared fields through the same reference gives the verdict it gives alone: it admits what it admits alone, and refuses with its own message. -- The related read keeps its authority and its bound; an undeclared field never enters it. - -What to do. Nothing. To find the rule at fault, re-save the object: the save door refuses the misspelt read and names the field. diff --git a/.changeset/24471-extension-guidance-hooks-list-views.md b/.changeset/24471-extension-guidance-hooks-list-views.md deleted file mode 100644 index 3a506f2ff59..00000000000 --- a/.changeset/24471-extension-guidance-hooks-list-views.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Clause-②: no - -fix(spec): the object-extension refusals for `hooks` and `listViews` say what `defineStack()` does with a hook or view on another package's object - -`ObjectExtensionSchema` refuses `hooks` and `listViews` on an `objectExtensions` entry. Both refusals used to tell the author to declare a top-level hook, or a top-level `view`, "bound to the target object instead". For an object the stack does not define, `defineStack()` refuses both at build, an extended object included, so the route the refusal named could not be taken. - -- **`hooks`** now reads: a hook follows the ownership of its object, so declare it as a top-level hook in the stack that defines the object; `defineStack()` refuses a top-level hook bound to an object the stack does not define, an extended one included. The refusal is the recorded decision of ADR-0130 §1.5 ("the split must follow hook ownership"). -- **`listViews`** now reads: declare the views as a top-level `view` in the stack that defines the target object; `defineStack()` refuses a top-level `view` whose `list` or `form` data source is an object the stack does not define, an extended one included. -- **Unchanged.** What `defineStack()` accepts and refuses, every error code, status and issue path. Only the refusal text moves, with its mirror in the object-extensions guide. diff --git a/.changeset/24472-bound-action-collision-report.md b/.changeset/24472-bound-action-collision-report.md deleted file mode 100644 index 7a6df984dc4..00000000000 --- a/.changeset/24472-bound-action-collision-report.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): a bound action named like an action its target object already lists, with a different declaration, is reported at boot naming both packages; `registerAction` reports replacing another owner's handler - -Clause-②: no (a warning only: no accept set narrows, no door answer moves, and no published surface gains a key; owes the seat's review of record before the queue) - -- **The listing.** A top-level action bound by `objectName` to an object that already lists an action of the same name is still not attached: the object keeps serving its own declaration, as the action router does. When the two declarations differ, the boot now warns once per pair, at `kernel:ready`, naming the object, the action, the package whose declaration is served and the package whose declaration is not listed. Two packages binding different declarations of one name to a third package's object are reported the same way (the first one attached is served). -- **Not a collision.** One declaration carried twice is compared without its provenance stamps (`_packageId`, `_provenance`, the `_lock` envelope, `protection`) and without `objectName`, and is not reported: a same-stack bound action merged onto its object, or the same declaration shipped by two packages. -- **Later artifacts.** An artifact registered after the boot (a marketplace install, a ledger rehydrate) is judged once it has registered, for the pairs naming one of its packages; a pair the boot already reported is not reported again. -- **The handler.** `ObjectQL.registerAction` keys handlers by `OBJECT:ACTION`. A registration under a key another owner holds still replaces it (the last registration runs), and now warns, naming both owners: the action door runs the new owner's handler whichever declaration the object lists, and withdrawing that owner leaves the action with no handler. The same owner re-registering is silent, and so is the authored-action resync, which tears its set down first. A registration with no package is its own owner. -- **Unchanged.** Nothing is refused and no boot fails on it; the served `actions`, the router's precedence and which handler runs are as before. Measured on stock CRM, showcase and todo boots: no new warning. -- **Fix.** Rename one of the two actions, or drop the duplicate declaration. diff --git a/.changeset/24486-spec-cloud-adr-0028-citation.md b/.changeset/24486-spec-cloud-adr-0028-citation.md deleted file mode 100644 index 15a2528cfff..00000000000 --- a/.changeset/24486-spec-cloud-adr-0028-citation.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Clause-②: no - -docs(spec): the `GET /api/v1/ai/models` response JSDoc cites `cloud ADR-0028`, not a bare `ADR-0028` - -- **What changed.** The JSDoc on `AiModelsResponseSchema` and on its `models` field now cites `cloud ADR-0028` (the plan-filtered AI model picker decision) where it cited a bare `ADR-0028`. This repository's own ADR-0028 is about metadata naming and namespace isolation; the AI service that serves this list is a cloud package. The client SDK TSDoc and the docs already cite it this way. -- **Unchanged.** Every schema shape, `.describe()` string, generated JSON Schema and behaviour. diff --git a/.changeset/24491-page-save-door-visibility-predicates.md b/.changeset/24491-page-save-door-visibility-predicates.md deleted file mode 100644 index ec703bd925e..00000000000 --- a/.changeset/24491-page-save-door-visibility-predicates.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -fix(lint)!: the page save door refuses a page component `visibleWhen` that `os build` refuses (#24491) - -Clause-②: no (narrowing) - -A page component's `visibleWhen` was judged by `os validate`, `os build` and `os lint`, and not by the metadata save door. A page saved through Studio, REST `/meta` or an MCP/AI author answered `200` and was stored when a component predicate called a function nothing registers (such as `totallyBogusFn(1)`), read a bare field (`status == 'open'`), did not parse, or overran the CEL parse budget. At render such a predicate faults, the visibility fallback is `true`, and the component renders for everyone. - -The visibility-predicate family (`validateVisibilityPredicates`, `validatePredicatePathRefs`) now runs on a `page` write as well as on a `view` write, on the principle of ruling B on #23388: a page with a finding is fixed, then saved. The door's verdict is the build's finding: the same rule id (`visibility-predicate-unknown-function`, `visibility-bare-identifier`, `visibility-predicate-syntax`, `visibility-predicate-over-budget`), location (`pages[N].regions[N].components[N]`, and the slot or nested path the build names), message and hint. `visibility-root-mislayered` (a `data.`-rooted predicate on a page) rides the save response as an advisory, as it is a warning at the build. `validatePredicatePathRefs` judges schema-bound form views only, so it adds no finding on a page. - -**BREAKING — what moves for consumers.** This is an accept-set narrowing on the metadata save door, shipped as `minor` under the launch-window convention for accept-set narrowings (`.changeset/pre.json` is open in `next` pre mode). - -- FROM: a page write in publish mode answered `200` for a component `visibleWhen` the build refuses. This covers `PUT /api/v1/meta/page/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/page/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). -- TO: it answers `422 INVALID_METADATA`, with the build's issue located at that component. -- Fix: correct the predicate the issue names, as `os build` already requires, and save again. Call a function `introspectScope` lists, read through a bound root (`record.FIELD`, `current_user.FIELD`, `page.VAR`), and fix the syntax the verdict quotes. Saving the page as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. - -**The next save of a stored page whose component `visibleWhen` the build refuses 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. - -**Unchanged.** - -- Stored rows are not migrated, and they are not refused on read. -- A `view` write is judged as before. -- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. -- Measured before crossing: the 36 pages this repository ships (the examples, `platform-objects`, `plugin-approvals`, `cloud-connection`, `mcp` and `qa/downstream-contract`) carry 8 component `visibleWhen` predicates. Neither rule finds anything in them, at the build or at the door, before this change or after it; deployed rows were not measured. -- No public export or signature moves. The two registry entries gain `page` in `runtimeTypes`, and the gate itself is unchanged: `page` already mapped to `pages`. - - diff --git a/.changeset/24497-hook-runas-org-peers.md b/.changeset/24497-hook-runas-org-peers.md deleted file mode 100644 index 9ae6f9bd694..00000000000 --- a/.changeset/24497-hook-runas-org-peers.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): a `runAs: 'user'` hook now sees the acting user's organization members in `sys_user`, as the same user's own request does - -A hook declaring `runAs: 'user'` is pinned to the triggering user. When the triggering write carried a hand-built system context such as `{ isSystem: true, userId, tenantId }` (the approvals writes are the shipped producers), the pinned context carried no `org_user_ids`, the peer list the request path resolves for every user. The shipped `sys_user_org_members` policy (`id in current_user.org_user_ids`) therefore dropped out, and the hook's `ctx.api` read of `sys_user` returned the acting user's own row only, while the same user's request returned their organization's members too. - -The hook wrapper now seeds the pinned context through the resolver the request path uses (`resolveUserAuthzGrants` from `@objectstack/core`): - -- The peer list is the members of the context's organization (`tenantId`) plus the acting user. A context with no organization gets the acting user alone, which is what an organization-less request gets. A user who belongs only to other organizations is never in it. -- A context with no `permissions` also takes the user's resolved positions and permission sets, so the hook acts with the user's real grants rather than the security plugin's member baseline. Positions and permission sets the context already carries stay exactly as they were, so a narrowed ceiling is not re-broadened; only the peer list is added. -- A context that already carries a peer list, such as a request-resolved one, resolves nothing and is handed through as before. -- If the resolver fails, the hook runs on the unseeded pin, as before, and a warning says so. Policies that read `org_user_ids` then drop out: the hook sees less, never more. - -`runAs: 'inherit'` (the default) and `runAs: 'system'` are unchanged, and a `runAs: 'user'` hook whose trigger names no user is still refused with `HOOK_UNSCOPED_DATA_ACCESS`. A `runAs: 'user'` hook fired from a context that lacks either list now pays one grants resolution per dispatch. - -Clause-②: no (a runtime producer fix: a `runAs: 'user'` hook reads what the same user's own request reads. Nothing `@objectstack/spec` declares moves; owes the seat's review of record before the queue) diff --git a/.changeset/24498-verify-validation-verdict-whole-body.md b/.changeset/24498-verify-validation-verdict-whole-body.md deleted file mode 100644 index c07fe22a538..00000000000 --- a/.changeset/24498-verify-validation-verdict-whole-body.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/verify': patch ---- - -fix(verify): the CRUD stage reads a refused create's whole answer before it calls the create a hard failure - -Clause-②: no - -- **A long validation message is `needs-fixture` again.** The stage used to cut a refused create's body to 280 characters and then look for the validation code in that cut. The `/data` door answers a validation refusal as `{ error, code, fields, object }`, with the app-authored message first, so a message long enough pushed `"code":"VALIDATION_FAILED"` past the cut. The object was then reported `create-failed`, a hard failure that makes `os verify` exit 1, although the app's own rule had refused the auto-derived record. -- **The verdict reads the whole body.** When the body is JSON, the stage reads the envelope's `code`: `VALIDATION_FAILED` on a 400 is `needs-fixture`. A body that is not JSON, or has no string `code`, is matched against the full text with the stage's original pattern. -- **Unchanged:** the stored `detail` still holds at most 280 characters, and the rule is the same: a 400 validation answer is `needs-fixture`, and anything else is `create-failed`. - -A patch: the stage reads the same answer correctly. No export, accepted value or published shape changes. diff --git a/.changeset/24501-list-object-column.md b/.changeset/24501-list-object-column.md deleted file mode 100644 index 3094ea92188..00000000000 --- a/.changeset/24501-list-object-column.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch -'@objectstack/plugin-audit': patch -'@objectstack/plugin-sharing': patch ---- - -fix(plugin-approvals, plugin-audit, plugin-sharing): list views no longer show the target object's API name as an Object column - -Clause-②: no - -- **The Object column leaves these lists.** These list views showed `object_name`, the target object's API name (such as `showcase_announcement`), as plain text beside the `record_id` record pointer. They no longer list it: - - `sys_approval_request`: `all_requests`, `my_pending`, `submitted_by_me` and `completed`; - - `sys_approval_action`: `recent` and `all_actions`; - - `sys_audit_log`: `recent`, `writes_only`, `record_views` and `all_events`; - - `sys_record_share`: all six views; - - `sys_share_link`: all four views. -- **The Record column names the target record.** `record_id` is the id half of the `record_id` / `object_name` pointer pair. The console draws it as the target record, with its object's icon and the record's name, and fetches `object_name` for that cell without a column. This relies on a console that fetches a pointer's type half for its cell, as the console bundled with this release does; an older console draws the raw record id in that column. -- **Unchanged:** `object_name` stays a declared field on all five objects, and the pointer pair, its indexes and every query on it are untouched. `sys_audit_log`'s `config_changes` view keeps its Object column, because its rows carry no `record_id`. - -A patch: entries removed from list view `columns` on shipped platform objects. No export, type, accepted value, served key or stored column changes. diff --git a/.changeset/24519-approvals-context-identity.md b/.changeset/24519-approvals-context-identity.md deleted file mode 100644 index b002044f2ea..00000000000 --- a/.changeset/24519-approvals-context-identity.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -fix(plugin-approvals): an approval's status write-back runs its automation as the person who acted, in the record's organisation - -Clause-②: no - -- **What changes.** The write that mirrors a request's status onto the record's `approvalStatusField` (submit, approve, reject, recall, send back, the action link) now names the organisation of the record it writes, and no longer carries an empty set of positions and permission sets. A `runAs: 'user'` hook on that object, and a `runAs: 'user'` record-change flow fired by that write, now run with the acting person's own request envelope in the record's organisation: their positions, their permission sets and their organisation's members, resolved exactly as their own request in that organisation resolves them. -- **Before.** Such a hook ran on the member baseline and saw only the acting person in `sys_user`. Such a flow resolved the person's grants with no organisation, so it saw only the person as a peer and did not run with what the record's organisation grants them. -- **Every acting person, one organisation.** An approver drawn from another organisation's directory also acts in the record's organisation. The organisation is read from the record when the status is written, so a record moved to another organisation while its approval was pending is written, and cascades, in its new organisation. -- **Unchanged.** The write stays elevated (`isSystem`), so it still lands on a locked record and on a read-only status field. A status written by a sweep with no person behind it (the dead-run release) stays user-less, so a `runAs: 'user'` automation it fires is still refused. `ctx.session.organizationId` in a hook on the write now carries the organisation, and `ctx.session.positions` is absent instead of an empty list. - -A patch: no export, type, accepted value or stored column changes. diff --git a/.changeset/24523-default-organization-memo.md b/.changeset/24523-default-organization-memo.md deleted file mode 100644 index 2c219fd80b2..00000000000 --- a/.changeset/24523-default-organization-memo.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/core': patch ---- - -perf(core): an administrator whose `admin_full_access` grant belongs to the Default Organization no longer pays a `sys_organization` read on every authenticated request - -Clause-②: no (a per-engine memo of an unchanged read: no accept set, standing verdict or served body moves; owes the seat's review of record before the queue, at `CONTRACT_REVIEW_TIER` because it is the platform-standing anchor) - -- **The Default Organization is read once per engine, not once per request.** Under `single`, `resolveUserAuthzGrants` asks for the Default Organization whenever the principal holds an organization-owned `admin_full_access` grant: one `sys_organization` read, or two when the only organization is not slugged `default`. The answer is now kept per engine across requests. On a booted composition the warm `get-session` costs 8 statements and a data read 10, the same as for an organization-less grant. Before, it cost 9 and 11, or 10 and 12. -- **What retires it.** Any `sys_organization` write through the engine (insert, update, delete), observed after it settles. With the `authz.invalidated` bridge attached (the grants cache on, `OS_AUTHZ_GRANTS_CACHE_TTL_MS` above 0, and a cross-process cluster driver), a peer replica's write arrives as a `remote` epoch bump and retires it; every other non-`write` bump (`metadata`, `manual`) retires it too. A 30-second bound covers what no seam sees: a peer's write with no bridge attached, which on the shipped default (the grants cache off) is every peer's write, so there the bound is the only cover for it; a write that started before the engine's observer registered; and a write inside a transaction that has not committed yet. A Default Organization that is deleted and recreated, or a `default` slug moved to another organization, is read again on the next request. -- **A failed read is not kept.** A read that fails with the authorization store unavailable propagates as before, and the next request reads again. "No Default Organization" (no organization, or several and none `default`) is a real answer and is kept. -- **Unchanged:** the two reads, their filters and limits, and the platform-standing predicate the answer feeds. An engine without the write epoch and `registerMiddleware`, or a registry that does not hold `sys_organization`, keeps reading every time. diff --git a/.changeset/24529-declared-webhooks-from-registry.md b/.changeset/24529-declared-webhooks-from-registry.md deleted file mode 100644 index 4fad5d6836d..00000000000 --- a/.changeset/24529-declared-webhooks-from-registry.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/plugin-webhooks': minor -'@objectstack/runtime': minor -'@objectstack/spec': patch ---- - -feat(plugin-webhooks,runtime)!: declared webhooks are read from the registry — the boot seeder retires, no `sys_webhook` row is written for one, and it is switched through the activation ledger - -Clause-②: yes (narrowing) - - - -**BREAKING** (a narrowing of where a declared webhook can be edited), shipped as `minor` under the launch-window convention for breaking changes. - -ADR-0131 D2 says no seeder writes declared items into a table, and D6 charts behavioural types under regime C. A webhook declared with `defineStack({ webhooks })` now follows both, as declared sharing rules already do: the dispatcher reads the declaration from the metadata registry on every subscription-cache refresh, and `bootstrapDeclaredWebhooks` no longer runs. - -What changes: - -- **No `sys_webhook` row for a declared webhook.** Before: every boot wrote or refreshed one `managed_by: 'package'` row per declared webhook, shown and editable in Setup → Integrations → Webhooks. Now: a fresh install writes none, and Setup lists only the webhooks an organization authors. The declared webhook still delivers, with the same URL, method, triggers, `timeoutMs`, signing secret and headers; its secret and headers are code, held in memory, and never stored. Its deliveries carry `ref_id = declared:`. -- **A declared webhook is read-only, and switched through the activation ledger.** Before: an administrator edited the seeded row in Setup (`customized: true`). Now: switch it on or off for the deployment with `POST /api/v1/security/_activation/webhook/` and `{ "enabled": true | false }` — one `sys_metadata_activation` row of type `webhook`, which overrides the declared `isActive` in either direction, so a webhook shipped inactive is switched on the same way. The door is the declared-sharing-rule door with `webhook` admitted: `manage_metadata`, then the platform operator under a wall; `404` for a name nothing declares; `503` when no declared-webhook reader is bound or it cannot be read. The dispatcher refreshes in the request that wrote the row. Remedy for a change beyond on/off: author a copy under a new name as a `sys_webhook` row, and switch the declared one off. -- **`label` and `description` on a declared webhook are no longer shown anywhere.** The seeded row was their only reader. They are display-only, so no delivery changes; the liveness ledger marks them dead, owed enforce-or-remove. - -On a database an 18.0 install created (expand first — nothing is moved or deleted): - -- The rows the 18.0 seeder wrote stay where they are. A pristine one (`managed_by: 'package'`, not `customized`) is ignored in favour of its declaration, so a declared webhook is never delivered twice; it follows the declaration's URL and switch, as the seeder's rewrite on every boot made it do. -- A seeded row an administrator edited (`customized: true`), or a row an administrator authored under a declared name, answers as itself, and the declaration is not dispatched beside it — an administrator's `active: false` on a noisy webhook keeps holding. This is logged at `warn` once per name per process, with the remedy. -- A seeded row whose declaration was removed keeps delivering, as it did on 18.0. -- Redelivering an old delivery of a pristine seeded row is judged by the declaration that now answers for it, so a webhook switched off through the ledger is not re-sent. - -Deleting the seeded rows, and re-pointing their `sys_http_delivery` history at the declaration, is a step of the ownership migration in a later 18.x release. diff --git a/.changeset/24530-settings-audit-global-rung-sibling.md b/.changeset/24530-settings-audit-global-rung-sibling.md deleted file mode 100644 index 76b1ba02f9e..00000000000 --- a/.changeset/24530-settings-audit-global-rung-sibling.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/service-settings': minor -'@objectstack/spec': minor -'@objectstack/cli': minor ---- - -feat(service-settings)!: the settings audit trail's global rung moves to the new deployment-level `sys_platform_setting_audit` - -Clause-②: yes (narrowing) - - - -**BREAKING** for a reader of the settings audit trail, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D7; ruling record 6257510973, Q4 A). This is the expand half of the change: it adds the new write shape, and the 18.0 rows stay where they are and readable. No row an 18.0 install wrote is moved, deleted or re-stamped. - -- **Before.** A write of a global-scope settings key appended its history row to `sys_setting_audit`, stamped with the Default Organization under `single`. Any principal the settings read door let read the namespace could list it there. -- **Now.** That row goes to the new `sys_platform_setting_audit`. It has the same row shape, `scope` is always `global`, and there is no organization column (`systemFields: { tenant: false }`). Reading it through the generic data API requires the `manage_platform_settings` capability, as `sys_platform_setting` does, and each namespace's `readPermission` applies there too. Tenant and user rows stay in `sys_setting_audit`, stamped as before. -- **18.0 rows.** Global-rung rows an 18.0 install wrote stay in `sys_setting_audit`, stamped, until the upgrade ceremony moves them. The global rung's history is both tables: the sibling, plus `sys_setting_audit` rows at `scope = global`. - -**The remedy.** If you list or filter the global rung's settings history (`sys_setting_audit` with `scope = global`), read `sys_platform_setting_audit` as well, with a principal that holds `manage_platform_settings`. Select the 18.0 rows by `scope`, never by a missing organization: they carry the Default Organization. Nothing changes for a reader of tenant or user rows. diff --git a/.changeset/24531-identity-mail-sibling.md b/.changeset/24531-identity-mail-sibling.md deleted file mode 100644 index c3f05200ee4..00000000000 --- a/.changeset/24531-identity-mail-sibling.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/platform-objects': minor -'@objectstack/plugin-email': minor -'@objectstack/plugin-auth': minor -'@objectstack/cli': patch ---- - -feat(plugin-email,plugin-auth): identity mail — verification, password reset, magic link, the email-change notice, an account invitation — is recorded in `sys_identity_email`, a deployment-level sibling of `sys_email` with no organization column - -Clause-②: yes (widening) - -ADR-0131 D7, ruling of record 6257510973 (letter Q2 A). Expand-first: this release adds the new log and its write path, and keeps reading every row and job an 18.0 install left behind. It moves and deletes nothing. - -- **The new object.** `sys_identity_email` (`@objectstack/platform-objects`, registered by `EmailServicePlugin`) has `sys_email`'s fields, so the row shape is the same, and `systemFields: { tenant: false }`, so it has no organization column. It is append-only, readable over `get` / `list`, and gated by `manage_platform_settings` (like `sys_job`), because its rows carry password-reset and sign-in links. -- **The contract.** `SendEmailInput` and `SendTemplateInput` (`@objectstack/spec/contracts`) gain `identityMail?: boolean`. With `true`, the mail service writes the row to `sys_identity_email`. Without it, the row goes to `sys_email`, as before. `identityMail: true` together with an `organizationId` is refused as `VALIDATION_FAILED` and recorded as a rejected row in `sys_identity_email`. The organization is not written. -- **One pipeline, both logs.** The queue worker, the outbox drain hook, the boot outbox sweep, attachment reclamation and the internal-header readback read a row from the log it lives in. A delivery or reclaim job for a `sys_identity_email` row carries `object: 'sys_identity_email'` beside `rowId`. A job with no `object` reads `sys_email`, which covers every job an 18.0 process queued. A job naming anything else is refused at `error` and touches no row. `EmailPersistence.insert` / `update` / `readHeadersJson` take the log as an optional last argument. A custom implementation that ignores it keeps every row in its one store. `@objectstack/plugin-email` exports `TENANT_EMAIL_OBJECT`, `IDENTITY_EMAIL_OBJECT`, `EMAIL_LOG_OBJECTS`, `isEmailLogObject` and `EmailLogObject`. -- **plugin-auth.** The five identity sends say `identityMail: true` and name no organization, under every posture. Before, they carried the Default Organization under `single` and nothing under a wall. The organization invitation (`auth.invitation`) is tenant mail. It still carries its organization and still lands in `sys_email`. -- **What a reader of `sys_email` sees.** - - **Before.** Identity mail was a `sys_email` row: stamped with the Default Organization under `single`, organization-less under a wall. - - **Now.** New identity mail is a `sys_identity_email` row. Rows already in `sys_email` stay there and are still delivered, swept and reclaimed. - - **Remedy.** A report, export or query that looks for verification or reset mail reads both logs. For example: `ql.find('sys_identity_email', { where: { related_object: 'sys_user', related_id: userId } })` beside the same query on `sys_email`. -- **Inventory.** `os migrate organization-ownership`'s inventory lists `sys_identity_email` as deployment-level (column-drop fate, nothing to attribute). Moving the identity rows already in `sys_email` is an 18.x upgrade-ceremony step, not part of this release. diff --git a/.changeset/24534-export-nav-visible-data-check.md b/.changeset/24534-export-nav-visible-data-check.md deleted file mode 100644 index 9550ff6d423..00000000000 --- a/.changeset/24534-export-nav-visible-data-check.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec/ui): `navItemVisibleDataNeedsDataSource` is exported from `@objectstack/spec/ui`, so a mirror of `NavigationItemSchema` attaches the spec's own rule by identifier - -Clause-②: yes (widening) - -- **What is added.** One function export, `navItemVisibleDataNeedsDataSource(item, ctx)`: the refinement `NavigationItemSchema` already runs, which refuses a navigation entry with no `dataSource` whose `visible` reads `data`. It is exported the way `objectNavTargetExclusivity`, `checkPageRegionPlacement` and `checkPageMainRegionWidth` are, so a hand-written mirror of the navigation item can chain the same function in its own `superRefine` instead of restating the rule. -- **What does not change.** No schema accepts or refuses anything new. The issue is the same: one `custom` issue at `visible`, with the same message. `NavigationItemSchema` still attaches the same function, once, by name. -- **What the function reads.** Both spellings of `visible`, so it answers the same on an entry as authored and as parsed: - - the authored bare string (`'data.total == 1'`), normalised by `EvaluatedExpressionInputSchema`, the slot's own schema, exactly as the spec's parse normalises it; - - the parsed `{ dialect: 'cel', source }` envelope. - - A boolean or absent `visible` judges nothing. So a mirror whose own `visible` stays the authored string chains the function as it is and refuses what the spec refuses. diff --git a/.changeset/24536-cel-env-reuse.md b/.changeset/24536-cel-env-reuse.md deleted file mode 100644 index ff9b405fcd9..00000000000 --- a/.changeset/24536-cel-env-reuse.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/formula': patch ---- - -perf(formula): a CEL evaluation reuses one environment instead of building the stdlib on every call, and compiles reuse the canonical parse environment - -Clause-②: no (an internal cache: no accept set moves, no evaluation result moves, no exported signature changes; owes the seat's review of record before the queue) - -- **`celEngine.evaluate` reuses one environment.** It used to build a new cel-js `Environment` and register the whole stdlib into it on every call, which objectql's formula fields reach once per row per field. The stdlib now reads `now()`, the timezone and the permission binding of `current_user.can` / `current_user.hasCapability` from a per-evaluation slot. `evaluate` saves the slot, sets it and restores it in a `finally`, so a nested or a thrown evaluation leaves the outer binding as it was. Evaluations also run a bounded per-source cache of parsed programs, keyed on the source after its row-dependent rewrites. -- **`celEngine.compile` reuses the canonical parse environment** that `parseCelToAst` already shares. -- Results, error kinds and error messages are unchanged. `registerStdLib`, `registerPermissionPredicate` and `registerCapabilityPredicate` keep their signatures and their behaviour. -- Measured on one source evaluated 20,000 times: about 142 µs per evaluation before, about 3 µs after. A 25-row read of an object with three formula fields built 78 environments before and builds none after. diff --git a/.changeset/24537-findfile-memo.md b/.changeset/24537-findfile-memo.md deleted file mode 100644 index d9d867bc264..00000000000 --- a/.changeset/24537-findfile-memo.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/metadata': patch ---- - -fix(metadata): `FilesystemLoader` remembers which file a metadata name resolves to, so a repeated read stops probing the filesystem (#24537) - -Clause-②: no - -`FilesystemLoader` used to make one `fs.access` call per resolvable extension (five under `MetadataPlugin`'s format set) on every `load()`, `exists()` and `stat()`, for a name no file holds as well as for one a file holds. Every authenticated request paid this cost: the security catalog asks the metadata service for each held permission set the engine registry does not serve, and in an artifact-mode runtime no such file exists. - -Each loader instance now remembers the answer for each `(type, name)`: the path, or no file. Only something that can change the answer retires it: - -- a `save()` through the loader; -- a walk of the type's directory by the loader (`list()`, `loadMany()`, `loadManyKeyed()`), so a name `list()` reports is not answered `null` by `get()`; -- a read with `useCache: false`, which re-resolves from disk. The file watcher's re-read on an `added` or `changed` event is such a read; -- a remembered path that the next read finds deleted. That read resolves the name again from disk and logs no stat failure. This covers an `unlink` event, and a deletion that no event reports. - -The memo holds at most 10,000 names and then starts over. - -One change is not seen at once. Suppose a name has already been answered "no file", and then another process creates that file under the loader's root while no watcher runs (`watch: false`). The loader does not see the file until the next walk of that type, the next `save()` through the loader, or a restart. Content edits to a file that already resolves are still read on the next request. Nothing in the public API changes. diff --git a/.changeset/24546-resume-inputs-variables-both-refused.md b/.changeset/24546-resume-inputs-variables-both-refused.md deleted file mode 100644 index 511396e2adf..00000000000 --- a/.changeset/24546-resume-inputs-variables-both-refused.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime)!: a flow resume body that carries both `inputs` and `variables` is refused with `400 VALIDATION_FAILED`; it no longer answers 200 with the `variables` bag silently dropped - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing on a public HTTP door: `POST /api/v1/automation/:name/runs/:runId/resume`), shipped as `minor` under the launch-window convention for breaking changes. - -`variables` is an accepted alias of `inputs` on the resume route: both name the screen submission. A body that carried both used to answer `200` with `success: true`, the run resumed on the `inputs` bag, and the `variables` bag was never read, even when it held a field the screen does not declare. Sent alone, that same bag is refused `400 INVALID_SCREEN_INPUT`. - -- **Now.** A body carrying both keys answers the route's own validation-failure envelope: `400`, `error.code: 'VALIDATION_FAILED'`, and `details.fields` naming `inputs` and `variables` (code `invalid_value`). The message says to send `inputs` alone. The engine is never consulted, so the paused run stays parked and the same call with a corrected body resumes it. -- **Unchanged.** A body carrying `inputs` alone, or `variables` alone, is forwarded exactly as before, and the engine judges it exactly as before. A key whose value is `undefined` counts as absent, as it already did for the value-shape check. The unknown-key and value-shape refusals keep their messages and come first. - -What to do. Send the screen submission under `inputs` only. `@objectstack/client`'s `automation.resume()` and the console's flow runner already send `inputs` alone, and the MCP `resume_run` tool builds its signal in process rather than through this body, so none of them is affected. diff --git a/.changeset/24548-complete-holds-bytes.md b/.changeset/24548-complete-holds-bytes.md deleted file mode 100644 index dfaeb5c8e40..00000000000 --- a/.changeset/24548-complete-holds-bytes.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/service-storage': minor ---- - -fix(service-storage): the single-shot upload commit refuses a file whose bytes the storage backend does not hold - -Clause-②: no (narrowing) - - - -**What was wrong.** `POST /api/v1/storage/upload/complete` set the `sys_file` row to `committed` without asking the storage backend. A presigned upload whose byte `PUT` failed, or was never sent, therefore committed with the size it declared. The download doors serve any `committed` file, so `GET /api/v1/storage/files/:fileId/url` handed out a signed URL, and fetching it failed with `500`. - -**What the commit door does now.** Before it commits, it asks the backend through two members every adapter implements: `exists(key)`, and `getInfo(key).size` compared with the size the upload declared. When the backend holds no bytes for the file, or holds a different number of bytes, the door answers `409` with the standard code `RESOURCE_CONFLICT`. The row stays `pending`, so nothing is served for it. This is the answer the chunked completion already gives an upload that does not hold its declared bytes. - -**What changes for you.** Nothing, if your client sends the bytes before it completes, as the SDK's `storage.upload` and the console's upload provider do. A client that completes first now receives the `409`: send the file to its upload URL (start a new upload if that URL has expired), then complete again. If the storage adapter reports a fault while the door asks, the door answers `500`. The local adapter does not report an unreadable storage root as a fault: it reports it as holding no bytes, so on the local adapter the door answers `409`. - -The `eTag` a completion may carry is still stored as sent and not compared: no adapter reports the stored object's ETag. - -**The local adapter's download link.** `GET /api/v1/storage/_local/raw/:token` answers `404` `FILE_NOT_FOUND` when no file is stored under the link's key. Before, it answered `500` `INTERNAL` ("Download failed"). Every other read failure still answers `500`. diff --git a/.changeset/24549-parent-gate-parity.md b/.changeset/24549-parent-gate-parity.md deleted file mode 100644 index b48cc852172..00000000000 --- a/.changeset/24549-parent-gate-parity.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/service-storage': minor -'@objectstack/plugin-audit': minor ---- - -The `sys_attachment` parent gate and the `sys_comment` moderation gate give the answer a by-id update of the parent record gets - -Clause-②: no (narrowing) - - - -- Every limb that asks whether the caller may edit the parent record (attaching a file, editing or removing another user's attachment or comment, moving an attachment to another record, and `assertMayAttach`) now admits only where a by-id update of that parent admits. After record sharing and the master-detail check, the gate asks the security service's judgement of that update: its record-grained `explain` verdict for `update` on the parent, `allowed` and `record.visible` both. -- The step only adds refusals, with each limb's existing envelope (`ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE`, or the not-visible `PERMISSION_DENIED` for a caller who cannot read the parent). A store fault keeps its own status, and an attach naming a parent object the registry does not hold answers what a by-id update of it answers (`404 OBJECT_NOT_FOUND`). -- Posting a comment is unchanged: it stays a read rule on the parent. diff --git a/.changeset/24550-sharing-rule-meta-door-closed.md b/.changeset/24550-sharing-rule-meta-door-closed.md deleted file mode 100644 index 38bfe1009a6..00000000000 --- a/.changeset/24550-sharing-rule-meta-door-closed.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-protocol': minor -'@objectstack/plugin-sharing': minor -'@objectstack/platform-objects': patch ---- - -feat(spec,metadata-protocol)!: the `/meta` door no longer authors sharing rules — `sharing_rule` is a code-only metadata type; Setup lists declared sharing rules - -Clause-②: yes (narrowing) - - - -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D6, regime C). - -A sharing rule has two sources: a package declares it (the stack's `sharingRules`, switched off through the activation ledger), or an organization authors its own (`sys_sharing_rule`). A runtime metadata item was a third, and it bypassed the same-name guard and the organization wall. The narrowings, FROM → TO: - -- `PUT /api/v1/meta/sharing_rule/:name`, with a name nothing declares, in publish or `?mode=draft` — FROM `200`, with an environment-wide item stored and served by `GET /api/v1/sharing/rules`, TO `403 NOT_CREATABLE`, nothing stored. With a declared rule's name — TO `403 NOT_OVERRIDABLE`. -- `GET /api/v1/meta/types` — FROM `sharing_rule` advertised `allowRuntimeCreate: true`, TO `allowRuntimeCreate: false` and `allowEnvironmentOverlay: false`. - -**The fix:** author an organization rule, or clone the declared one. `POST /api/v1/sharing/rules` (Setup > Sharing Rules) creates an organization rule. To customize a declared rule, copy it to an organization rule under a new name, then switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/:name`, body `{ "enabled": false }`. A package declares its rules in the stack's `sharingRules`, as before. An item stored through the old door is still served until you remove it with `DELETE /api/v1/meta/sharing_rule/:name`. On an environment kernel, removing it needs `OS_METADATA_WRITABLE=sharing_rule`. That hatch is an explicit operator opt-in, not an authoring route: as for any code-only type, it reopens every write on the type, including creates of new names, and a sharing rule grants access. Unset it after the removal. - -Also in this change: - -- **`@objectstack/spec`.** `MetadataTypeSchema` (so the `MetadataType` union) and `DEFAULT_METADATA_TYPE_REGISTRY` gain `sharing_rule`. Its schema binding (`SharingRuleSchema`) moves from the non-kind collections into the kind map. `META_URL_TO_SINGULAR` gains the `sharing_rules` spelling, so `/api/v1/meta/sharing_rules` now folds to `sharing_rule`; before, that plural was not a recognised spelling. -- **`@objectstack/metadata-protocol`.** The refusal names the two sanctioned paths, not a source glob. `sharing_rule` gains its ADR-0126 Regime C row (clone, and the ledger switch). -- **`@objectstack/plugin-sharing`.** Setup's Access Control group gains **Declared Sharing Rules** (`nav_declared_sharing_rules`). It opens `/apps/setup/metadata/sharing_rule?scope=environment` beside **Sharing Rules**, which lists the organization's own rules. -- **`@objectstack/platform-objects`.** That entry's label, in `en`, `zh-CN`, `ja-JP` and `es-ES`. diff --git a/.changeset/24561-batch-hook-refusal-sentence.md b/.changeset/24561-batch-hook-refusal-sentence.md deleted file mode 100644 index 2a2aef0c482..00000000000 --- a/.changeset/24561-batch-hook-refusal-sentence.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-protocol): a batch row answers a sandboxed hook's refusal in the hook's own words - -Clause-②: no (no accept set or route moves, and a row keeps its shape: a sandboxed hook's refusal now carries the sentence and code the other data doors already answer) - -- **A sandboxed hook's refusal reads as its sentence on a batch row.** When a sandboxed hook body refused a row with `throw new Error('…')`, `POST /api/v1/data/:object/batch` answered that row with `code: 'INTERNAL_ERROR'` and `The create of this record failed. The reason is in the server log.` `POST /data/:object`, `/createMany` and `/import` answer the hook's sentence. The row now carries that sentence and `code: 'VALIDATION_ERROR'`, the code those doors give a refusal that declared no code or status. Rows of `updateMany` and `deleteMany` answer the same way. The row still has no `httpStatus`, because the hook declared none. -- **A code or status the hook declared still rides.** For a refusal that declared a 4xx status, the row used to quote the `hook 'NAME' threw: Error: …` debug wrapper. It now quotes the hook's sentence. -- **Unchanged:** a hook body that crashed, a refusal that declared a 5xx, the sandbox's own faults (a time or CPU budget, a denied capability) and driver errors keep the withheld sentence and the server-log warning. A crashed body that declared a 4xx is now withheld as well. Before, the row quoted its wrapper with the native error text. diff --git a/.changeset/24562-seed-replay-time-equality.md b/.changeset/24562-seed-replay-time-equality.md deleted file mode 100644 index 3e7aa501dd4..00000000000 --- a/.changeset/24562-seed-replay-time-equality.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-protocol): a seed replay no longer rewrites an unchanged row whose `time`, `datetime` or `date` value the seed spells differently from the stored form (#24562) - -Clause-②: no - -The replay's no-op check (`isNoOpReplay`) compared a seed value with the stored one as text, so `'09:15'` against the stored `'09:15:00'`, `'2026-06-17T14:30:00Z'` against `'2026-06-17T14:30:00.000Z'`, and a `date` written as an instant against its stored `YYYY-MM-DD` all read as changed. Every boot rewrote those rows: `updated_at` moved, lifecycle validation ran again, history tracking recorded an edit nobody made, and a parent's summary recompute moved the parent's `updated_at` too. - -A field the object declares `time`, `datetime` or `date` is now compared in its stored form, by the rule the drivers apply on write (`temporalStorageForm`, `@objectstack/core`). Two spellings of the same wall clock, instant or calendar day skip the row. A different value is still rewritten, and a value that rule cannot read still counts as changed. What a seed may declare, and what any write stores, is unchanged. diff --git a/.changeset/24563-list-view-out-of-closure.md b/.changeset/24563-list-view-out-of-closure.md deleted file mode 100644 index ae959890d06..00000000000 --- a/.changeset/24563-list-view-out-of-closure.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -fix(lint)!: the save door refuses a list view written into a package and bound outside that package's closure (#24563) - -Clause-②: yes (narrowing) - -**Why.** The runtime save door judges a write into a package against that package's closure (ruling #9612). The closure is the package's own objects, the objects of the packages its manifest declares in `dependencies`, platform/system objects, and provenance-less overlay rows; every other object is dropped from the snapshot before any rule runs. The list-view field rule read a binding missing from that snapshot as "an object this stack does not define" and skipped the whole list view. So a list view written into a package and bound to an object of a package it never declared, or to an object that exists nowhere, was stored with every field reference unjudged: `200`, no advisory. The same body as an overlay write is refused `422 list-view-field-unknown` for an unknown `columns` field. No other rule judged the binding on a `view` write, and such a view then fails at render ("This view's query was rejected"). - -**What is refused.** A list view written into a package whose bound object is outside the package's closure is refused with one `list-view-object-outside-closure` issue. The issue is located at the key the binding is read from: `views[0].data.object` for a standalone list view, otherwise the container's `objectName` / `object`. The hint names the remedy. An object that exists nowhere is refused the same way. This is the verdict the dataset door already gives an out-of-closure base object under the same closure (`object-reference-unknown`). An object written into a package whose built-in `listViews` entry retargets `data.object` outside the closure is refused the same way. The list view's field references stay unjudged until the binding is inside the closure. - -**BREAKING — what moves for consumers.** This is an accept-set narrowing on the metadata save door, shipped as `minor` under the launch-window convention for accept-set narrowings (`.changeset/pre.json` is in `exit` mode, so the next version pass cuts 18.0.0 and this change ships in it). - -- FROM: a publish-mode write of a list view into a package, bound outside the package's closure, answered `200` and was stored, with no issue. This covers `PUT /api/v1/meta/view/:name?package=…` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/view/:name/publish`, `publishMetaItem`), a package draft publish (`publishPackageDrafts`), and a package copy (`duplicatePackage`), which writes its copies through the same door. -- TO: `saveMetaItem` in publish mode, the draft promotion and `publishPackageDrafts` answer `422 INVALID_METADATA`, with the `list-view-object-outside-closure` issue at the binding, and nothing is stored. A package draft publish fails the item, and the batch with it. In `duplicatePackage`, a copied list view whose bound object stays outside the copy package's closure is refused with the same rule and listed in its `failed[]`. One way that happens is when that object's own copy was refused. -- Fix: declare a dependency on the package that defines the bound object in the `dependencies` of the writing package's manifest, or bind the view to an object the package may reach (its own, a declared dependency's, or a platform object). Then save again. Once the object is inside the closure its fields are judged: an unknown `columns` field is refused `422 list-view-field-unknown`, as at the overlay door. Saving the view as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. - -**The next save of a stored list view bound outside its package's closure is refused with the fix; stored views keep serving until then.** The door judges writes only. A stored row is not re-judged at boot, and it loads and is served as before. - -**Unchanged.** - -- A list view bound to an object inside the closure (the package's own, a declared dependency's, a platform/system object, an overlay row): same field verdicts, same refusals. -- A write that names no package (an overlay write) is judged against the whole collection, exactly as before. An object that exists nowhere stays unjudged there. -- The flow door: `flow-trigger-unknown-object` stays an advisory. -- `os validate`, `os build` and `os lint`: no closure is stated, so the rule skips an unresolvable binding as before. -- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. -- Measured before crossing: the 11 views shipped in this repository carry 39 list-view bindings, in `examples/app-crm`, `app-todo`, `app-showcase` and `app-multi-package`, the `create-objectstack` blank template, and `qa/downstream-contract`. None is outside its package's closure, and the door's `view` rule set refuses none of them, with the package's closure or without it. None of the 97 platform built-in list views retargets `data.object`. Deployed rows were not measured. - -**API.** - -- `AuthoringRuleContext` gains an optional `packageScope` (`{ packageId, dependencies }`). Only the runtime publish gate sets it, and only when it narrowed the snapshot's objects to a package closure. `runAuthoringRules` never sets it. -- `validateListViewFieldRefs(stack, options?)` accepts an optional `{ packageScope }` (`ListViewFieldRefOptions`, exported). -- `LIST_VIEW_OBJECT_OUTSIDE_CLOSURE` (`'list-view-object-outside-closure'`) is exported. - - diff --git a/.changeset/24564-reminder-action-link-at-rest.md b/.changeset/24564-reminder-action-link-at-rest.md deleted file mode 100644 index 3e5405a448a..00000000000 --- a/.changeset/24564-reminder-action-link-at-rest.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -fix(plugin-approvals): a reminder's notification no longer carries one-tap approve / reject links, so no stored notification row holds a raw action token - -Clause-②: no - -- **What changed.** `remind()` still sends each concrete pending approver their own `approval.reminder` notification, with the same title, body and deep link to the request in the Approvals Inbox. It no longer mints ADR-0043 action tokens for that notification, and the payload it hands to `messaging` has no `actions` list. Messaging stores that payload, on the `sys_notification` event row and on each `sys_notification_delivery` row, so the links were kept in the database as usable links. ADR-0043's storage rule (`sys_approval_token` keeps SHA-256 digests only) exists so that the database holds none. -- **What a recipient sees.** Nothing changes. A reminder names no channel, so it is delivered to the in-app inbox, and the inbox row never carried the links. -- **Unchanged.** The action page (`GET` and `POST /api/v1/approvals/act`), `issueActionTokens`, `peekActionToken` and `redeemActionToken` behave exactly as before, and a redeemed token still decides as the approver it binds. The approval notification payload type no longer accepts links, and `issueActionTokens` now states that a raw token may travel only in a message that is sent and not kept. -- **Rows written before this fix.** A reminder sent by an earlier build stored its links. Each of those tokens stops working at its 72-hour expiry, or earlier: once it is used, once its request is decided or recalled, or once its slot is reassigned. The rows themselves age out under the notification tables' retention, 90 days at most. diff --git a/.changeset/24565-audit-def-cache-follows-metadata.md b/.changeset/24565-audit-def-cache-follows-metadata.md deleted file mode 100644 index 760ed528c4c..00000000000 --- a/.changeset/24565-audit-def-cache-follows-metadata.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -fix(plugin-audit): an object definition changed through the metadata door takes effect in the audit writers at once, with no restart - -Clause-②: no - -The audit writers kept each object's first definition for the life of the process. They now read the registered definition on every call. The registry already holds the merged definition behind its own invalidation points, so the read is two map lookups. - -- **Attachments follow `enable.files`.** An object whose `enable.files` was switched on through `PUT /api/v1/meta/object/:name` kept refusing `sys_attachment` writes with 403 `FILES_DISABLED` until the server restarted, although the metadata read-back already showed `enable.files: true`. The same attachment is now admitted at once, and switching the flag off refuses it again. `enable.feeds` on comments follows the same read. -- **The ledger copy follows field changes.** A `password` field added through the door to an already-audited object is masked on the ledger copy from the moment it is added, as on every other read exit. The same read decides the mask for `secret` fields and the omission of `internal` ones. -- **The activity row follows the object.** `enable.activities`, `activityMilestones`, the title field and the object label are read from the current definition too. - -What the metadata door accepts is unchanged. diff --git a/.changeset/24567-drop-gate-refusal-18-0.md b/.changeset/24567-drop-gate-refusal-18-0.md deleted file mode 100644 index e28c7313491..00000000000 --- a/.changeset/24567-drop-gate-refusal-18-0.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os migrate apply --allow-destructive`'s organization-ownership refusal says what 18.0 supports for a v17 database, and names no step of the command 18.0 refuses - -Clause-②: no - -- **`os migrate apply --allow-destructive` on a v17-shaped database.** When the run would drop `organization_id` from a table the organization-ownership ceremony owns, and the ceremony has not verified on the database, the refusal now says that 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, whose ceremony drops these columns itself. The v18 boot refusal and `os migrate organization-ownership`'s own refusal say the same. Before, it told the operator to run `os migrate organization-ownership --out PLAN` and then `--apply --plan-file PLAN`, which 18.0 refuses on every database (`NOT_IMPLEMENTED`). The `--json` refusal's `detail` no longer names that command either: it reads "No run of the organization-ownership ceremony is recorded on this database.", or names the last unverified run with its date and blocking count. -- **`os migrate organization-ownership --help`** shows what the command answers in 18.0: exit `1` with `NOT_IMPLEMENTED`, nothing read or written, and the same refusal as JSON. Before, it showed six plan and apply examples that 18.0 refuses. -- **`os dev` on a v17-shaped development database.** When it does not upgrade the database in place (the ceremony's plan is refused, or the database is not a local SQLite file), it now ends with the same position: keep a v17 development database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there. Before, it told the developer to run `os migrate organization-ownership --dev` and then the same command with `--apply`. - -Nothing else changes. The refusals still hold back the whole run, and their message code (`refused_organization_ownership_unmigrated`), `tables`, `marker`, exit code `1` and every flag are as before. diff --git a/.changeset/24576-try-catch-help-words.md b/.changeset/24576-try-catch-help-words.md deleted file mode 100644 index 0b9e4bc959d..00000000000 --- a/.changeset/24576-try-catch-help-words.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -The flow inspector's Error Variable help for a Try / Catch node describes the caught error in words - -Clause-②: no (help text only: the `try_catch` descriptor's `configSchema` accepts exactly what it accepted before, and the value bound to the error variable is unchanged) - -- The `errorVariable` description no longer names a TypeScript type (`TryCatchErrorValue`). It now says what the bound value carries, in plain language: the id of the Try / Catch node (`nodeId`), the failure message (`message`), the platform error code when the failing step reported one (`code`), and, for a failure inside a loop, the zero-based pass number (`iteration`) and the item being processed (`item`). The default variable `$error` is named. -- The old text also left out `code`, which the engine has bound since the classified-code change; an author reading the inspector had no way to learn that a catch region can branch on it. diff --git a/.changeset/24577-mirror-declared-values.md b/.changeset/24577-mirror-declared-values.md deleted file mode 100644 index 94e6d73c1e0..00000000000 --- a/.changeset/24577-mirror-declared-values.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -fix(plugin-approvals): the approval status mirror writes only a value the field declares, so a recalled record whose status field is a select goes back to where it stood before it entered approval - -Clause-②: no - -**The rule:** when `approvalStatusField` is an option field (a `select`) whose options do not declare the status, a recall or the dead-run sweep writes the value the field held when the flow started (the request's submit snapshot), else the field's declared default, else empty, and every other transition writes nothing and logs a `warn` naming the object, field and value. - -- **Before.** Each status was written verbatim. The engine checks a `readonly` field's value for shape only, and the spec advises a readonly status field, so a select that did not declare `recalled` ended up holding it after a recall. An app whose submit gate admits only the select's own values (for example `not_required` or empty) then never re-entered approval. On an editable select the engine refused the undeclared value, and the field kept reading `pending` with no request pending. -- **Now.** A recalled record, or one released by the dead-run sweep, reads its submit-time value again, so the app's own start condition decides whether it re-enters approval. A gate that admits that value re-admits the record at once, because the restore is itself an update. -- **Send-back keeps the field.** A select that does not declare `returned` keeps reading `pending` through the revise window. The run is still alive there (ADR-0044), and a restored pre-approval value would re-admit the start condition and open a second, parallel approval of the same record. -- **Unchanged.** A status the select declares is written as before. A field with no declared value set (text, or any type that is not an option type) gets every status verbatim, as before. The write's context is unchanged. diff --git a/.changeset/24597-field-code-catalog.md b/.changeset/24597-field-code-catalog.md deleted file mode 100644 index 726e9d2f3c4..00000000000 --- a/.changeset/24597-field-code-catalog.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): two hand-built 400 refusals now put an ADR-0114 `FieldErrorCode` catalog member on `details.fields[].code` instead of a Zod issue code (#24597) - -Clause-②: no - -`FieldErrorCode` is a closed catalog: a service may not invent a field code. Two refusals built their `details.fields[]` rows by hand and wrote Zod's issue code there instead of a catalog member: - -- **The run-lifecycle body refusal** on `POST /api/v1/automation/:name/runs/:runId/cancel` and `POST /api/v1/automation/:name/runs/:runId/restore-suspension`. For an unknown body key, the row's `code` changes from `unrecognized_keys` to `unknown_field`. `unknown_field` is the code the `resume` door on the same run already answers for an unknown key. -- **The permission-set clone door's name rule** on `POST /api/v1/security/permission-sets/:name/clone`. For a `name` that is not lowercase snake_case, the row's `code` changes from `invalid_string` to `invalid_format`. - -What changes on the wire: a client that switched on `details.fields[].code` sees `unknown_field` where it saw `unrecognized_keys`, and `invalid_format` where it saw `invalid_string`. Both are members the published catalog already declares. Nothing else changes: each refusal keeps its status (400), its `error.code` (`VALIDATION_FAILED`), its message and its rows' `field` and `message`. No body is accepted or refused that was not before, and no catalog member is added. diff --git a/.changeset/24613-run-identity-email.md b/.changeset/24613-run-identity-email.md deleted file mode 100644 index 3ecbeb440c7..00000000000 --- a/.changeset/24613-run-identity-email.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/service-automation': minor -'@objectstack/spec': minor ---- - -fix(service-automation): a `runAs: 'user'` flow now carries its user's email, so a row policy on `current_user.email` admits the run on the rows it admits for the same user's own request - -Clause-②: yes (widening) - -A `runAs: 'user'` flow's data nodes ran without the acting user's email, which the request path resolves for every user. The row-level security compiler reads `current_user.email` from the execution context's `email` and nothing else, so every owner policy keyed on it (such as `assignee == current_user.email`) dropped out inside the run. A record-change flow triggered by a member whose only access to a row is such a policy was refused on the row the member had just written, and could read and update with their own request. - -Run setup now seeds the email through the resolver the request path uses (`resolveUserAuthzGrants` from `@objectstack/core`, the user's stored `sys_user` email), and every data node passes it on: - -- It is seeded for every trigger shape that names a user and carries no email: the record-change shape (`userId` and `tenantId` only) and the REST and action doors' shape (positions and permission sets already resolved). Only the missing facts are taken from the resolver; forwarded positions and permission sets stay exactly as they were. -- A context that already carries an email keeps it verbatim, as a request's session email wins over the stored one. A `subflow` or `map` child and a suspended run keep the email their run was seeded with. -- After an approval decision, the nodes that run as the decider carry the decider's own email, resolved for them. The submitter's email is removed with the rest of the submitter's grants, on both `runAs` postures. -- A user with no stored email, or a resolver failure, leaves the run without one: policies that read it drop out, and the run sees less, never more. The `[runAs]` warning on a resolver failure now names the email beside the peer list. -- A run suspended before this release resumes without the email until it ends. - -A `runAs: 'system'` flow is unchanged. The flow CEL scope's `current_user` is unchanged too: it still binds no email. - -Published types: `AutomationContext` (`@objectstack/spec/contracts`) gains an optional `email` member, and `RunIdentityContext` / `RunDataContext` and `FlowUserGrants` (`@objectstack/service-automation`) each gain an optional `email` member. diff --git a/.changeset/24614-system-run-user-grants.md b/.changeset/24614-system-run-user-grants.md deleted file mode 100644 index b7e61bb2213..00000000000 --- a/.changeset/24614-system-run-user-grants.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/service-automation': minor ---- - -fix(service-automation)!: a `runAs: 'user'` hook fired by a user-triggered `runAs: 'system'` flow's write runs on that user's own grants - -Clause-②: no (narrowing) — a `runAs: 'user'` hook fired by a write of a `runAs: 'system'` flow that names a user now runs on that user's own grants instead of the member baseline, so for a user whose own grants admit less than the baseline its reads narrow; the published `RunIdentityContext` / `RunDataContext` types make `positions` and `permissions` optional, and no published schema or export moves. - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -A `runAs: 'system'` flow run that names a user (a manual run, a record change fired by a user's write, and every post-decision node of a `runAs: 'system'` approval flow, where the user is the decider) handed ObjectQL `{ isSystem: true, userId, tenantId, positions: [], permissions: [] }`. Its own operations never read the two arrays: elevation short-circuits every gate before they are read. But a `runAs: 'user'` hook fired by one of its writes de-elevates to that context, and the engine keeps the arrays a context carries, resolving the user's grants only when none are carried. So that hook ran on the member baseline, whoever the user was. - -- **Now:** a `runAs: 'system'` run that names a user carries no `positions` or `permissions` in its data context. A `runAs: 'user'` hook its writes fire resolves the named user's own positions, permission sets and organization peers in the run's organization, exactly as that user's own request resolves them. After an approval decision that user is the decider, so the hooks the post-decision writes fire run on the decider's grants. -- **Wider:** for a user granted more than the baseline, such a hook now reads and writes what the user may, where it was refused on objects the baseline does not grant. -- **Narrower:** for a user whose own grants admit less than the baseline, such a hook now reads only what that user's own request reads. -- **Unchanged:** the run's own elevation, `isSystem`, its `actor` label, its `userId` and its `tenantId`; a user-less `runAs: 'system'` run (a schedule), which keeps `positions: []` and `permissions: []`, and a `runAs: 'user'` hook it fires is still refused for want of a user; hooks declaring `runAs: 'system'` or `'inherit'`; and `runAs: 'user'` flows. - -The published `RunIdentityContext` and `RunDataContext` types of `@objectstack/service-automation` declare `positions` and `permissions` optional, because a `runAs: 'system'` envelope that names a user no longer carries them. Code that reads either member from `resolveRunDataContext`'s answer must allow for its absence. - -What changes for you: a `runAs: 'user'` hook that a user-triggered `runAs: 'system'` flow fires now sees exactly what that user sees. If such a hook must read or write something the user may not, declare it `runAs: 'system'`. diff --git a/.changeset/24618-date-bucket-calendar-day.md b/.changeset/24618-date-bucket-calendar-day.md deleted file mode 100644 index 895e03056af..00000000000 --- a/.changeset/24618-date-bucket-calendar-day.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): a `date` dimension buckets on its own calendar day under every timezone on the in-memory path, as the SQL pushdown path already did - -Clause-②: no (no authorable key, export or signature moves; the in-memory answer for a `date` dimension under a non-UTC zone now equals the stored day, which is what the pushdown path answers) - -- **A `Field.date` is a calendar day, not an instant (ADR-0053).** When `engine.aggregate` buckets in memory (any non-UTC `timezone` forces that path), a `groupBy` item with a `dateGranularity` on a field the object declares a `date` is now labelled by that field's own `YYYY-MM-DD`, with no zone. Before, the value was read as that day's UTC midnight and placed on the requested zone's calendar, so in every zone west of UTC each row landed one day early: `2026-10-01` bucketed as `2026-09-30`, and month, quarter and year totals moved across their edges (`POST /api/v1/analytics/query` with `timezone: "America/New_York"` answered `{2026-09: 1, 2026-10: 5, 2026-11: 4}` for six October and four November deliveries). -- **A `datetime` keeps the zone.** An instant still lands on the requested zone's calendar day, unchanged. So does a field the declaration cannot type: `applyInMemoryAggregation` called without its `fields` argument, an undeclared field, or a `formula`. -- **The value is read by the `date` storage rule first** (`temporalStorageForm(value, 'date')` in `@objectstack/core`): a bare `YYYY-MM-DD` is itself, and a `Date` handed back by a driver is its UTC calendar day. diff --git a/.changeset/24637-spec-upgrade-entries-fresh-install-only.md b/.changeset/24637-spec-upgrade-entries-fresh-install-only.md deleted file mode 100644 index 6107e9ce91e..00000000000 --- a/.changeset/24637-spec-upgrade-entries-fresh-install-only.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the 18.0 upgrade-guide entries that name `os migrate organization-ownership` say first that 18.0 supports fresh installs only - -Clause-②: no - -In 18.0, `os migrate organization-ownership` refuses plan and apply alike on every database (exit 1, `NOT_IMPLEMENTED`, nothing read or written). Seven ADR-0087 semantic entries still told an operator to run `--out PLAN`, then `--apply --plan-file PLAN`, as if 18.0 upgraded a v17 database. Each now says, in the same words as the CLI and the boot refusal: 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path. The plan and apply steps stay, marked as the 18.x release's ceremony. - -- `sys-metadata-organization-column-retired`, `sys-metadata-audit-organization-column-retired`, `sys-metadata-commit-organization-column-retired`, `sys-metadata-history-organization-column-retired`, `ups-permission-set-id-column-retired`: the replacement and the acceptance criteria. -- `sharing-rule-declared-mirror-retired`: the replacement and the acceptance criteria. -- `cli-organization-ownership-apply-plan-file-required`: the replacement now says that 18.0 refuses this invocation, as it refuses every invocation of the command, and keeps the plan-file rule as the 18.x release's. - -Only the text changes. Every entry keeps its `id`, its surface and its step. diff --git a/.changeset/24644-console-pin-12dc7b47c340.md b/.changeset/24644-console-pin-12dc7b47c340.md deleted file mode 100644 index 82c189c7a81..00000000000 --- a/.changeset/24644-console-pin-12dc7b47c340.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `12dc7b47c340`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 28 releasing of 28 changesets added across 32 non-merge commits; omitted: 4 commits carrying no changeset (they ship no package code). - -- **minor** — Setup lists the sharing rules installed packages declare again, read-only, with Disable / Enable and Clone (objectui#12982). (objectui `12dc7b47c`) -- **minor** — `list-view` publishes the keys of its `@objectstack/spec` row that the renderer honours on a page node (objectui#13047). (objectui `a244a172a`) -- **minor** — **BREAKING** — An import undo that leaves rows unreverted now says so in the Import Wizard (objectui#13015). (objectui `a928aaa61`) -- **minor** — A `type: 'form'` action placed at `record_related` and fired from a related-list row now opens the clicked row's edit form (`/forms/NAME?recordId=ROW_ID&recordObject=CHILD`), as i… (objectui `9f1dd79fe`) -- **minor** — A field group whose `visibleWhen` cannot be evaluated is now hidden, header and members, on the form and on the record detail page (objectui#13055). (objectui `fb85637e1`) -- **minor** — The AI build chat's pending-drafts bar no longer covers the composer's input (objectui#12978). (objectui `51269f0fd`) -- **minor** — Bound record blocks draw an authored empty state when their binding answers no row (objectui#12932, the renderer half of objectstack-ai/objectstack#24266). (objectui `c2191654a`) -- **minor** — Publish `optionListDefault` (and its parameter type `OptionListFieldLike`) from the package entry: the reading of an option list's `default: true` that create forms seed from (obj… (objectui `b9e188762`) -- **minor** — Build `@objectstack/*` from objectstack `main` `f4755a68a7` (objectui#12971). (objectui `8aa8e3e1e`) -- **minor** — Two Cloud SDUI widgets are retired: `cloud:onboarding-next`, the Welcome page's next-step block, and `cloud:plan-status`, the pricing page's current-plan marker (objectui#12954).… (objectui `30083da91`) -- **minor** — Every app now has a report index at `/apps/APP/reports` (objectui#13020). It lists the reports the app's package ships, by their localized labels, each linking to its `report/NAME… (objectui `47508f3bb`) -- **minor** — A record page that shows the same unfiltered related list both as a `record:reference_rail` card and in a `page:tabs` count badge now reads that list once on open, not twice. The… (objectui `d0b9648ef`) -- **patch** — Studio's RLS policy editor now gives a predicate the runtime cannot lower the server's verdict: an error that holds Save and autosave (objectui#13061). A `USING` or `CHECK` clause… (objectui `2a75c83b2`) -- **patch** — A master-detail edit form now applies its line rules to the header record however late that record loads (objectui#13062). (objectui `bc98626ed`) -- **patch** — A deep link to an object read by name always leaves the "Opening your console…" splash (objectui#13052). (objectui `1ce071187`) -- **patch** — A kanban card move is announced to screen readers when its write answers, not when the card is dropped (objectui#13054). (objectui `083e80735`) -- **patch** — An `app` page that declares a `record` binding now reads that row, and its components' `visibleWhen` predicates read it as `record` (objectui#12931, the renderer half of objectsta… (objectui `a23a33d6f`) -- **patch** — One page open asks the server for one object document once (objectui#13044). The metadata provider's by-name object read now goes through the adapter's `getObjectSchema`, so the p… (objectui `65fe4c285`) -- **patch** — A record page no longer asks `POST /api/v1/security/explain` for a verdict nothing on screen uses (objectui#13045). (objectui `50ba1bb0a`) -- **patch** — Charts and dataset widgets read their object's metadata through the data source, so the widgets of one dashboard ask for one object's metadata once (objectui#13039). (objectui `988aa9fa1`) -- **patch** — The Undo of an `operation: 'update'` action covers the `patch` of the declared action its `name` names, and no action renderer forwards a page action row's own `patch` (objectui#1… (objectui `61ed5ada6`) -- **patch** — A list view draws a `referenceVia` pointer column's record when its columns do not show the type half (objectui#13036). (objectui `fe11eb12e`) -- **patch** — A record link in an assistant answer opens that record inside the console (objectui#13037). (objectui `633b8f18f`) -- **patch** — The chat's step line no longer shows the agent's tool-loading step, and no longer counts it (objectui#13038). (objectui `912bfd4a6`) -- **patch** — An assistant answer reads well in the narrow assistant panel (objectui#13032). Headings inside a chat answer render at chat size (15–16px, bold, set apart by weight and spacing in… (objectui `6f877f1df`) -- **patch** — The console's pending-approvals read now asks for one page of requests and takes the count from the route's `total` (objectui#13027). (objectui `d14ba9636`) -- **patch** — A record page of a `trackHistory` object reads its audit history when the History tab is shown (objectui#13010). (objectui `acefe6fa6`) -- **patch** — A record page reads its timeline, comments, user list and foot-of-page attachments when they are shown, not on every open (objectui#13010, part 2). (objectui `29c19cc1a`) - -⚠️ 1 of these carries a breaking change: 1 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 4 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. - -- _(no changeset)_ fix(ci): read transient jobs-API failures, re-run attempts and stale shards without false reds in the aggregate Test gate (objectui#13064) (#13077) (objectui `90a4fc49b`) -- _(no changeset)_ tooling(pin): keep one pinned @objectstack build per machine and copy it into each new checkout (objectui#12972) (#13035) (objectui `f9401e6af`) -- _(no changeset)_ tooling(pin): commit the pin's packed manifests so a lockfile-only resolution needs no build (objectui#13008) (#13031) (objectui `b014f5eea`) -- _(no changeset)_ tooling: publish computes each @objectstack/* floor from .objectstack-sha (objectui#12958) (#13024) (objectui `4484a1104`) - -Clause-②: no - - - -objectui range: `4016370af3c9...12dc7b47c340` diff --git a/.changeset/24652-report-v18-upgrade-text.md b/.changeset/24652-report-v18-upgrade-text.md deleted file mode 100644 index 49839d91ac2..00000000000 --- a/.changeset/24652-report-v18-upgrade-text.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security): the boot report's unnamed-grant line says what 18.0 supports for a v17 database, not a command 18.0 refuses - -Clause-②: no - -- **The error line for `sys_user_permission_set` grants whose `permission_set` is empty** used to end with a fix: plan with `os migrate organization-ownership --out plan.json`, then run `os migrate organization-ownership --apply --backup-taken --plan-file plan.json`, restart, with a warning that granting the set again by name before it runs leaves a second grant for the ceremony to report. 18.0 refuses that command on every database, so the operator was sent to a refusal. The line now ends with the sentence the drop-gate refusal of `os migrate apply --allow-destructive` carries: "18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path." -- **Unchanged:** the count, the "permission_set is empty" fact, the ADR-0131 D4 reason ("each confers nothing"), "Nothing was changed.", the structured meta, the log level and the report's result. Such a grant still confers nothing and is still listed. -- **Removed from the line:** the two command steps and the restart step, and the advice about granting the set again by name. They described the 18.x upgrade ceremony, which 18.0 does not carry. diff --git a/.changeset/pre.json b/.changeset/pre.json deleted file mode 100644 index 421211bc431..00000000000 --- a/.changeset/pre.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "mode": "exit", - "tag": "next" -} diff --git a/.changeset/pre/11509-element-flat-binding-retired.md b/.changeset/pre/11509-element-flat-binding-retired.md deleted file mode 100644 index cb03d665eb1..00000000000 --- a/.changeset/pre/11509-element-flat-binding-retired.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/spec': major -'@objectstack/lint': major ---- - -feat(spec)!: an element binds data through the node-level `dataSource` only — the flat `object` / `filter` / `sort` / `limit` of `element:record_picker`, `element:number` and `element:repeater`, and `object-grid.defaultFilters`, are retired (#11509) - -Clause-②: no (narrowing: the ten element-layer flat data-binding keys and `object-grid.defaultFilters` leave the accept set, and the component-props gate's `dataSource.object` waiver becomes a refusal) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). `@objectstack/lint` is `major` with it: its component-props rule refuses what it used to waive. - -**Why.** One page element carried two doors onto one query. Each of these flat keys was the same query as a key of the node-level `dataSource` binding (`ElementDataSourceSchema`), and the three renderers resolved them by three contradictory rules: the record picker let the binding win, `element:number` AND-combined the two filters, and the repeater read the flat keys alone and ignored the binding — while the component-props rule waived a missing flat `object` whenever `dataSource.object` was present, so a repeater bound only through `dataSource` passed `os validate` and drew "No records". The console moved all three elements onto the binding first (objectui#11880); from this release the binding is the one door. `object-grid.defaultFilters` was the legacy second spelling of `filter`, read only when `filter` lowered to nothing. - -**What changes.** - -- **`@objectstack/spec`.** `ElementRecordPickerPropsSchema` `object` / `filter` / `sort` / `limit`, `ElementNumberPropsSchema` `object` / `filter`, `ElementRepeaterPropsSchema` `object` / `filter` / `sort` / `limit` and `ObjectGridPropsSchema` `defaultFilters` are `retiredKey()` tombstones: each is refused at its key with the prescription, and its input type is `never`. The repeater's other spellings of its query keys (`objectName`, `where`, `orderBy`, `top`, …) now point at the binding. The `object-*` blocks keep their own keys, and the relationship-scoped blocks are unchanged. -- **`@objectstack/lint`.** `validate-component-props` (`component-props-invalid`, warning) reports an `element:record_picker`, `element:number` or `element:repeater` node with no `dataSource.object` at that path, instead of waiving the flat `object` for any type whose binding names one. A flat key is reported by its tombstone. -- **Conversions (stored rows, artifacts, `os migrate meta --from 17`).** `element-flat-data-binding-to-data-source` moves a flat key the binding lacks onto it, deletes one the binding already set where the binding won, and appends `element:number`'s flat filter to the binding's (they always AND-combined). It leaves for the author, as a TODO: a record-picker key beside a `dataSource.view` that sets no such key of its own, a repeater key the binding sets to a different value or beside a `view`, and an `element:number` filter pair that is not two rule arrays. `object-grid-default-filters-removed` moves `defaultFilters` onto an empty `filter` (absent, `null`, `[]` or `{}`) and deletes it beside a `filter` that has rules. Both run before `page-component-filter-record-to-rule-array`, which then converts a moved record-form filter at its new door. Both are retired from the load path: an author is refused at the parse. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `{ type: 'element:record_picker', properties: { object: 'deal', limit: 20, labelField: 'name' } }` | `{ type: 'element:record_picker', dataSource: { object: 'deal', limit: 20 }, properties: { labelField: 'name' } }` | -| `{ type: 'element:number', properties: { object: 'deal', aggregate: 'count', filter: [...] } }` | `{ type: 'element:number', dataSource: { object: 'deal', filter: [...] }, properties: { aggregate: 'count' } }` | -| `{ type: 'element:repeater', properties: { object: 'deal_note', sort: [...], titleField: 'subject' } }` | `{ type: 'element:repeater', dataSource: { object: 'deal_note', sort: [...] }, properties: { titleField: 'subject' } }` | -| `object-grid` `properties: { defaultFilters: [...] }` (no `filter`) | `properties: { filter: [...] }` | -| `object-grid` `properties: { filter: [...], defaultFilters: [...] }` | `properties: { filter: [...] }` — the fallback was never read beside a `filter` with rules | - -**The one-line fix: move each key from `properties` to the node's `dataSource` (a sibling of `type`), unchanged; rename `defaultFilters` to `filter` where `filter` is empty, and delete it where it is not.** - -**For a consumer that pins both repositories:** its objectui pin moves past objectui#11880 no later than its objectstack pin moves past this release — the converted shape is one only that objectui reads. - -**Who is affected, measured.** This repository authors none of the eleven keys: its one element-layer author (the showcase record picker) already binds through `dataSource`, and a tree-scoped absence pin (`packages/spec/src/ui/element-flat-binding-retirement.test.ts`) keeps it that way. Other repositories and deployed metadata were not measured here. - -### The retirement kit - -- `RETIRED_KEYS_BY_MAJOR[18]`: `ui/ElementRecordPickerProps:object|filter|sort|limit`, `ui/ElementNumberProps:object|filter`, `ui/ElementRepeaterProps:object|filter|sort|limit`, `ui/ObjectGridProps:defaultFilters`. D3 entries `element-flat-data-binding-retired` and `object-grid-default-filters-retired`, each with a step-18 rationale fragment. They absorb the three protocol-18 narrowings of the same keys to the rule array (`element-number-filter-rule-array`, `element-record-picker-filter-rule-array`, `object-grid-default-filters-rule-array`), whose keys are gone in the same major; `page-component-filter-record-to-rule-array` drops the retired doors from its reach. -- Generated: `authorable-surface/ui.json` (11 rows `[RETIRED]`) and the `ui/component` reference page. Hand-edited ledger: `dropped-refinements.baseline.json` loses the three rows whose only dropped refinement was a retired `filter`. -- Pins: the tombstones, the binding, both conversions and the tree-scoped absence walk in `element-flat-binding-retirement.test.ts`; the missing-binding refusal and the repeater trap in `validate-component-props.test.ts`; the docs gate's twin in `check-yaml-examples.ts --self-test`. diff --git a/.changeset/pre/11979-default-org-admin-grant.md b/.changeset/pre/11979-default-org-admin-grant.md deleted file mode 100644 index 47d85689a9b..00000000000 --- a/.changeset/pre/11979-default-org-admin-grant.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/plugin-security": minor -"@objectstack/plugin-auth": minor ---- - -fix(security): under the `single` tenancy posture the first-user platform-admin grant is owned by the Default Organization, and every reader of platform standing reads that row - -Clause-②: yes (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -- **FROM** (`@objectstack/plugin-security`): a `single` deployment holding several organizations, none slugged `default`, promoted its first user with an organization-less `admin_full_access` grant. **TO:** it is not promoted; the bootstrap logs one `error` line, returns `reason: 'default_organization_unresolved'` and writes no grant row. **Fix:** give the intended organization the slug `default` (or keep one organization, or declare a walled posture); the next `kernel:ready` or sign-up pass promotes. -- **FROM** (`@objectstack/plugin-auth`): a `sys_organization` `slug` change or delete was never refused. **TO:** it is refused with `403 PERMISSION_DENIED` when that organization is the Default Organization whose `admin_full_access` grant is the last unbanned administrator's anchor. **Fix:** give another account administrator standing first, then make the change. -- **FROM** (`@objectstack/plugin-auth`): `ensureDefaultOrganization` under `single`, with a sole organization of another slug, created a second, `default`-slugged organization and bound the administrator there. **TO:** it reuses the sole organization; no second organization is created. **Fix:** none needed; an install that wants a `default`-slugged organization re-slugs its existing one or creates it explicitly. -- **Widening** (`@objectstack/core`): under `single` only an organization-less `admin_full_access` grant conferred `PLATFORM_ADMIN`. Now a grant owned by the Default Organization confers it too and applies in every organization; a pre-existing Default-Organization-scoped `admin_full_access` grant gains the rung on upgrade; and `sys_organization` becomes a standing-deriving table (the resolver reads it only under `single` for a holder of an organization-owned `admin_full_access` grant — one or two small reads per uncached request; the last-administrator guard adds three to six small reads per guarded write). - -- **The promotion writes the Default Organization (`@objectstack/plugin-security`).** A `single` deployment with no configuration still promotes its first user that can sign in. The `admin_full_access` grant row that promotion writes now carries the Default Organization as its `organization_id` (ADR-0131 D5), never NULL. The Default Organization is the one with `slug = 'default'`, else the only organization: the organization the auth plugin creates at boot. A deployment that names no Default Organization (several organizations, none with the slug `default`) is not promoted. The bootstrap logs one `error` line saying why and returns `reason: 'default_organization_unresolved'`. It writes no grant without an owner. `OS_PLATFORM_OWNER_EMAIL` is still the walled-posture channel and is not required under `single`. -- **The authorization resolver reads that row (`@objectstack/core`).** Under `single`, `PLATFORM_ADMIN` derives from an `admin_full_access` grant owned by the Default Organization, and still from the organization-less row an earlier release wrote. The anchoring grant applies in whichever organization the user acts in, as the organization-less row always did. A grant of the set owned by any other organization confers the set in that organization, never platform standing. Under a walled posture no grant anchors standing (unchanged). One predicate decides both postures, with the posture as its parameter. The resolver reads `sys_organization` only for a user who holds an organization-owned `admin_full_access` grant under `single`. -- **An upgraded database keeps its administrator.** `os migrate organization-ownership --apply` gives a `single` database's organization-less grants to the Default Organization, the platform admin's grant included. Before this change the resolver then stopped grading that admin `PLATFORM_ADMIN`, and every later boot found no organization-less holder and promoted again. The holder scan now reads the Default Organization's grant, so a migrated database keeps its admin and promotes nobody else. A user who holds both rows counts as one holder. -- **The last-administrator guard and the default-organization bootstrap read it too (`@objectstack/plugin-auth`).** The break-glass guard counts the holder of the Default Organization's grant as a platform admin and treats that grant as evidence that the environment once had one. It also judges the two organization writes that move the anchor off that grant: changing the Default Organization's `slug` while another organization exists, and deleting it. Either is refused when it would leave no administrator who can sign in. `ensureDefaultOrganization` finds the admin through the same grant. Under `single` it also reuses the Default Organization when that is a sole organization with another slug, where it used to create a second, `default`-slugged organization. diff --git a/.changeset/pre/13458-manifest-permissions-string-list-retired.md b/.changeset/pre/13458-manifest-permissions-string-list-retired.md deleted file mode 100644 index ba966cbf27a..00000000000 --- a/.changeset/pre/13458-manifest-permissions-string-list-retired.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/runtime': patch -'@objectstack/plugin-security': patch ---- - -feat(spec)!: a package manifest's `permissions` no longer takes a flat list of permission strings — the structured `{ services, hooks, network, fs }` block is the only form (#13458) - -Clause-②: no (narrowing) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not yet in on `main`). - -`ManifestPermissionsSchema` was a union of a flat `string[]` and the structured ADR-0025 §3.2 block. Nothing ever acted on the list: the loader registers the consented `grantedPermissions` set from the environment artifact with the permission enforcer, never the manifest's request, and the only code that met a list on a manifest was two reports saying it had been skipped. The marketplace install disclosure reads only the four lists, so a list was shown to an installer as "Requests no special permissions." (ADR-0049 enforce-or-remove). `ManifestPermissionsSchema` is now the structured block itself. - -### FROM → TO - -| before | what to write instead | -| --- | --- | -| `permissions: ['system.user.read', 'system.data.write']` | `permissions: { services: [...], hooks: [...], network: [...], fs: [...] }`, naming the platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it reaches and the filesystem paths it touches. | -| `permissions: []` | delete `permissions`: absence is the spelling for "requests nothing". | -| a list on an app manifest that ships no code | delete `permissions`. An app's record access is its permission SETS, in the stack's own top-level `permissions` collection. | - -**The one-line fix: replace every manifest `permissions` list with the structured block, or delete it.** A permission string has no mechanical mapping onto the four lists, so the translation is done by hand. `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. - -**What an author now sees.** Writing a list fails `tsc` (the key's type is the block), and `os validate`, `os build`, `os plugin build` and `defineStack` refuse it at `manifest.permissions` with the block's own answer: `Expected the plugin permission block { services?, hooks?, network?, fs? }, received a flat list.`, followed by the prescription. Every structured block that parsed before still parses, unchanged. - -### The retirement kit - -- **Schema.** `ManifestPermissionsSchema` is `PluginPermissionsSchema`, by identity, and both export names stay. The block answers a list with its prescription on its own error map, the bare-array pattern `ListViewExportOptionsSchema` uses. The block also carries `EnvironmentArtifactSchema.grantedPermissions` values, so the answer is worded true there too, where a list was never legal. -- **D2 conversion `manifest-permissions-string-list-removed`** (step 18, retired from the load path): a lossless delete of an all-string list from the stack's `manifest` and every `packages[].manifest`. The notice carries the dropped strings. A built artifact replays it at the artifact door, so an artifact built while the list was legal still boots. It never touches the structured block, an array of objects, or the top-level ADR-0090 permission-set collection. -- **D3 entry `manifest-permissions-string-list-retired`** carries the judgement the delete cannot make: what each dropped string meant in services, hooks, hosts and paths. -- **Liveness.** `manifest.permissions` stays `live` on corrected evidence. Its consumer is the marketplace install disclosure, and it refuses nothing at load. The four keys are now drilled. -- **The two skip reports reworded.** `AppPlugin`'s security registrar and `@objectstack/plugin-security`'s audience-binding reconciler both report a manifest-stage `permissions` they cannot read as permission sets. They now name the flat list as the retired legacy form. They behave as before. - -**Measured producers: none outside tests.** On origin/main e67ba80049, no manifest in `examples/`, `apps/`, `packages/`, `skills/` or `content/docs/` writes a list. The exceptions are five `@objectstack/spec` `manifest.test.ts` fixtures, re-triaged here, and the skip-report tests of `@objectstack/runtime` and `@objectstack/plugin-security`, which hand a list to an unparsed bundle on purpose and still pass. The same instrument finds those five fixtures, which is its control. At the objectui pin `a58626c88dc8`, nothing reads `manifest.permissions` (control: 91 `manifest.(id|name|version)` reads), and the install disclosure reads the structured block alone. Deployed and cloud-held manifests NOT MEASURED. diff --git a/.changeset/pre/15195-metadata-protocol-seed-organization-stamp.md b/.changeset/pre/15195-metadata-protocol-seed-organization-stamp.md deleted file mode 100644 index 990370a1a3e..00000000000 --- a/.changeset/pre/15195-metadata-protocol-seed-organization-stamp.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -Every seed row of an object that carries an organization is stamped with the install's organization, platform-namespace seeds included, or the seed loader refuses the row - -Clause-②: yes (narrowing) - - - -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3, D9). - -- **The exemption is withdrawn.** When a seed load names no organization (`config.organizationId`), the loader stamps the install's sole organization on every row of an object that carries an `organization_id` column. Seeds of `sys_`, `cloud_` and `ai_` objects used to be exempt as "intentionally global" and landed NULL. There are no platform-global seeds left, so they now carry the organization too: a seeded `sys_business_unit` is the organization's own business unit. -- **No owner, no row.** When the load names no organization and the install holds none, or holds several, a row of an object that carries an `organization_id` column is refused, counted in the result's errors and named in the message. Nothing of it is written. Before this change those rows were written NULL. A row that sets its own `organization_id` still loads. -- **No column, no stamp.** A row of an object with no organization column (`tenancy: { enabled: false }`, or a platform object whose injected column was dropped) is written without one, whether the organization was named by the load or derived. Before this change such a row was stamped anyway, and the engine refused it as an unknown field, so the row was lost. -- **Unchanged.** A load that names its organization (the per-organization replay under a walled posture) stamps it on every row that carries the column, as before. A composition that registers no organization object at all keeps loading its seeds unstamped. Rows written before this change keep their bytes; attributing that residue is ADR-0131 C7's. - -**The remedy.** Under the `single` posture the Default Organization exists before seeds load, so a boot seed always has its owner. A load that is refused names the organization it needs: pass it as `config.organizationId` (the organization the seed populates on a walled deployment), or set `organization_id` on the record. diff --git a/.changeset/pre/15195-objectql-no-organization-refused.md b/.changeset/pre/15195-objectql-no-organization-refused.md deleted file mode 100644 index 9cf1c94e9a1..00000000000 --- a/.changeset/pre/15195-objectql-no-organization-refused.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -A system-context insert under the `single` tenancy posture is refused when the install holds no organization, instead of landing with no owner - -Clause-②: yes (narrowing) - - - -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9). - -- **Before.** Under the `single` posture, in a composition that registers the organization object, a system-context insert on a tenant-scoped object with no organization anywhere landed with `organization_id` NULL when the install held no organization yet. That was the normal state of a first boot: the organization arrived with the first sign-up. -- **Now.** The Default Organization is a boot invariant under `single` (ADR-0131 D3): `@objectstack/plugin-auth` creates it before the application seeds load and before the server accepts a request. An install that registers the organization object and holds none of it therefore has no owner to derive, and the insert is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500, `reason: 'no-organization'`). The message names the missing organization. Nothing is written. -- **Unchanged.** Exactly one organization is derived and stamped. Several organizations, or a walled posture, are refused as before. A write that carries an organization, on the execution context or on the record, is never refused. Objects with no organization column, objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. The platform-namespace objects the tenancy inventory has not admitted keep their per-object exclusion; ADR-0131 C8 retires it. A composition that registers no organization object at all (a lean embedding) still lands the write unstamped. - -**The remedy.** On a `single` deployment, the refusal means the Default Organization is missing: restart, and the auth plugin recreates it at boot, or carry the organization on the write (`{ context: { isSystem: true, tenantId } }`, or `organization_id` on the record). A TypeScript caller of `resolveSystemWriteOrganization` passes whether its composition registers the organization object. diff --git a/.changeset/pre/15195-plugin-auth-default-organization-boot-invariant.md b/.changeset/pre/15195-plugin-auth-default-organization-boot-invariant.md deleted file mode 100644 index 8e1cf6c0474..00000000000 --- a/.changeset/pre/15195-plugin-auth-default-organization-boot-invariant.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -Under the `single` tenancy posture the Default Organization is created at boot, before the application seeds and before the server accepts a request, and the first admin of a fresh deployment is its owner - -Clause-②: yes (narrowing) - - - -**BREAKING** boot and accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3). - -- **A boot invariant.** Under the `single` posture (the posture in force, so a degraded walled request counts), the auth plugin's `start()` finds or creates the Default Organization (`slug: 'default'`), with or without a platform admin. Every application plugin starts after it. Before this change the organization was created on `kernel:ready` only once a platform admin existed, so on a fresh deployment it arrived with the first sign-up, after the seeds had loaded with no owner. -- **A failure stops the boot.** If the organization cannot be created, or the store cannot be read, `start()` throws and the deployment does not boot. Before, the bootstrap logged a warning and the deployment served anyway. An install holding several organizations and none with `slug: 'default'` gets no new organization; the boot logs it and continues. -- **`autoDefaultOrganization: false` no longer means "no organization".** It now turns off only the platform admin's owner bind. A host that set it to keep an organization-less `single` deployment gets the Default Organization anyway: under `single` no row is organization-less. -- **Every user belongs to it from the first boot.** Because the organization exists before anyone signs up, the membership reconciler binds every new user (under the `auto` membership policy) to it as `member` the moment they are created, so every session carries it as the active organization. Before, a user created before the first admin carried none. The one-time membership backfill likewise has its target at the first `kernel:ready`. -- **The first admin is the owner.** That includes the first admin, whom the reconciler binds as `member` before the owner bind learns they are the platform admin. While the one-time owner bind is undecided and the Default Organization has no owner, the bootstrap promotes that `member` row to `owner` in place and records the decision as `promoted`. A decided bind, an existing owner, or a membership elsewhere is never touched. -- **The in-memory driver answers `503` until C8.** `@objectstack/driver-memory` refuses tenant-scoped reads, and every session now carries the Default Organization, so under `single` its signed-in reads answer `503` until ADR-0131 C8 (#15212, D8) gives `single` no read predicate. The platform admin met the same refusal before this change, once the Default Organization existed. A SQL driver (`connection: { filename: ':memory:' }` for an in-process store) serves the deployment meanwhile. - -**The remedy.** If the boot stops on the Default Organization, make the `sys_organization` insert land: check the datasource's write permission and connectivity, and whether a legacy unique index on `slug` refuses `default`. A host relying on `autoDefaultOrganization: false` for an organization-less `single` deployment has no such deployment any more; run a walled posture if organizations are meant to be absent until created. diff --git a/.changeset/pre/15195-runtime-app-plugin-after-auth.md b/.changeset/pre/15195-runtime-app-plugin-after-auth.md deleted file mode 100644 index a68d179a8b9..00000000000 --- a/.changeset/pre/15195-runtime-app-plugin-after-auth.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -`AppPlugin` starts after the auth plugin when both are composed, so the Default Organization exists before the inline seed loads - -`AppPlugin` now declares the auth plugin (`com.objectstack.auth`) among its order-if-present dependencies. Under the `single` posture the auth plugin creates the Default Organization in its `start()` (ADR-0131 D3), and every seed row is stamped with it; the declaration makes the kernel start the auth plugin first instead of relying on the order plugins were registered in. A composition without the auth plugin is unaffected. diff --git a/.changeset/pre/15195-verify-bootstack-production-single.md b/.changeset/pre/15195-verify-bootstack-production-single.md deleted file mode 100644 index 661a40f359c..00000000000 --- a/.changeset/pre/15195-verify-bootstack-production-single.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/verify': minor ---- - -`bootStack` boots the production `single` shape: the Default Organization exists from the boot, every sign-up is its member, and the harness admin is its owner - -Clause-②: yes (narrowing) - - - -**BREAKING** for fixtures that relied on an organization-less `single` boot, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3, D11). - -- **What `bootStack` boots now.** Under `single`, `@objectstack/plugin-auth` creates the Default Organization before the seeds load, so every boot has one, and `bootStack` no longer turns off the platform admin's owner bind. The harness admin is the Default Organization's `owner`, every user a fixture signs up is its `member`, and their sessions carry it as the active organization. This is the shape `objectstack dev` and `objectstack serve` boot. Before, `bootStack` pinned `autoDefaultOrganization: false` and booted a `single` stack with no organization at all, a shape no deployment runs after this release. -- **`orgContext` asserts, it no longer switches.** `orgContext: true` keeps its refusal: the boot fails when the harness admin holds no membership, and it still refuses to compose with `multiTenant`. The bind itself happens on every boot. -- **Unchanged.** `multiTenant` boots a walled posture as before, and the open default-organization bootstrap still abstains under it. - -**The remedy.** A fixture that needs a principal outside the organization's `org_member` domain removes that user's membership (an administrator's act a real deployment performs) instead of booting without an organization. A fixture that minted its own `slug: 'default'` organization reads the one the boot created. A fixture that ran on `databaseDriver: 'memory'` and signs a user in meets the memory driver's tenant-scope refusal (`503`) until ADR-0131 C8; run that leg on the SQL in-memory driver. diff --git a/.changeset/pre/15196-builtin-positions-declared-metadata.md b/.changeset/pre/15196-builtin-positions-declared-metadata.md deleted file mode 100644 index 9fe59e3fe8c..00000000000 --- a/.changeset/pre/15196-builtin-positions-declared-metadata.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -feat(plugin-security)!: the six built-in positions are declared position metadata of the plugin (ADR-0131 D2) - -Clause-②: yes (narrowing) - - - -**BREAKING** on one write door, shipped as `minor` under the repo's launch-window convention for breaking changes. - -- **What is declared.** The identity positions `platform_admin`, `org_owner`, `org_admin` and `org_member` (ADR-0068 D2) and the audience anchors `everyone` and `guest` (ADR-0090 D5/D9) are registered with the engine registry as `position` metadata owned by `com.objectstack.plugin-security`, the way the plugin's own permission sets are. Their names, labels and descriptions come from one list built from `@objectstack/spec`'s `BUILTIN_IDENTITY_NAMES` and `BUILTIN_IDENTITY_METADATA` and the anchor text, and the built-in seeder reads that same list. -- **Rows and grants do not move.** `sys_position` is seeded exactly as before: the same names, the same organizations (one copy per organization under a walled posture, one organization-less pass under `single`), `managed_by: 'platform'`, `active: true`, `is_default: false`, and the same labels and descriptions. No principal's grants change. -- **What a client can now read.** `GET /api/v1/meta/position` lists the six beside the stack's own positions, and `GET /api/v1/meta/position/:name` answers `200` with each one's definition, where it answered `404 RESOURCE_NOT_FOUND`. The security catalog read (`createSecurityCatalogReader` in `@objectstack/core`) lists them from the engine registry. -- **What a client can no longer write.** `PUT /api/v1/meta/position/:name` naming one of the six is refused `403 NOT_OVERRIDABLE`: the name is provided by a code package, and `position` has no overlay. It was saved as an environment-wide definition before. Give an authored position a different name. `DELETE /api/v1/meta/position/:name` on one of the six still answers `200` and removes nothing. -- **The declared-positions seeder** keeps reading the engine registry first and the metadata service only when the registry holds no position. The six are left out of that decision and out of what it seeds, so stack-declared positions keep seeding and the six keep the built-in seeder as their one writer. diff --git a/.changeset/pre/15196-core-security-catalog-read.md b/.changeset/pre/15196-core-security-catalog-read.md deleted file mode 100644 index c6550bdff83..00000000000 --- a/.changeset/pre/15196-core-security-catalog-read.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/core": minor ---- - -feat(core): one by-name read of the security catalog (`createSecurityCatalogReader`) - -Clause-②: yes (widening) - -- **What is new.** `createSecurityCatalogReader({ registry, metadata })` returns a reader with two members: `resolve(type, name)`, the definition a position, permission set or capability name resolves to (or `undefined`), and `list(type)`, one entry per name. `type` is `'position' | 'permission' | 'capability'`. Each entry is `{ type, name, definition, source, packageId? }`. The types `SecurityCatalogType`, `SecurityCatalogSourceName`, `SecurityCatalogRegistry`, `SecurityCatalogMetadataService`, `SecurityCatalogSources`, `SecurityCatalogEntry` and `SecurityCatalogReader` are exported with it. -- **Where it reads.** ObjectQL's `SchemaRegistry` (`engine.registry`) first, then the kernel `metadata` service for the names the registry does not hold. Neither holds the whole catalog: the engine registry carries the platform's own permission sets and every package manifest's catalog items, stack-declared positions included, and the metadata service carries the security collections an app registers in memory but not the platform's permission sets. Both are required; construction refuses a missing one. -- **A name two packages ship** resolves the way the registry's by-name read does today: a stored override first, else the first-registered package's body. -- **What it does not answer.** Whether an item is in effect: the row `active` flag stays the authority, and no definition carries it. The position → permission-set binding. Organization scope: the catalog is environment-level. -- **Failures are loud.** A reader that throws, or a metadata read that lost a loader and found nothing, raises `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503) instead of answering "no such item". A definition owned by a disabled package answers neither member. -- Nothing calls the reader yet; no grant changes. diff --git a/.changeset/pre/15196-declared-positions-catalog-read.md b/.changeset/pre/15196-declared-positions-catalog-read.md deleted file mode 100644 index f3a9433d179..00000000000 --- a/.changeset/pre/15196-declared-positions-catalog-read.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -A position saved through the metadata door no longer stops an app's declared positions from being seeded - -Clause-②: no - -Every seeding pass copies the app's declared positions into the `sys_position` catalog: the boot's pass and, on a walled deployment, the pass each new organization gets. That seeder read one source or the other: the engine registry alone whenever it held any position besides the six built-ins, otherwise the metadata service. A position a platform administrator saves with `PUT /api/v1/meta/position/:name` lives in the registry. So after one such save, every later pass seeded that position and none of the app's own. Measured on the showcase, walled: an organization created after the save held 7 positions (the six built-ins and the saved one) instead of 17. - -The seeder now reads through the security catalog read (`createSecurityCatalogReader`). It reads both sources on every pass, and for a name both hold, the registry answers first. - -- **New organizations** get the built-ins, every declared position and every door-saved one. Measured on the same walled showcase: 17 positions. -- **Rows are written as before.** The door-saved position's row is unchanged: same name, label and description, `active: true`, `is_default: false`, no provenance stamp. For a name both sources hold, the registry's definition is still the one written. -- **The six built-in positions** are still written only by the built-in seeder, with `managed_by: 'platform'`. Some deployments saved a definition under a built-in name before the six were declared, and that definition still shadows the declaration when the catalog is read. This seeder skips it by name, so it never reaches a row. -- **A catalog read that fails** now writes nothing for that pass, and the boot reports it once as `[security] declared-position seeding failed`. That covers a source that cannot be read and a metadata list that lost a loader. Before, the seeder seeded whatever part it could read, without saying so. -- **No grant changes.** A principal's permissions move only if it holds a position that is newly seeded into its organization. diff --git a/.changeset/pre/15196-grant-permission-set-name-backfill.md b/.changeset/pre/15196-grant-permission-set-name-backfill.md deleted file mode 100644 index 0be497d8c7c..00000000000 --- a/.changeset/pre/15196-grant-permission-set-name-backfill.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -Grants written before `sys_user_permission_set.permission_set` existed now get their permission set's name, once, at boot (ADR-0131 D4) - -Clause-②: no - -- **What happens on the first boot after upgrading.** At `kernel:bootstrapped`, `@objectstack/plugin-security` fills `permission_set` on every grant that has no name yet. The name is the `name` of the `sys_permission_set` row that the grant's `permission_set_id` points at. The set row is read inside the grant's own organization: a grant of an organization may name that organization's set or an organization-less one, and an organization-less grant may name only an organization-less set. Each name is checked in the security catalog before it is written. Only the name column is written: no id changes, no grant is moved and no row is deleted. No principal's grants change, because readers still resolve grants from `permission_set_id`. -- **What is left unnamed, and reported in the boot log by count and grant id.** A grant whose id names no set row (`warn`). A grant whose id names another organization's set row (`warn`); nothing about that organization is logged. A grant whose set row carries a name the security catalog does not hold at that boot (`error`): register the permission set definition, or re-point the grant. -- **It runs once.** When the pass has nothing left that a later boot could decide differently, it records its verdict in `sys_migration` under the id `adr-0131-grant-permission-set-name-backfill`, and later boots skip it. A grant the catalog could not verify, or a write that did not land, leaves the verdict unrecorded, so the next boot tries again. Without a `sys_migration` table (no `PlatformObjectsPlugin` in the composition) the pass still runs on every boot, and renames nothing it already named. -- **Nothing to migrate.** No configuration is needed. diff --git a/.changeset/pre/15196-grant-permission-set-name-column.md b/.changeset/pre/15196-grant-permission-set-name-column.md deleted file mode 100644 index ff442737d74..00000000000 --- a/.changeset/pre/15196-grant-permission-set-name-column.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/plugin-auth": minor -"@objectstack/verify": minor ---- - -`sys_user_permission_set` gains `permission_set`, the name of the permission set a grant holds, written beside `permission_set_id` (ADR-0131 D4) - -Clause-②: yes (widening) - -- **The column.** `permission_set` is a read-only text column, at most 100 characters, holding the `name` of the `sys_permission_set` row that `permission_set_id` points at. It is readable everywhere the grant row is readable. A grant written before this release has `NULL` here until the backfill stage rewrites it. No reader uses the column yet: the grant is still resolved from `permission_set_id`, which stays until it is dropped in a later major (ADR-0131 D10). -- **The platform writes it, on every write that carries `permission_set_id`, for every caller.** Two `@objectstack/plugin-security` engine hooks (`beforeInsert` and `beforeUpdate` on `sys_user_permission_set`) look up the set by id and store its name. A write that sends only the id, which is how the data door and the Setup forms write, gets the name filled in. -- **A name that names a different set is refused** with `400 VALIDATION_FAILED`, `invalid_value` at `permission_set`. This covers a name that disagrees with the id written beside it, or with the id already stored when only the name is written. For a non-system caller it also covers a name beside an id that names no set this caller's organization can see. A name that agrees is accepted. A cleared name (`null`) is not stored as a clear: the derived name is written back. Before this change the column did not exist, so a write naming it was refused with `400 INVALID_FIELD`. No write that was accepted before is refused now. -- **Every platform grant writer writes both columns:** the organization-admin reconcile and the platform-admin promotion in `@objectstack/plugin-security`, the self-registration grant in `@objectstack/plugin-auth`, and the RLS probe persona in `@objectstack/verify`. -- **Nothing to migrate.** No principal's grants change. To fill the column on grants written by your own code, write the set's name as `permission_set`, or leave it out and the platform fills it in. Do not write any other value there. diff --git a/.changeset/pre/15196-grant-readers-by-name-plugin-auth.md b/.changeset/pre/15196-grant-readers-by-name-plugin-auth.md deleted file mode 100644 index 3d9fe555114..00000000000 --- a/.changeset/pre/15196-grant-readers-by-name-plugin-auth.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -The permission-set grant readers in `@objectstack/plugin-auth` read which set a grant holds from its name column, `sys_user_permission_set.permission_set` (ADR-0131 D4) - -Clause-②: no - -- **What reads the name now.** The last-administrator guard (`registerLastAdminGuard`) counts a grant-anchored platform administrator from an unscoped, in-window grant whose `permission_set` is `admin_full_access`, provided the `admin_full_access` row of that name is active. The default-organization bootstrap (`ensureDefaultOrganization`) finds the platform administrator by the same grant name. The self-registration grant checks by name whether the new user already holds the declared set. None of them reads `permission_set_id` for this any more. -- **The guard treats `permission_set` as a standing column.** A write that clears the name on the last administrator's grant is refused like any other revocation. A write that re-points `permission_set_id` without a name is treated as taking the standing away, because the platform derives the new name only after the guard runs. A write that only repeats the grant's own id keeps the standing. -- **A grant whose name is empty.** A grant written before the name column existed has no name until `@objectstack/plugin-security`'s one-time backfill names it at `kernel:bootstrapped`. The guard does not count such a grant as an administrator. When no administrator is counted, such a grant, if unscoped and in-window, is evidence that the environment is not a fresh install, so the write is refused instead of the bootstrap window opening. The default-organization bootstrap answers `no_admin` for it and binds no owner; that answer records no decision, so a later trigger binds once the grant has its name. -- **Nothing to migrate.** diff --git a/.changeset/pre/15196-grant-readers-by-name-plugin-security.md b/.changeset/pre/15196-grant-readers-by-name-plugin-security.md deleted file mode 100644 index bf589cb0edf..00000000000 --- a/.changeset/pre/15196-grant-readers-by-name-plugin-security.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -The permission-set grant readers in `@objectstack/plugin-security` read which set a grant holds from its name column, `sys_user_permission_set.permission_set` (ADR-0131 D4) - -Clause-②: no - -- **What reads the name now.** The explain engine's dropped-grant provenance (`buildContextForUser`: an expired grant, or a grant of a deactivated set), the platform-admin bootstrap's existing-holder check (`bootstrapPlatformAdmin`, and the seed-ownership claim's `findExistingPlatformAdmin`), the organization-admin reconcile (`reconcileOrgAdminGrant`, `backfillOrgAdminGrants`) and the delegated-administration gate's judgement of a stored grant it is asked to change or delete. Each reads the grant's `permission_set` instead of its `permission_set_id`. Deactivation is still read from the `sys_permission_set` row, now found by that name: the grant's own organization's row, else the organization-less one. The authorization resolver in `@objectstack/core` still reads the id, and nobody's resolved permissions change. -- **A grant whose name is empty.** A grant written before the name column existed has no name until the one-time backfill names it at `kernel:bootstrapped`, and the backfill leaves a grant unnamed when its id names no set row or another organization's set row. Such a grant grants nothing through these readers. Explain reports nothing for it. The organization-admin reconcile still finds it through its id when it revokes the grant or checks for a duplicate before inserting one. The delegated-administration gate refuses a delegate's change to it; a tenant administrator is not affected. The platform-admin bootstrap does not promote a second administrator while an unscoped grant on the `admin_full_access` row is still unnamed. It returns `reason: 'admin_grant_unnamed'` without an `adminUserId`, logs a warning, and the next boot reads the grant by its name. -- **Within the organization-admin reconcile,** a pair that already holds the organization-admin set by name, through any organization's copy of it, gets no second grant. -- The earlier release notes for the name column said no reader used it yet. That is no longer true of the readers listed above. -- **Nothing to migrate.** diff --git a/.changeset/pre/15196-position-environment-write-through.md b/.changeset/pre/15196-position-environment-write-through.md deleted file mode 100644 index 6c9a31dac14..00000000000 --- a/.changeset/pre/15196-position-environment-write-through.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -feat(plugin-security)!: under `single`, a position created or edited in Setup is also written to the environment ledger, and positions Setup wrote earlier are backfilled into it once (ADR-0131 D3) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -ADR-0131 D3 gives positions one home, the environment registry. Under `single`, a position an administrator creates in Setup used to be a `sys_position` row only: no environment definition, so the security catalog read did not find it. Under `single`, every Setup create, edit, rename and delete of a position now also writes the position's definition through the metadata door, at environment scope. - -**What a Setup position write does now, under `single`.** - -- **Create.** The row is written as before, with the same checks: a required label, the reserved built-in names, and one name per organization. The definition `{ name, label, description, delegatable }` is then saved from that row as an environment item, and the security catalog read resolves it at once. `active` and `is_default` stay on the row only. -- **Edit.** The row is updated, and the definition is saved again from it. A patch that touches only `active` or `is_default` writes the row and nothing else. -- **Rename.** The new name's definition is saved, and the old name's definition is deleted. -- **Delete.** The row is deleted, then the definition. If the definition delete fails, an error is logged that names the remedy, because the next boot would bring the position back from the surviving definition. -- **Unchanged.** Under a walled posture, every write behaves as before. System writes (the seeders and the package door) are never translated. On a kernel without a metadata door, the row is written as before. For a position a package or a built-in declares, a Setup edit is written to the row exactly as before, and nothing is written to metadata. -- **No grant changes.** Every reader still reads the row, so a user holding a position is granted exactly what they were granted before. - -**What stops being accepted.** Under `single`, the data door now refuses a create, or a rename into, a position name that the metadata door refuses. The refusal is the metadata door's own: `400 INVALID_REQUEST` for a name outside the item-name grammar (uppercase, a space, a hyphen, a leading digit or underscore), or `422 INVALID_METADATA` for a name `PositionSchema.name` refuses (a single character, a dot). No row is kept. `PositionSchema` already declared such names rejected, and a position named that way could never have a definition. - -- An edit of an existing row that already carries such a name is still accepted. It stays a row write, with no definition. -- **Remedy.** Name the position in lowercase `snake_case`: it starts with a letter, is at least two characters, and holds letters, digits and underscores only. For example, write `sales_manager` instead of `Sales Manager`. Then re-point any assignment that names the old spelling. - -**One more refusal follows from the new definitions.** Positions, permission sets and capabilities hold one name per deployment. Once a Setup position is defined in the environment ledger, a package that registers a position under the same name is refused with `422 NAMESPACE_CONFLICT`, and the refusal names both holders. Before this change, the same registration was accepted. - -**The one-time backfill.** At `kernel:bootstrapped`, under `single`, every position whose name the security catalog read does not resolve gets an environment definition from its row. This covers positions Setup wrote before this release. - -- A name the environment ledger, a package or a built-in already declares is left alone. -- A name the metadata door refuses is not written. It is reported at `warn`, with its remedy, as a final class. -- If two rows of one name disagree, the name is reported at `error` and nothing is written for it. -- When every name is decided, the verdict is recorded in `sys_migration` (`adr-0131-position-environment-backfill`). If a write fails or two rows disagree, the verdict is not recorded, and the next boot runs the pass again. diff --git a/.changeset/pre/15196-retired-catalog-residue.md b/.changeset/pre/15196-retired-catalog-residue.md deleted file mode 100644 index f5ea12f6905..00000000000 --- a/.changeset/pre/15196-retired-catalog-residue.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security): the `sys_user_position.position` refusal names the security catalog type it judged against, and reads no retired catalog table - -A `sys_user_position` write naming no position in the security catalog is still refused `400 VALIDATION_FAILED` with one `reference_not_found` entry at `position`. That entry's `constraint.target` now reads `position`, the security catalog type the value is judged against, instead of `sys_position`, a catalog table that is no longer registered. The sibling `sys_user_permission_set.permission_set` refusal already answers `target: 'permission'`. A client that read `constraint.target` should expect `position`. - -The refusal no longer looks a refused value up as a `sys_position` record id to suggest that row's name. That lookup could not succeed on a current deployment, so it never suggested a name. Every refused value now gets the same remedy: write the machine name of an existing position, or declare the position first. - -Also removed: the unused internal `per-organization-catalog.ts` module, which nothing imported. Nothing else changes. - -Clause-②: no diff --git a/.changeset/pre/15196-s10-position-namespace.md b/.changeset/pre/15196-s10-position-namespace.md deleted file mode 100644 index ae8656ef085..00000000000 --- a/.changeset/pre/15196-s10-position-namespace.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -feat(plugin-security)!: under `single`, a Setup create of a position, or a rename into a name, that a package or a built-in already holds is refused with the metadata door's own refusal (ADR-0048 addendum N.2/N.3) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -Positions hold one name per deployment (ADR-0048 addendum, N.2). A position a package declares, and the platform's built-in positions, are managed items: the metadata door already refused an environment save under their names (`PUT /api/v1/meta/position/:name` answers `403 NOT_OVERRIDABLE`). The Setup data door did not. - -**FROM.** Under `single`, a non-system `POST /api/v1/data/sys_position` whose `name` a package or a built-in holds answered `201`. The new row sat beside the package's (or the platform's) position under the same name. A `PATCH` that renamed an administrator's position into such a name answered `200`, and it also deleted that position's own environment definition. Measured on the showcase: a create of `manager` (package-declared), `everyone` or `guest` (built-in audience anchors) answered `201`, and a rename into `exec` answered `200`. - -**TO.** Under `single`, the same create, or a rename into such a name, is refused with the metadata door's own refusal, relayed as the door builds it: `403 NOT_OVERRIDABLE`. No row is kept, a renamed row keeps its old name, and its definition is untouched. The door's own verdict is asked; nothing is written to environment metadata for such a name. - -- **Unchanged.** The engine's own refusals answer first, as before: a reserved built-in identity name (`platform_admin`, `org_owner`, `org_admin`, `org_member`) stays `400 VALIDATION_FAILED`, and a name already held in the same organization stays `409 UNIQUE_VIOLATION`. An edit that keeps such a name, and a delete, behave as before. System writes, walled postures and kernels without a metadata door behave as before. Under a walled posture, each organization already holds its own rows of the declared and built-in names, so the same create answers `409 UNIQUE_VIOLATION`, as before. -- **Remedy.** Rename the position: give it a name no package and no built-in holds (for example `regional_manager` instead of `manager`), then point any assignment at the new name. `GET /api/v1/meta/position` lists the names already held. diff --git a/.changeset/pre/15196-s5a-resolver-grant-name.md b/.changeset/pre/15196-s5a-resolver-grant-name.md deleted file mode 100644 index 490cb2e9006..00000000000 --- a/.changeset/pre/15196-s5a-resolver-grant-name.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/plugin-security': minor -'@objectstack/plugin-auth': minor ---- - -feat(core)!: the authorization resolver reads which permission set a user grant holds from the grant's name, `sys_user_permission_set.permission_set` (ADR-0131 D4) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -**What the resolver reads now.** `resolveUserAuthzGrants` (and every surface built on it: `resolveAuthzContext`, `hasPlatformAdminStanding`, the explain engine, `runAs: 'user'` automation) finds a user grant's permission set by the grant's `permission_set` name. The set row is the grant's own organization's row of that name, or else the organization-less row of that name. `permission_set_id` is no longer read for this. Deactivation is still read from the set row. Sets a principal holds through a position are still reached through the position binding's id. - -- **Platform standing.** An unscoped grant is read against the organization-less `admin_full_access` row only. An organization's copy of `admin_full_access` gives that organization's grants its capabilities, never platform standing. -- **Nobody's resolved permissions change** where a grant's name and id agree. The platform's grant writers store both, and the one-time backfill names grants stored before the name column existed. Goldens for the platform administrator, an organization administrator, a member and an agent are unchanged in `single`, `group` and `isolated`. - -**What stops conferring.** - -- **A grant that names nothing.** This is a grant the backfill has not named yet, or one it could not name: its id names no set row, its id names another organization's set row, or the name is not in the security catalog. Such a grant now confers nothing through the resolver. The backfill runs at `kernel:bootstrapped`, before any server opens its socket, so on the first boot after upgrading no request is answered before the grants it can name are named. The grants it cannot name are listed in its boot report. -- **A grant whose id names another organization's set.** Before this change, such a grant conferred that set. It now confers nothing. -- **Remedy.** Read the backfill's boot report, which lists each grant by row id. Re-point each listed grant at a permission set of its own organization, or at an organization-less one, with an update of `permission_set_id`. The platform stamps the name, and the grant confers again. - -**What the grant name hook refuses now** (`@objectstack/plugin-security`). The name is taken only from a set row of the grant's own organization, or from an organization-less one. A tenant-less system writer can read every organization's sets, and it could name an organization-less grant after another organization's set. Now such a grant is stored without a name. A write that supplies that name is refused, from a system writer too, with the hook's usual `400 VALIDATION_FAILED` and `invalid_value` at `permission_set`. An update that re-points a grant at such a set, or moves a grant to an organization its set does not belong to, clears the name. - -**What the last-administrator guard judges now** (`@objectstack/plugin-auth`). `organization_id` on `sys_permission_set` is a standing column. Moving the organization-less `admin_full_access` row into an organization takes away every grant-anchored platform administrator, as deleting it does, so the guard refuses it when it would leave none. An organization's copy of `admin_full_access` no longer counts as the set being in effect. - -**Nothing to migrate** in code or metadata. diff --git a/.changeset/pre/15204-7a-permission-set-projection-retired.md b/.changeset/pre/15204-7a-permission-set-projection-retired.md deleted file mode 100644 index f924809020e..00000000000 --- a/.changeset/pre/15204-7a-permission-set-projection-retired.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/plugin-security': minor -'@objectstack/spec': patch ---- - -feat(plugin-security)!: the permission-set projection, its seed-name lookup and refusal sink are deleted (ADR-0131 D3, C3 stage 7a) - -Clause-②: no (narrowing) - - - -**BREAKING** (retired exports of `@objectstack/plugin-security`), graded `minor` on the v18 prerelease line. - -Stage 8 stopped registering the `sys_permission_set` row object, the `permission` mutation projector and the data-door write-through. This change deletes the modules behind them: `permission-set-projection`, `seed-name-lookup` and `seed-refusal-sink`. A permission set is written through the metadata door alone (`PUT /api/v1/meta/permission/:name`), and the security catalog serves it by name. - -What a consumer sees: - -- FROM `createPermissionSetWriteThrough(deps)` TO nothing: no `sys_permission_set` data-door write exists to redirect. Write the set through the metadata door. Delete the call. -- FROM `registerPermissionSetProjection(protocol, deps)` TO nothing: no row mirrors a definition. Delete the call. -- FROM `reconcilePermissionSetProjection(...)` TO nothing: there is no projection to reconcile at boot. Delete the call. -- FROM `projectPermissionMutation(...)` TO nothing. Delete the call. -- FROM `upsertEnvPermissionSet(...)` TO `PUT /api/v1/meta/permission/:name`, or the metadata protocol's `saveMetaItem({ type: 'permission', name, item })`. -- FROM `permissionSetBodyFromRow(row)` TO `permissionSetDefinitionFromRow(row)` from `@objectstack/core`: the same mapping, for converting a stored `sys_permission_set` row into its definition. -- FROM `permissionSetRowFields(definition)` TO nothing: no row is written. Delete the call. -- FROM the types `PermissionSeedOutcome`, `ProjectionDeps`, `ProjectionReconcileOutcome` and `WriteThroughDeps` TO nothing. Delete the import. - -Unchanged: the security catalog's echo of a metadata-door permission set or position on an environment kernel, which lives in its own module since #23308. The packaged-permission-set lock still refuses a metadata-door save of a set a code package ships (`403 NOT_OVERRIDABLE`), and the boot still reports environment overlays that shadow such a set. `OverlayDetectionOptions.logger` now asks only for `warn`, the one channel the reading reports on. - -**`@objectstack/spec`** (patch): the `permission.tabPermissions` liveness entry no longer cites the deleted projection, and the liveness README's registered-but-unbound `permission-set-projection` proof row is retired with the invariant it described (no `@proof` tag carries it any more). diff --git a/.changeset/pre/15204-7b-position-write-through-retired.md b/.changeset/pre/15204-7b-position-write-through-retired.md deleted file mode 100644 index 381e6d90ef2..00000000000 --- a/.changeset/pre/15204-7b-position-write-through-retired.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security): the position write-through and the row-only position backfill are deleted, and the boot reference report stops reading position rows (ADR-0131 D3, C3 stage 7b) - -Clause-②: no - -Stage 8 stopped registering the `sys_position` row object, the `sys_position` data-door write-through and the row-only position backfill. This change deletes the modules behind them, `position-write-through` and `position-environment-backfill`. A position is written through the metadata door alone (`PUT /api/v1/meta/position/:name`). No export of the package's entry changes. - -What a deployment observes: - -- **The boot reference report reads no `sys_position` row.** It still lists, per organization, every position assignment and permission-set grant whose name the security catalog does not resolve, and every grant that names no permission set. It no longer has the two position-row lines ("position(s) … have sys_position rows and no definition" and "position name(s) are carried by more than one sys_position row"). No composition provisions that table any more, so those lines could not fire. Legacy position rows are converted and reported by the upgrade ceremony (ADR-0131 D10). -- The Setup-create refusal for a position name the catalog already holds (`409 UNIQUE_VIOLATION`, raised by the deleted write-through) goes with its module. A `sys_position` data-door create cannot reach the engine any more, so nothing raised it. diff --git a/.changeset/pre/15204-8post-catalog-gates-retired.md b/.changeset/pre/15204-8post-catalog-gates-retired.md deleted file mode 100644 index 451c8435616..00000000000 --- a/.changeset/pre/15204-8post-catalog-gates-retired.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/plugin-security': patch -'@objectstack/platform-objects': patch -'@objectstack/spec': patch -'@objectstack/core': patch -'@objectstack/metadata-protocol': patch ---- - -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`. diff --git a/.changeset/pre/15204-catalog-activation-environment-authored.md b/.changeset/pre/15204-catalog-activation-environment-authored.md deleted file mode 100644 index 461d24c293f..00000000000 --- a/.changeset/pre/15204-catalog-activation-environment-authored.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/core": minor -"@objectstack/platform-objects": minor -"@objectstack/service-automation": minor ---- - -The security catalog's activation door switches a position or permission set the environment authored, and a ledger write it refuses answers with the status its code means - -Clause-②: yes (widening) - -- **The door accepts environment-authored items** (`@objectstack/runtime`). `POST /api/v1/security/_activation/:type/:name` switched a position or permission set off and on only when a package shipped it. FROM: an item saved through the metadata door (`PUT /api/v1/meta/position/:name`, `PUT /api/v1/meta/permission/:name`) was refused `503` with `{ code: 'VALIDATION_FAILED', message: 'Package is required' }`, and nothing was written. TO: it is switched like a packaged one, `200`, and the resolver honours the row: a switched-off position stops granting its permission sets, and a switched-off set stops granting by every path. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared name `404`, a type with no switch or an audience anchor `400`, the last administrator `403`). -- **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, a hook's refusal keeps its own pair (the last-admin guard's `403 PERMISSION_DENIED`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. -- **The ledger row of an item no package ships carries no package** (`@objectstack/platform-objects`, `@objectstack/core`). `sys_metadata_activation.package_id` is no longer required. It is empty exactly when no package ships the item. It is not part of the row identity (`metadata_type`, `name`), and no reader decides anything from it. The column was already nullable on disk (ADR-0113: `required` is a write-time check), so there is nothing to migrate. -- **One spelling for "no package"** (`@objectstack/core`). `MetadataActivationRow.packageId` is optional. FROM: `packageId: string`, with `''` the only way to say "no package". TO: omit `packageId` for an item no package ships, and the store writes no value. `ObjectStoreMetadataActivationStore.setActive` refuses `packageId: ''` with a `TypeError` before it reads or writes anything, and `list()` returns `packageId` only when the row names a package. If your code passes `''`, omit the key instead. -- **The flow row follows the shared row** (`@objectstack/service-automation`). `FlowActivationRow.packageId` is optional to match. Every flow row still names its package, because the flow toggle refuses a flow no package ships before it writes. -- **The action activation door's answer for an action no package ships changes; the action is still refused.** `POST /api/v1/actions/_activation/:object/:action` still passes `''` as the package of such an action. The write is still refused and still writes nothing, but the store's `TypeError` refuses it now, with the store's sentence. FROM: `503 VALIDATION_FAILED`. TO: `503 SERVICE_UNAVAILABLE`. An environment-authored action could not be switched on `main` before this change and cannot after it. diff --git a/.changeset/pre/15204-grant-by-name-security.md b/.changeset/pre/15204-grant-by-name-security.md deleted file mode 100644 index 2d1a8468cfc..00000000000 --- a/.changeset/pre/15204-grant-by-name-security.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -A permission-set grant names its set by name: `sys_user_permission_set.permission_set` is the grant's one reference, and `permission_set_id` is no longer written or required - -Clause-②: yes (narrowing) - - - -**BREAKING** accept-set narrowing on `sys_user_permission_set`, shipped as `minor` under the repo's launch-window convention for breaking changes. ADR-0131 D10's first step: every grant names its set, then the id column goes. - -- **Before.** A grant was written with `permission_set_id`, a required lookup to a `sys_permission_set` row, and the platform derived `permission_set` (read-only) from that row. A write carrying only the id was accepted and named; a supplied name had to agree with the id's row. -- **Now.** `permission_set` is required and writable: it holds the permission set's machine name, and the set is the security catalog's definition of that name (ADR-0131 D3). `permission_set_id` is optional, and nothing writes it or derives anything from it. A grant that names no set is refused `400 VALIDATION_FAILED` (`required` at `permission_set`). A non-system write naming a set the security catalog does not hold is refused `400 VALIDATION_FAILED` (`reference_not_found` at `permission_set`), the same envelope `sys_user_position.position` answers with; a deactivated set is still a set and is accepted. System writes are not judged against the catalog. -- **The platform's own grant writers** (the platform-admin promotion and the organization-admin reconcile) write the name alone and read no `sys_permission_set` row to do it, so a database with no such rows still gets its first platform administrator and its organization administrators. A row id is read only to revoke, or not duplicate, a grant written before the name column existed, which names nothing until the one-time backfill names it. Uninstalling a package revokes the grants that name its sets. -- **Unchanged.** Grants written before this release keep their id and are named by the existing backfill; the resolver and every grant reader already read the name. - -**The remedy.** A caller that wrote `{ user_id, permission_set_id: }` writes `{ user_id, permission_set: '' }`: the `name` of that set, as the metadata door (`GET /api/v1/meta/permission`) lists it. diff --git a/.changeset/pre/15204-grant-by-name-spec-tsdoc.md b/.changeset/pre/15204-grant-by-name-spec-tsdoc.md deleted file mode 100644 index 9d287579476..00000000000 --- a/.changeset/pre/15204-grant-by-name-spec-tsdoc.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The TSDoc on `RecordRelatedListProps.add` names its canonical example by name: "Assigned Users" on a permission set keys `sys_user_permission_set` on `permission_set` with `relationshipValueField: 'name'`, since a grant names its permission set (ADR-0131 D4). Text only; no schema changes. diff --git a/.changeset/pre/15204-grant-by-name-writers.md b/.changeset/pre/15204-grant-by-name-writers.md deleted file mode 100644 index a8f6844faab..00000000000 --- a/.changeset/pre/15204-grant-by-name-writers.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/plugin-auth': patch -'@objectstack/verify': patch -'@objectstack/platform-objects': patch ---- - -Grants name their permission set, with no `sys_permission_set` row read (ADR-0131 D4) - -- `@objectstack/plugin-auth`: the self-registration grant names the declared set alone. Admission asks the security catalog and the activation ledger whether the set grants, and the settlement writes `{ user_id, permission_set }` with no row read, so a deployment with no `sys_permission_set` row for the set still grants it. The last-administrator guard reads which set a grant holds from its name only; writing `permission_set_id` moves no standing. -- `@objectstack/verify`: the RLS probe persona's grant names the registered probe set alone; it no longer needs, or refuses for want of, a boot-seeded `sys_permission_set` row. -- `@objectstack/platform-objects`: Setup's user page grants a permission set by name: the Add picker stores the set's machine name (`valueField: 'name'`, `linkField: 'permission_set'`), and the list shows the name column. The picker still lists `sys_permission_set` rows; listing the security catalog itself is a console change. diff --git a/.changeset/pre/15204-grant-name-pickers.md b/.changeset/pre/15204-grant-name-pickers.md deleted file mode 100644 index 85567ff1aa7..00000000000 --- a/.changeset/pre/15204-grant-name-pickers.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/platform-objects': patch -'@objectstack/plugin-security': patch ---- - -Setup's user page grants a position or a permission set by picking its name from the security catalog (ADR-0131 D4) - -Clause-②: no - -- `@objectstack/plugin-security`: `sys_user_position.position` declares `widget: 'position-ref'` and `sys_user_permission_set.permission_set` declares `widget: 'permission-ref'`, so the grant form offers a picker over the positions and permission sets the catalog serves, and stores the chosen name. A console without those widgets keeps the text input. `sys_user_permission_set.permission_set_id`, the legacy id that grants nothing, is now `hidden` and no longer appears on the grant form; the column stays until a later release drops its data. -- `@objectstack/platform-objects`: the user page's Positions and Permission Sets lists no longer carry the "Assign position" / "Grant permission set" buttons. Those buttons listed `sys_position` / `sys_permission_set` rows, which a deployment need not hold. Grant with the list's **New**, which opens the grant form; removing a grant is unchanged. diff --git a/.changeset/pre/15204-permission-set-clone-door.md b/.changeset/pre/15204-permission-set-clone-door.md deleted file mode 100644 index c5d8a7db232..00000000000 --- a/.changeset/pre/15204-permission-set-clone-door.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -feat(runtime): `POST /api/v1/security/permission-sets/:name/clone` — clone a permission set by name from the security catalog (ADR-0126 §7.1) - -Clause-②: yes (widening) - -- **What is new.** A route that copies a permission set's whole definition under a new machine name. The body is `{ name, label }`: both are required, no other key is accepted, and `name` must be snake_case. The answer is `{ permissionSet, notice }`: the copy that was saved, and a sentence naming what was not copied. -- **Where it reads and writes.** The source is read through the security catalog (the engine registry first, then the metadata service), the same read the authorization resolver uses. It never reads a `sys_permission_set` row, so it works on a database with none. The copy is saved through the metadata protocol's `saveMetaItem` as a `permission` item authored in this environment, with no organization and no package. The save is create-only, so a row written under the name meanwhile is refused, not overwritten. -- **What is copied, and what is not.** Every facet of the definition is copied. One server-declared list (`PERMISSION_SET_CLONE_DROPPED_KEYS`) is removed: the protection envelope and read decorations (derived from the spec), `packageId`, `managedBy`, `protection`, and the two privilege decisions `adminScope` and `isDefault`. The copy records nothing about its source. -- **Who may call it.** A holder of `manage_metadata`, through the same verdict the metadata save door asks (`metaWriteCapabilityVerdict`); `isSystem` bypasses. Anyone else gets `403 PERMISSION_DENIED`, and an anonymous caller `401`. -- **Refusals.** `400 VALIDATION_FAILED` for a malformed body or a name failing the name rule; `404` for an unknown source; `409 RESOURCE_CONFLICT` for a name the catalog already holds, including the source's own; `503 SERVICE_UNAVAILABLE` when no catalog is bound or it cannot be read; `501` when no metadata protocol is composed. -- **Nothing to migrate.** No existing route changes. The Setup action `clone_permission_set` keeps working as before. diff --git a/.changeset/pre/15204-s1-position-permission-sets.md b/.changeset/pre/15204-s1-position-permission-sets.md deleted file mode 100644 index 1dd556f3fd6..00000000000 --- a/.changeset/pre/15204-s1-position-permission-sets.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/core': minor -'@objectstack/plugin-security': minor -'@objectstack/plugin-auth': minor -'@objectstack/platform-objects': patch ---- - -feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4) - -Clause-②: yes - - - -**BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes. - -**FROM → TO.** A position's permission sets were `sys_position_permission_set` rows. They are now declared in `permissionSets` on the position's definition; junction rows no longer grant. - -```ts -definePosition({ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }); -``` - -The one-line fix for an app that bound sets to its positions with a boot-time binder: name the sets in `permissionSets` on each position. - -**`@objectstack/spec`.** `PositionSchema` accepts `permissionSets`: a list of permission-set names (lowercase snake_case). Before, the key was refused with a pointer to the junction. Each name is resolved in the environment catalog when a holder's grants are resolved. A name the catalog does not hold confers nothing. - -**`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`): - -- A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. -- A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing — including a row inserted directly (a script or a harness, outside the product's doors). -- **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. -- The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition. -- `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. -- Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. -- An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. -- `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot. - -**`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it). - -**`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A ledger write that switches `admin_full_access` off is refused only when it would leave no administrator who can sign in, and switching it back on is never refused. The guard no longer judges `sys_permission_set` writes, because the resolver no longer reads that row. - -**`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES. - -**`@objectstack/plugin-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row. - -**What stops granting at upgrade, and what to do.** - -- **A deactivation made in Setup.** The Deactivate action on a position or a permission set writes the row's `active` column, which no longer switches anything off. ⚠️ Until the upgrade ceremony converts the rows (`convertDeactivatedCatalogRows`), a position or set deactivated that way **grants again**. No enable/disable door accepts the `permission` or `position` type yet, so a new deactivation has no in-product path. To revoke now, remove the assignments or the binding. - -- **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code. -- **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today. -- **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate organization-ownership --apply`) under `single` only, where its `authored-conversion` step applies `convertPositionBindingRows`. Under a wall (`group`, `isolated`) the ceremony converts no binding (ADR-0131 D3): its `rewrite` step refuses each binding whose position's definition does not name the bound sets, and the ceremony stops there until the operator acts and re-runs it. A position no catalog declares is declared with those sets in `permissionSets` through one door only: the deployment's code, or the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator). A declared position the metadata door can edit takes the missing sets through that door. A definition a package ships is sealed (ADR-0131 D6), and no door on the database adds sets to it. A name that organizations bound differently is reported `conflicting` and is never merged. diff --git a/.changeset/pre/15204-s2a-security-readers.md b/.changeset/pre/15204-s2a-security-readers.md deleted file mode 100644 index e0d821bfa2f..00000000000 --- a/.changeset/pre/15204-s2a-security-readers.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/plugin-security': minor ---- - -feat(plugin-security)!: the security plugin's own readers (set resolution, the delegated-administration gate, the permission explainer and the position-assignment refusal) read the security catalog and the activation ledger, not the catalog rows (ADR-0131 D3/D4) - -Clause-②: yes - - - -**BREAKING** (enforcement stops reading `sys_permission_set` rows), shipped as `minor` under the launch-window convention for breaking changes. This is the second step of the cutover that `position-permission-sets-declared` registers: the authorization resolver already reads the catalog; now the security plugin's readers read the same catalog. - -**Permission-set resolution (`@objectstack/plugin-security`).** - -- FROM: a set name the metadata service and the bootstrap sets did not answer was loaded from a `sys_permission_set` row (the caller's organization's, else an organization-less one), its object map and field map included, and dropped when the row's `active` was false. -- TO: it is loaded from the security catalog (the environment registry the security plugin binds to its engine): every package's sets and every set an author saved through the metadata door. A set that only a row carries resolves nothing. The resolver already granted nothing through such a set; now its object and field permissions stop applying too. -- Fix: declare the set through the metadata door (`PUT /api/v1/meta/permission/NAME`) or in a package. -- A permission set that resolves only because a POSITION of the same name was folded into the request is dropped when the activation ledger (`sys_metadata_activation`, type `permission`) switched it off. The row's `active` column is not read. - -**Delegated administration (ADR-0090 D12).** - -- FROM: the sets a position distributes were its `sys_position_permission_set` rows in the caller's organization; `delegatable` was the organization's `sys_position` row's; the assignable-positions listing (`describeDelegableScope`) listed `sys_position` rows; a direct grant's set (and its `adminScope`) was its `sys_permission_set` row. -- TO: all four read the security catalog by name. A position distributes the sets its definition's `permissionSets` names, so the containment check and the listing see a binding declared only on the definition. `delegatable` is the definition's. The listing offers every position the catalog holds. A direct grant's set is the catalog's definition of the name the grant carries; a grant naming a set the catalog does not hold is refused for a delegate (a tenant administrator is not judged). -- A name a position's `permissionSets` carries that the catalog does not hold must be on the delegate's allowlist too. It grants nothing today, but it starts granting as soon as a set of that name is authored. -- A catalog read that fails refuses the delegate's write. Before, it approved it as "distributes nothing". - -**The permission explainer.** "deactivated" is read from the activation ledger through the resolver's own read, so explain and enforce agree. A direct grant whose set the catalog does not hold is reported neither as expired nor as deactivated: there is no set to lose. - -**Position assignments (`sys_user_position.position`).** - -- FROM: refused `400 VALIDATION_FAILED` unless a `sys_position` row (the writer's organization's, or an organization-less one) carried the name. A position declared only in a package or saved only through the metadata door was refused. -- TO: refused unless the security catalog holds a position of that name. A registry-declared position is accepted. A name only a `sys_position` row carries (with no definition) is refused, because it grants nothing. A deactivated position is still accepted. - -**One position name per deployment under `single`.** - -- FROM: a Setup create of a position, or a rename into a name, that an environment definition already held overwrote that definition (another organization's label and description, an empty `permissionSets`). -- TO: refused `409 UNIQUE_VIOLATION`, the answer a second row of the name in one organization already gets, and the row write is undone. This covers a definition another organization's row wrote, one saved through the metadata door, and one an application stack declares. A name a package or a built-in holds keeps its `403 NOT_OVERRIDABLE`. - -**`@objectstack/core`.** `readDisabledCatalogNames(ql, positions, sets)` is exported: the resolver's one read of the activation ledger. It returns the names switched off for this deployment and is now shared by the explainer and the security plugin. diff --git a/.changeset/pre/15204-s2b-plugin-auth-catalog-readers.md b/.changeset/pre/15204-s2b-plugin-auth-catalog-readers.md deleted file mode 100644 index 7a1ae3d843a..00000000000 --- a/.changeset/pre/15204-s2b-plugin-auth-catalog-readers.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -feat(plugin-auth)!: the last-administrator guard, the self-registration admission and the default-organization step read the security catalog and the activation ledger, never a `sys_permission_set` row (ADR-0131 D3/D4) - -Clause-②: yes (narrowing: a zero-administrator write is refused when an unscoped grant names a set the security catalog does not hold, and self-registration is refused when its declared set is not in the catalog or is switched off in the activation ledger; widening: writes to a `sys_permission_set` row are no longer judged by the guard, and a ledger write switching `admin_full_access` off is refused only when it would leave no administrator) - - - -**BREAKING**, shipped as `minor` under the launch-window convention for breaking changes. It finishes, for `plugin-auth`, the move the authorization resolver made in the same release: a permission set exists when the security catalog bound to the engine holds its definition, and it is switched off by a `sys_metadata_activation` row of type `permission` whose `active` is false. No `sys_permission_set` row decides either any more, its `active` column included. - -**The last-administrator guard.** - -- A grant-anchored platform administrator is counted the way the resolver derives one: an unscoped, in-window grant naming `admin_full_access`, which the catalog holds and the ledger leaves on. An engine with no catalog bound counts none, as the resolver grants none. -- Switching `admin_full_access` off in the ledger (an insert, an update of its row, or an update moving another row onto that pair) is judged like every other standing write. It is refused only when it would leave no administrator who can sign in. A config-anchored or organization administrator still standing permits it. The flat refusal of every such write is gone, and with it `refuseLedgerSwitchingAdminOff`. -- Writes to a `sys_permission_set` row (delete, rename, `active`, `organization_id`) are no longer judged. The resolver reads no row, so none of them moves an administrator. `PERMISSION_SET_STANDING_KEYS` is removed, and with it the guard's two `sys_permission_set` hooks. -- With zero administrators, the guard still tells the bootstrap window from an emptied environment. The evidence is now an unscoped, in-window grant naming a set the catalog does not hold (or naming none), or `admin_full_access` switched off in the ledger while such grants name it. Its remedy is to switch the set back on, and that write is permitted. - -Two exports of the guard are removed, with nothing in their place: `PERMISSION_SET_STANDING_KEYS` (the table it judged is no longer read) and `refuseLedgerSwitchingAdminOff` (`registerLastAdminGuard` judges ledger writes itself). No package in this repository imports either outside `plugin-auth`. - -**Self-registration.** The admission asks whether the declared `selfRegistrationPermissionSet` would grant: the catalog holds it and the ledger leaves it on. A `sys_permission_set` row of that name must also exist, because the grant's `permission_set_id` is written with its id. The row's `active` is no longer read, at admission or when the grant is written. - -**The default organization.** The platform administrator the default-organization step binds as owner is found through a set the catalog holds and the ledger leaves on, not through a `sys_permission_set` row. diff --git a/.changeset/pre/15204-s2b-plugin-sharing-ledger-deactivation.md b/.changeset/pre/15204-s2b-plugin-sharing-ledger-deactivation.md deleted file mode 100644 index 8d2d1ada8c3..00000000000 --- a/.changeset/pre/15204-s2b-plugin-sharing-ledger-deactivation.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/plugin-sharing': minor ---- - -feat(plugin-sharing)!: a sharing rule reads a position's deactivation from the activation ledger, as the authorization resolver does (ADR-0131 D3, ADR-0126 §4) - -Clause-②: yes (narrowing: a position switched off in the activation ledger stops receiving sharing-rule shares in every organization; widening: a position whose `sys_position` row reads `active: false` receives them again) - - - -**BREAKING**, shipped as `minor` under the launch-window convention for breaking changes. A position's deactivation now comes from `sys_metadata_activation` instead of the `sys_position` row's `active` column, so the shares a sharing rule materializes follow the same switch the authorization resolver reads. - -- A sharing rule whose recipient is a position materializes no share while that position is switched off. Switched off means a `sys_metadata_activation` row of type `position` naming it, with `active` false (a driver `0` included). The switch is deployment-wide, like the ledger, so it no longer depends on the rule's organization. -- The `sys_position` row's `active` is no longer read. A deactivation made in Setup writes that column, so until the upgrade ceremony converts it into a ledger row it stops neither the resolver nor sharing rules. -- A name with no ledger row keeps sharing, including a membership-derived position name. A composition that registers no ledger object issues no ledger read. A ledger read that fails still grants for the pass, as the row read did. diff --git a/.changeset/pre/15204-s2c-catalog-activation-door.md b/.changeset/pre/15204-s2c-catalog-activation-door.md deleted file mode 100644 index 44ec6e90638..00000000000 --- a/.changeset/pre/15204-s2c-catalog-activation-door.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -feat(runtime): `POST /api/v1/security/_activation/:type/:name` switches a position or a permission set on or off for the deployment (ADR-0126 §3 regime C, as ADR-0131 D6 amends it) - -Clause-②: yes - -The authorization resolver reads whether a position or a permission set is switched off from the activation ledger, `sys_metadata_activation` (types `position` and `permission`), and never from the catalog row's `active` column (ADR-0131 D3). Until now nothing wrote that ledger for these two types, so a deactivation switched nothing off. This route is the write half: - -- **Route:** `POST /api/v1/security/_activation/:type/:name`, with `:type` either `position` or `permission`. It is also mounted under `/api/v1/environments/:environmentId` when project scoping is on, like the action door. -- **Body:** `{ enabled?: boolean }`. `enabled` defaults to `true`. Unknown keys and a non-boolean `enabled` are refused `400 VALIDATION_FAILED`, the same body reader `POST /actions/_activation/:object/:action` uses. -- **Effect:** one `sys_metadata_activation` row through the engine, carrying the definition's package. Re-enabling updates that row. Switching a position off stops every grant through it for every holder in the deployment. Switching a permission set off stops it granting by every path. No definition and no catalog row is written. -- **Authority:** the same as the flow and action activation doors. The caller needs `manage_metadata`. Under a `group` or `isolated` tenancy posture the caller must also be the platform operator (ADR-0126 §5). Refusals are `403 PERMISSION_DENIED`, and nothing is written. -- **Refusals:** the audience anchors `position/everyone` and `position/guest` are refused `400` in either direction, before anything is read or written, because one row would switch the authenticated or anonymous baseline off for every principal. A name that does not resolve in the security catalog is `404`. No catalog bound, or a catalog that cannot be read, is `503 SERVICE_UNAVAILABLE`. A composition without the ledger object is `501`. Switching `admin_full_access` off is refused `403 PERMISSION_DENIED` by the last-admin guard's ledger hook. - -The route is not in the JS SDK. Its caller is the Setup console's Deactivate on the position and permission-set pages, which calls the platform API directly. diff --git a/.changeset/pre/15204-s3-boot-report.md b/.changeset/pre/15204-s3-boot-report.md deleted file mode 100644 index 6ae2eaad6be..00000000000 --- a/.changeset/pre/15204-s3-boot-report.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -A boot report lists, per organization, every security catalog reference whose name resolves nowhere (ADR-0131 D3/D4) - -Clause-②: no - -ADR-0131 D4 makes every reference to a position or a permission set a name, resolved through the security catalog (the environment registry). A stored reference whose name the catalog does not hold confers nothing, while Setup still lists it. At every boot, at `kernel:bootstrapped` and after the two one-time backfills, `@objectstack/plugin-security` now reads the stored references and logs what the catalog does not resolve. The report writes nothing: no row, no definition, no ledger entry. - -- **Where to read it.** In the boot log, as `[security]` lines at `error`, one line per kind of finding. Each line's metadata lists the organizations (`organizationId`, `null` for organization-less rows) with a count and the names or row ids, at most 50 per list. A clean boot logs one `info` line that says every reference resolves. -- **What it lists, per organization:** - - `sys_user_position` assignments whose `position` the catalog does not resolve; - - `sys_user_permission_set` grants whose `permission_set` the catalog does not resolve; - - `sys_user_permission_set` grants with no `permission_set` at all. The one-time name backfill could not name them: the set row is missing, belongs to another organization, or carries a name the catalog does not resolve (a row-only set). An unnamed grant confers nothing; - - `sys_position` rows whose name the catalog does not resolve, by name and organization. Under a walled posture these are the positions an organization authored in Setup: the catalog is environment-level, so they have no catalog home, and their assignments distribute no permission set. Under `single` these are the row-only positions the row-only backfill could not define, with `refusedNames` naming the ones whose names the metadata door refuses. The backfill reports that class once and records its verdict; this report lists it at every boot. -- **And under `single` only:** position names carried by more than one `sys_position` row (two organizations' rows, or an organization's row beside a package's or a built-in's seeded row). A position name is unique per deployment under `single`, so each is reported as conflicting, never guessed and never merged. -- **What clears a line.** The next boot re-reads everything. A reference whose definition is declared again (its package or its environment definition) leaves the report. So does a walled organization's position once a definition of that name is saved through the metadata door. Otherwise remove the row, or re-point it at a name the catalog holds. -- **A read that did not happen is not a finding.** If a scan or the catalog read fails, the report says which store it could not read, at `warn`, and reports nothing for that boot. -- **The grant-name backfill's warnings now say what such a grant is worth.** A grant the backfill could not name, because its set row's name is not in the catalog or its name write did not land, confers nothing until a later boot names it. The warning used to say these grants still resolved by id. That stopped being true when the authorization resolver began reading grants by name. -- **Nothing to migrate.** No configuration is needed. diff --git a/.changeset/pre/15204-s6a-position-seeders.md b/.changeset/pre/15204-s6a-position-seeders.md deleted file mode 100644 index 70571ee405f..00000000000 --- a/.changeset/pre/15204-s6a-position-seeders.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -feat(plugin-security): the boot no longer writes `sys_position` rows — the built-in and declared position seeders and the `everyone` baseline junction writer are deleted - -Clause-②: no - -`SecurityPlugin` no longer seeds a `sys_position` row for the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) or for a position a stack declares, and no longer writes a `sys_position_permission_set` row binding the baseline sets to `everyone`. Positions are served by the security catalog (ADR-0131 D2): the built-ins are this plugin's declarations, a stack's positions are registry items, and `everyone`'s baseline — the app's default set and `member_default`, minus any set the high-privilege check refuses — is that anchor's declared `permissionSets`. `GET /api/v1/meta/position` lists all of them, and assigning a declared position needs no row. - -No authorization decision changes: since the resolver reads position definitions, no grant reads a `sys_position` or `sys_position_permission_set` row, and a fresh database and an existing one grant every principal the same. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. - -FROM → TO: - -- FROM reading the built-in or declared positions from `sys_position` → TO the security catalog read (`createSecurityCatalogReader`) or `GET /api/v1/meta/position`. -- FROM the `everyone` → baseline `sys_position_permission_set` rows → TO the `everyone` definition's `permissionSets`. diff --git a/.changeset/pre/15204-s6b1a-capability-declarations.md b/.changeset/pre/15204-s6b1a-capability-declarations.md deleted file mode 100644 index fb4894241d0..00000000000 --- a/.changeset/pre/15204-s6b1a-capability-declarations.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -feat(plugin-security): the curated platform capabilities are declared `capability` metadata - -Clause-②: no - -`SecurityPlugin` now declares the nine curated platform capabilities (`PLATFORM_CAPABILITIES` in `@objectstack/spec`: `manage_users`, `manage_org_users`, `manage_metadata`, `manage_platform_settings`, `setup.access`, `setup.write`, `studio.access`, `manage_sharing`, `view_all_audit_log`) to the engine registry under its own package id, from that one spec list. The security catalog read (`createSecurityCatalogReader`) and `GET /api/v1/meta/capability` now list them beside the capabilities a stack declares, and `GET /api/v1/meta/capability/:name` answers each one's definition (`name`, `label`, `description`, `scope`) where it answered `404`. A write to one of them at the metadata door is still refused, as before: `capability` is code-only. - -Nothing else changes: the `sys_capability` rows the boot seeds are identical, on a fresh database and on one already seeded, and no capability seeder reports the platform's own declarations. diff --git a/.changeset/pre/15204-s6b1b-system-capability-seeder.md b/.changeset/pre/15204-s6b1b-system-capability-seeder.md deleted file mode 100644 index a59ca8bb25f..00000000000 --- a/.changeset/pre/15204-s6b1b-system-capability-seeder.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/plugin-security': minor -'@objectstack/spec': patch -'@objectstack/lint': patch ---- - -feat(plugin-security): the boot no longer writes `sys_capability` rows for the platform's curated capabilities - -Clause-②: no - -The curated and derived-default capability seeder (`bootstrapSystemCapabilities`) is deleted. On a fresh database the boot writes no `sys_capability` row for the nine curated platform capabilities (`manage_users`, `manage_org_users`, `manage_metadata`, `manage_platform_settings`, `setup.access`, `setup.write`, `studio.access`, `manage_sharing`, `view_all_audit_log`), and none for a name a permission set grants in `systemPermissions` without declaring it. The curated capabilities are served by the registry: `GET /api/v1/meta/capability` lists them and `GET /api/v1/meta/capability/:name` answers each one's definition, as before. Declare a capability your package grants with `defineCapability` (`stack.capabilities`); a declared capability is still seeded as a package row. - -No authorization decision changes: a grant is still resolved by the capability name in `systemPermissions`, and no runtime check reads a `sys_capability` row. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. - -In `@objectstack/spec` (`PLATFORM_CAPABILITIES`) and `@objectstack/lint` (`validateCapabilityReferences`), the doc comments now name the registry instead of the retired seeder; no behaviour changes there. diff --git a/.changeset/pre/15204-s6b1c-declared-capability-seeder.md b/.changeset/pre/15204-s6b1c-declared-capability-seeder.md deleted file mode 100644 index 2e843a82e11..00000000000 --- a/.changeset/pre/15204-s6b1c-declared-capability-seeder.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/plugin-security': minor -'@objectstack/spec': patch -'@objectstack/objectql': patch -'@objectstack/runtime': patch -'@objectstack/lint': patch ---- - -feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted - -Clause-②: no - -`SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served. - -A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run. - -In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged. - -Removed from `@objectstack/plugin-security`'s public exports, with no replacement: - -- `CAPABILITY_NAME_COLLISION` -- `capabilityNameCollisionDiagnostic` -- `formatCapabilityNameCollisionDiagnostic` -- `reportCapabilityNameCollisions` -- `CapabilityNameCollisionDiagnostic` (type) - -FROM → TO: - -- FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`. -- FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place. diff --git a/.changeset/pre/15204-s6b2-declared-permission-seeders.md b/.changeset/pre/15204-s6b2-declared-permission-seeders.md deleted file mode 100644 index abf718276c7..00000000000 --- a/.changeset/pre/15204-s6b2-declared-permission-seeders.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/plugin-security': minor -'@objectstack/metadata-protocol': minor -'@objectstack/objectql': minor -'@objectstack/cloud-connection': patch ---- - -feat(plugin-security)!: no seeder or materializer writes a `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and the `permission` publish materializer are deleted - -Clause-②: yes (narrowing) - - - -**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. - -`SecurityPlugin` no longer writes a `sys_permission_set` row for any permission set: not for the platform defaults (`admin_full_access`, `member_default`, …), not for a set a package declares, not for an organization's copy, and not on a metadata reload or an organization's creation. Permission sets are served by the security catalog (ADR-0131 D3), and since the grant names its set (`sys_user_permission_set.permission_set`), no grant path needs a row. A fresh database still promotes its first platform administrator, grants organization administrators and self-registered members, and grants every principal what it was granted before. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. Until a later stage retires the ADR-0094 projector, publishing a permission set (a package-door publish, or a writable base's direct publish) still has it write a projection row, `managed_by: 'admin'` with no `package_id` (a package-door publish wrote `managed_by: 'package'` before this release); no authorization path reads it, and uninstalling the package removes it. - -- **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. -- **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. -- **Uninstalling a package still revokes every grant of its permission sets** (ADR-0090 D5, "no ghost grants"). With no row standing for a package's set, both uninstall doors (`deletePackage` and `DELETE /api/v1/marketplace/install-local/:id`) capture the set names the package held just before they withdraw it from the registry, and the `security.package-permissions` cleanup revokes every `sys_user_permission_set` grant naming them. The withdrawal still runs before the cleanups. A grant naming such a set would otherwise survive the uninstall and be live again on reinstall. Grants written before the name column, and position bindings, are still reached through a legacy `sys_permission_set` row where an already-seeded database holds one. -- `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once. - -Added (additive, nothing an author writes changes): - -- `@objectstack/metadata-protocol`: the optional `declared?: UninstallDeclared` argument of `UninstallCleanup` (and of `runUninstallCleanups`' request), the `UninstallDeclared` type, and `captureUninstallDeclared(registry, packageId, stored?)`, the one capture both doors call. A cleanup that ignores `declared` behaves as before. -- `@objectstack/objectql`: `SchemaRegistry.securityCatalogNamesHeldBy(packageId, type)`. -- `@objectstack/plugin-security`: `cleanupPackagePermissions` takes an optional fourth argument, the captured names. - -Removed from `@objectstack/plugin-security`'s public exports, with no replacement: - -- `bootstrapDeclaredPermissions` -- `normalizeManagedByVocab` -- `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` -- `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` diff --git a/.changeset/pre/15204-s6b2a-meta-resync-example-binders.md b/.changeset/pre/15204-s6b2a-meta-resync-example-binders.md deleted file mode 100644 index eb592f48db0..00000000000 --- a/.changeset/pre/15204-s6b2a-meta-resync-example-binders.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/cli': minor -'@objectstack/plugin-security': patch -'@objectstack/driver-sql': patch -'@objectstack/runtime': patch ---- - -feat(cli)!: `os meta resync` is deleted - -Clause-②: yes (narrowing) - - - -**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. - -`os meta resync` reconciled the platform-default `sys_permission_set` rows (`admin_full_access`, `member_default`, …) to the shipped declaration. Since the authorization resolver reads permission sets from the security catalog (ADR-0131 D3), an edited default set is served from its declaration at the next boot, and the rows the command reconciled decide nothing. The command, its `--yes` / `--json` modes and its skip-count explanation are gone; a script that calls it gets oclif's unknown-command error. - -In `@objectstack/plugin-security`, `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments and one warning sentence (the permission-set backfill's re-provision list) no longer name the command. Their behaviour is unchanged. diff --git a/.changeset/pre/15204-s6c-prep-organization-scope.md b/.changeset/pre/15204-s6c-prep-organization-scope.md deleted file mode 100644 index d13e5457381..00000000000 --- a/.changeset/pre/15204-s6c-prep-organization-scope.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -Internal move with no behavior change: the organization-scoping helpers and the seed write-refusal accumulator leave `per-organization-catalog.ts` for two new internal modules, `organization-scope.ts` and `write-refusals.ts` - -Clause-②: no diff --git a/.changeset/pre/15204-s7pre-overlay-discard-retired.md b/.changeset/pre/15204-s7pre-overlay-discard-retired.md deleted file mode 100644 index 16bacfd64c0..00000000000 --- a/.changeset/pre/15204-s7pre-overlay-discard-retired.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-security': minor -'@objectstack/rest': minor ---- - -feat(plugin-security,rest,spec)!: the permission-set "Discard Overlay" action, its REST route and its contract member are retired; no door creates the overlay it discarded, and a database holding one is refused at cold boot (ADR-0131 cutover, ADR-0048) - -Clause-②: no (narrowing) - - - -**BREAKING** — a narrowing of the public surface: one HTTP route, one Setup action and one optional contract member are removed. Shipped as `minor` under the launch-window convention for breaking changes. - -The action deleted the environment-wide `sys_metadata` row that shadowed a package-declared permission set, then re-projected the set's `sys_permission_set` row. Neither half has anything left to do: - -- No door creates such an overlay. The metadata door refuses a save over a packaged permission set on every topology, whatever `OS_METADATA_WRITABLE` says. The data door refuses an edit of one (clone it instead). The data API cannot write `sys_metadata`. A package that declares a name the environment holds is refused at install. -- An overlay stored before those refusals makes the cold boot refuse the deployment with `422 NAMESPACE_CONFLICT`, so a server that could answer the route never holds one. -- The authorization resolver reads the security catalog, not `sys_permission_set` rows, so the re-projected row granted nothing. - -### What was removed - -| removed | what to use instead | -| --- | --- | -| `POST /api/v1/security/permission-sets/:id/discard-overlay` | `os migrate security-catalog-overlays` with the flags and environment the deployment boots with lists every overlay row with no server running; `--apply` deletes them (a history tombstone per row). | -| the **Discard Overlay** action on `sys_permission_set` in Setup | the same offline step. To keep a customization of a packaged set, clone it under a new name (the **Clone** action) and assign the clone. | -| `ISecurityService.discardPermissionSetOverlay` and `PermissionSetOverlayDiscardResult` (`@objectstack/spec/contracts`) | nothing: delete the call and the import. `tsc` names each one. | -| `discardPermissionSetOverlay`, `PermissionSetNotFoundError`, `PermissionSetOverlayStateError` and the `PermissionSetOverlayDiscardDeps` / `PermissionSetOverlayDiscardResult` types (`@objectstack/plugin-security`) | nothing: delete the import. | - -**The one-line fix:** stop calling the route or the member, and clear any overlay offline with `os migrate security-catalog-overlays --apply` before the first boot on this release. - -`ERROR_CODE_LEDGER['@objectstack/plugin-security']` no longer lists `INVALID_STATE` or `NOT_FOUND`: the removed action was their only producer in this package. Both codes stay registered under `@objectstack/rest`, and the union of codes is unchanged. - -The permission-set drift diagnostic no longer names the action in its `overlay_shadow` detail, and the boot reading of shadowed packaged sets names the offline step instead. diff --git a/.changeset/pre/15204-s8-catalog-row-objects-retired.md b/.changeset/pre/15204-s8-catalog-row-objects-retired.md deleted file mode 100644 index 7ed08713e47..00000000000 --- a/.changeset/pre/15204-s8-catalog-row-objects-retired.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/plugin-security': minor -'@objectstack/platform-objects': minor -'@objectstack/spec': minor -'@objectstack/core': minor -'@objectstack/metadata-protocol': patch -'@objectstack/objectql': minor -'@objectstack/plugin-sharing': patch -'@objectstack/lint': patch -'@objectstack/service-settings': patch ---- - -feat(plugin-security)!: the four security-catalog row objects are retired — `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` (ADR-0131 D3) - -Clause-②: no (narrowing) - - - -**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. - -Positions, permission sets and capabilities live in the security catalog — the environment registry, fed by package declarations and by environment metadata saved through `/api/v1/meta` (ADR-0131 D3). The four row objects that mirrored them are no longer registered, so their data and REST routes and their Setup object pages are gone. No table is dropped and no row is deleted. - -FROM → TO: - -- `GET|POST|PATCH|DELETE /api/v1/data/sys_permission_set` → `GET|PUT|DELETE /api/v1/meta/permission/:name`; to copy a set, `POST /api/v1/security/permission-sets/:name/clone` with `{ name, label }`. -- `… /api/v1/data/sys_position` → `/api/v1/meta/position/:name`. A position's permission sets are its definition's `permissionSets`. -- `… /api/v1/data/sys_position_permission_set` → the position definition's `permissionSets` (`PUT /api/v1/meta/position/:name`). -- `… /api/v1/data/sys_capability` → read-only `GET /api/v1/meta/capability`; a package declares a capability with `defineCapability` (`stack.capabilities`). -- Setup → Access Control → Positions / Capabilities / Permission Sets now open Setup's catalog lists (`/apps/setup/metadata/?scope=environment`) instead of the object pages; the `sys_position` record page (`SysPositionDetailPage`) is removed. -- An object permission on one of the four names in a permission set grants nothing and can be deleted. - -What else changes: - -- `sys_user_permission_set.permission_set_id` is a plain text column now, not a lookup: same column, same stored ids, same indexes. It is not written and grants nothing; a grant names its set by `permission_set`. -- A permission set saved through the metadata door is written to no row: the `permission` mutation projector, the `sys_permission_set` and `sys_position` data-door write-throughs and the row-only position backfill are no longer registered. -- On a fresh `single`-posture database the first human is promoted to platform administrator without reading any catalog row. An unscoped grant written before `sys_user_permission_set.permission_set` existed (it names no set) still withholds the promotion until the organization-ownership migration names it. -- Uninstalling a package still revokes every grant naming a permission set the package declared, by the names the uninstalling door captures before the withdrawal (`declared`, `captureUninstallDeclared`), and now by those names alone: the cleanup reads and removes no `sys_permission_set` or `sys_position_permission_set` row, so the `sets` and `positionBindings` counts on its outcome are always 0. A grant written before `sys_user_permission_set.permission_set` existed names no set and is not reached; the organization-ownership migration names it. -- On a `group` or `isolated` deployment the data-door half of the walled catalog refusal goes with the objects: a data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability` answers `404 OBJECT_NOT_FOUND` for every caller instead of `403 PERMISSION_DENIED` below the platform operator. The refusal at the `/meta` write doors, the permission-set clone door and the `/packages` write doors is unchanged. -- `capability-reference-unknown` no longer counts a `sys_capability` seed row as a declared capability — no runtime reader consults one. Declare the capability with `defineCapability`. -- On an existing database `os migrate plan`'s unmanaged-table sweep now lists the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` tables: they keep their rows and no object declares them any more. The sweep only reports; nothing is dropped, and the tables stay for the organization-ownership migration. - -Removed exports, no replacement: - -- `@objectstack/plugin-security`: the object definitions `SysPosition`, `SysPermissionSet`, `SysPositionPermissionSet`, `SysCapability` (from `./objects`); `securityObjects` lists the remaining three. -- `@objectstack/platform-objects/pages`: `SysPositionDetailPage`. -- `@objectstack/spec`: `SystemObjectName.POSITION`, `SystemObjectName.PERMISSION_SET`; the four names leave `PLATFORM_OBJECTS_BY_PACKAGE['plugin-security']` and `PLATFORM_PROVIDED_OBJECT_NAMES`. -- `@objectstack/core`: `isRowActive` and `ActivatableRow` — a catalog row's `active` column decides nothing; deactivation is the activation ledger's (`sys_metadata_activation`). -- `@objectstack/objectql`: the published `PLATFORM_OBJECT_TENANCY` record loses its `sys_permission_set` key (the other three names were never in it). A removed key of an exported constant is a narrowing, so `@objectstack/objectql` takes `minor` under the launch-window convention for breaking changes. diff --git a/.changeset/pre/15204-su2-contract-text.md b/.changeset/pre/15204-su2-contract-text.md deleted file mode 100644 index 0a121dd223a..00000000000 --- a/.changeset/pre/15204-su2-contract-text.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/client": patch -"@objectstack/rest": patch -"@objectstack/cli": patch ---- - -docs: the suggested-binding acceptance is deployment-level and takes effect - -Clause-②: no - -Text only. These are the published sentences that describe what confirming a package's `isDefault` suggestion does. They now match the behaviour `@objectstack/plugin-security` ships in this release. - -- `@objectstack/spec`: `ISecurityService.listAudienceBindingSuggestions`, `confirmAudienceBindingSuggestion` and `dismissAudienceBindingSuggestion` keep "writes the binding" for the confirm. The text now also says what that means: a `manage_metadata` holder accepts once for the deployment, `everyone`'s definition is derived again, and a dismiss on an accepted suggestion revokes it. The package-level `isDefault` note keeps "an admin confirms in Setup" and adds who that admin is. -- `@objectstack/client`: the `security.suggestedBindings.confirm` and `.dismiss` TSDoc. -- `@objectstack/rest`: the route summaries of the confirm and dismiss routes. Paths and envelopes are unchanged. -- `@objectstack/cli`: the ownership-inventory citation for `sys_audience_binding_suggestion` now says the record is deployment-level and owned by the Default Organization. diff --git a/.changeset/pre/15204-su2-suggestion-acceptance.md b/.changeset/pre/15204-su2-suggestion-acceptance.md deleted file mode 100644 index ac5da262793..00000000000 --- a/.changeset/pre/15204-su2-suggestion-acceptance.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -feat(plugin-security)!: accepting a package's `isDefault` suggestion takes effect for the whole deployment - -Clause-②: yes (narrowing) - -**BREAKING.** A package's `isDefault: true` permission set now reaches every signed-in member through ONE deployment-level acceptance, and the acceptance takes effect. Before this release a confirm wrote a `sys_position_permission_set` row that granted nothing. - -- **What an accept does.** `POST /api/v1/security/suggested-bindings/:id/confirm` records the decision once for the deployment, and the security plugin derives the `everyone` anchor's definition again as the baseline plus every accepted set. The derivation also runs at boot, on uninstall and on a package-door publish. A member's reads of the package's objects change on the next request. `bindingCreated` is `true` when the definition did not name the set before. -- **Who may decide (narrowing).** All three routes now need the `manage_metadata` capability, and under a `group` or `isolated` tenancy posture the platform operator. Before this release a tenant administrator (the `*` wildcard holder) listed, confirmed and dismissed suggestions for their organization. That caller is now refused `403 PERMISSION_DENIED`. Remedy: a `manage_metadata` holder decides, once for the deployment. -- **The record is the deployment's.** `sys_audience_binding_suggestion` rows are owned by the Default Organization. A deployment with no Default Organization (several organizations, none with slug `default`) answers `409 SUGGESTION_STATE` with the remedy. -- **Rows written before this release are never promoted.** A Default Organization row a tenant administrator confirmed or dismissed is set back to `pending`, unless the baseline already names the set. A row another organization owns, or none, is not read. Boot logs a warning with the count. Such rows granted nothing before this release and grant nothing after it. -- **The high-risk gate is on the definition (ADR-0090 D7).** Accepting a set that carries a bit the `everyone` anchor forbids answers `403 PERMISSION_DENIED`. If a publish later adds such a bit to an accepted set, the derivation withholds it and logs an `error` that names the set. -- **Revoke.** `POST …/:id/dismiss` on an accepted suggestion revokes it, and `everyone` is derived again without the set. A set that only the baseline names (`confirmed`, no resolver) cannot be dismissed (`409`). -- **Only `everyone`.** A `guest`-anchor suggestion is refused `409`, because accepting it would record a grant that never takes effect. -- **The anchors stay sealed.** No caller writes `everyone`'s definition. An acceptance changes the derivation's input, and the plugin re-declares the definition. Reconciliation reads that definition and no binding row. -- **Declarations come from the security catalog.** A suggestion is offered for an `isDefault` set that the catalog serves with an owning package, which is the source the authorization resolver reads. The module no longer reads `sys_position`, `sys_permission_set` or `sys_position_permission_set`. It also no longer materializes a set. -- **New field:** `sys_audience_binding_suggestion.deployment_decision`, a boolean that is `true` when a `manage_metadata` holder resolved the row. - -Exported TypeScript surface (the compiler names each caller): - -- Removed: `reapOrganizationLessSuggestions`, `listSuggestionOrganizationIds` and the `SuggestionReconcileScope` type. Nothing replaces them, because the record is no longer per organization. -- `reconcileAudienceBindingSuggestions` and `syncAudienceBindingSuggestions` now take `(ql, logger)`. The metadata, scope and organization parameters are gone. -- `SuggestionReconcileOutcome` reports `unread` in place of `organizations` and `reaped`. `SuggestionSyncOutcome` gains `reopened`. -- `SuggestionDeps` drops `resolveSets` and takes `posture` and `rederiveEveryone`. -- New: `readAcceptedEveryoneSets(ql, metadata)` and the `WithheldAcceptance` type. - - diff --git a/.changeset/pre/15204-verify-rls-probe-registry-persona.md b/.changeset/pre/15204-verify-rls-probe-registry-persona.md deleted file mode 100644 index db5480d70b1..00000000000 --- a/.changeset/pre/15204-verify-rls-probe-registry-persona.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/verify': patch ---- - -fix(verify): the RLS probe persona is provisioned through the registry — `provisionRlsProbePersona` no longer mints a `sys_permission_set` row - -Clause-②: no - -`provisionRlsProbePersona` used to find-or-create a `sys_permission_set` row named `verify_rls_probe` and grant it. Under ADR-0131 D3/D4 a permission set's one home is the registry: the authorization resolver reads the grant's `permission_set` name and takes the definition from the security catalog, never from a row. So a row this function minted was a copy of a definition nothing read. - -It now: - -- asks the security catalog (`securityCatalogReaderOf`, the read the resolver itself uses) whether `verify_rls_probe` is registered. `rlsProbeSecurity(config)` registers it at boot; -- reads the id of the row the boot already seeded for that registered set, because `sys_user_permission_set.permission_set_id` is still a required column; -- writes the one grant, carrying both columns. - -It throws, writing nothing, when the catalog does not serve the probe set or the boot seeded no row for it. `objectstack verify --rls` reports either as a degraded probe persona and fails the run, as it already does for any provisioning failure. Fix for a caller that hits it: boot the stack with `rlsProbeSecurity(config)` as the `security` option of `bootStack`. diff --git a/.changeset/pre/15204-walled-catalog-package-refusal.md b/.changeset/pre/15204-walled-catalog-package-refusal.md deleted file mode 100644 index 43a7d02a38e..00000000000 --- a/.changeset/pre/15204-walled-catalog-package-refusal.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/cloud-connection': minor -'@objectstack/runtime': minor -'@objectstack/objectql': minor -'@objectstack/spec': minor ---- - -Under a walled posture a package that carries security catalog items is the platform operator's: tenants are refused installing, upgrading, uninstalling or deleting it - -Clause-②: yes (narrowing) - - - -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant (managed packages, Studio)."). - -**Why.** A package registers its positions, permission sets and capabilities environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack, with an organization owner granted `manage_metadata` inside their own organization (never the platform operator): -- the owner re-installed an operator-installed package at a new version through `install-local`, adding `manage_users` to a permission set another organization's member holds, and that member's next resolution carried `manage_users`; -- the owner's `install-local` uninstall of that package took the set away from that member; -- the owner's `DELETE /api/v1/packages/:id` of a runtime package the operator had saved a permission set into did the same. - -The existing name-conflict refusal stops only a package that shadows a name another holder has, never a write by the package that holds it. - -**FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): - -- `POST /api/v1/marketplace/install-local` (`@objectstack/cloud-connection`) of a package whose incoming version declares a position, permission set or capability, or, on an upgrade, whose installed version does: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the package is registered or the ledger written. The whole package is refused, since a package installs whole. The message names the posture, and `error.details.catalogItems` lists the items. -- `DELETE /api/v1/marketplace/install-local/:id` of a package whose installed version declares one: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the ledger is touched. -- `DELETE /api/v1/packages/:id` on the runtime dispatcher, of a writable package holding stored positions or permission sets: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is deleted. - -**Unchanged.** A package with no catalog item installs, upgrades and uninstalls as before, for the same callers. The platform operator does all of the above as before. Under `single` (and with no `tenancy` service) nothing changes. Disabling a package is unchanged: it was measured not to change what any organization resolves. - -**The remedy.** The platform operator installs, upgrades and removes catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-writes-retired`. Both doors ask the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), once per catalog type the package declares. `@objectstack/objectql` now exports `declaredSecurityCatalogNames`, the engine's own reading of what a manifest declares, so a package door judges the declarations the engine registers. diff --git a/.changeset/pre/15204-walled-catalog-package-verbs.md b/.changeset/pre/15204-walled-catalog-package-verbs.md deleted file mode 100644 index 80165b29f5f..00000000000 --- a/.changeset/pre/15204-walled-catalog-package-verbs.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/runtime': minor -'@objectstack/spec': minor ---- - -Under a walled posture the `/packages` verbs that publish, discard, roll back, revert, duplicate or adopt security catalog items are the platform operator's - -Clause-②: yes (narrowing) - - - -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant"). - -**Why.** A package's positions and permission sets are environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack: the operator published a permission set in a writable package, and another organization's member held it. An organization owner granted `manage_metadata` inside their own organization (never the platform operator) then: -- published the operator's pending draft adding `manage_users`, and that member's next resolution carried it; -- rolled the package back, and the member lost it; -- reverted the operator's commit, and the member lost it; -- copied the set, under the same name, into a package of their own, and `GET /meta` then served their copy; -- discarded the operator's pending draft (through `revert`, and `discard-drafts` is the same discard by a second door); -- adopted the environment's catalog rows into their own package. - -**FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner). Every door is on the runtime dispatcher, and each **FROM** is: admitted to any `manage_metadata` holder. Each **TO** is `403 PERMISSION_DENIED`, naming the posture, before the verb runs: - -- `POST /api/v1/packages/:id/publish-drafts`, when the package has a pending position, permission-set or capability draft. -- `POST /api/v1/packages/:id/revert`, when the package has such a draft. -- `POST /api/v1/packages/:id/discard-drafts`, when the package has such a draft. -- `POST /api/v1/packages/:id/rollback`, when a commit it would undo touched a position, permission set or capability. -- `POST /api/v1/packages/:id/commits/:commitId/revert`, when that commit touched one. The commit's own items are judged: the verb takes the commit id alone, so the URL's package segment changes nothing. -- `POST /api/v1/packages/:id/duplicate`, when the source package's published environment rows (the ones it copies) hold one. -- `POST /api/v1/packages/:id/adopt-orphans`, when the environment's orphaned rows include one. - -**Unchanged.** A package whose drafts, commits or rows carry no catalog item is handled as before, for the same callers. The platform operator runs every verb as before. Under `single` (and with no `tenancy` service) nothing changes, and no extra read happens: the verdict is asked before the items are read. - -**The remedy.** The platform operator publishes, discards, rolls back, reverts, duplicates and adopts catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-verbs-retired`. Every door asks the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), through the same helper as the package delete. diff --git a/.changeset/pre/15204-walled-catalog-refusal.md b/.changeset/pre/15204-walled-catalog-refusal.md deleted file mode 100644 index d22ed5c368b..00000000000 --- a/.changeset/pre/15204-walled-catalog-refusal.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/metadata-core': minor -'@objectstack/rest': minor -'@objectstack/runtime': minor -'@objectstack/plugin-security': minor -'@objectstack/spec': minor ---- - -Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them - -Clause-②: yes (narrowing) - - - -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). - -**Why.** The catalog is environment-level, and every organization resolves it by name. Measured on a booted `isolated` and `group` stack: an organization owner granted `manage_metadata` inside their own organization saved `PUT /meta/permission/:name` over a permission set another organization's member holds, and that member's next resolution carried the change. One organization's write changed what another organization's members resolve. - -**FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): - -- `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for `position` or `permission`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. -- The same doors for `capability`: admitted where they were admitted. `capability` is a code-only type, so these doors already refused its writes before this change; only the sentence the caller reads changes, to the posture refusal. For `capability`, the accept set narrows at the data door on `sys_capability`, below. -- `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. -- A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. This includes an ADR-0090 D12 delegate whose `adminScope` carries `authorEnvironmentSets`: under a wall it can no longer author environment permission sets at the data door. Its assignment and binding paths are untouched. - -**Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. - -**The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-tenant-writes-retired`. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. diff --git a/.changeset/pre/15205-email-template-org-door-closed.md b/.changeset/pre/15205-email-template-org-door-closed.md deleted file mode 100644 index 11a26591973..00000000000 --- a/.changeset/pre/15205-email-template-org-door-closed.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/plugin-email': minor -'@objectstack/plugin-auth': minor -'@objectstack/platform-objects': patch ---- - -feat(plugin-email,plugin-auth)!: an organization can no longer create or edit an email template row; the template provenance stamp and the auth SMS template seed retire - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing and three retired exports), shipped as `minor` under the launch-window convention for breaking changes. It carries ADR-0131 D6 and the maintainer's ruling C on ADR-0131 §6 Q1: email templates are not overridden per organization. - -**What stops being accepted.** - -- **The `sys_email_template` organization door is closed.** An organization's create and update of a template row are refused with `403 PERMISSION_DENIED`, and the message names the closed door: every engine `insert` / `update` whose context names a caller and is not system-elevated. That covers `POST` / `PATCH /api/v1/data/sys_email_template`, the Studio record editor, batch and import routes, and scripts and flows that run as a user or a service principal. System-context writes still pass: the built-in seed, the declared-template boot sweep, the live projection of a Studio `email_template` save into its row, and the v18 migration ceremony's promotion. Delete is not part of this door. -- **The template provenance stamp is retired.** It marked a package- or platform-seeded row `customized: true` when a non-system caller updated it. With the door closed no such update reaches the engine's write, so nothing marks a row any more. Rows already marked keep their mark and are still never overwritten by the boot seeders; they are the population the v18 migration ceremony promotes to environment-level Studio templates. -- **Retired exports of `@objectstack/plugin-email`:** `bindEmailTemplateProvenanceStamp`, `unbindEmailTemplateProvenanceStamp` and `EMAIL_TEMPLATE_PROVENANCE_PACKAGE`. They have no replacement. `EmailServicePlugin` closes the door itself, and there is no stamp left to bind, so a direct call is deleted, not rewritten. -- **The auth SMS template seed is retired.** A boot with phone sign-in on no longer writes the built-in OTP and invitation texts into `sys_notification_template` as rows. It was internal to `@objectstack/plugin-auth` and exported nothing. - -**What renders unchanged.** - -- Every email template renders as before: the template loader still reads `sys_email_template`, and a Studio edit of an `email_template` still reaches the mail at once and survives a restart. -- Every auth SMS text renders byte for byte as the seeded store rendered it, for every built-in text and every recipient locale. Each locale rung renders an operator's active row when one exists, and the built-in text where no row exists at that locale. A deactivated or blank row passes its rung on, as it did before. -- A `sys_notification_template` row an operator already has still wins, and no existing row is touched. - -**What changes for you.** To change what an email template sends, edit the `email_template` in Studio. A script or integration that wrote `sys_email_template` rows through the data API now receives `403 PERMISSION_DENIED`. `@objectstack/platform-objects` corrects the `is_system` and `customized` field help on `sys_email_template`, which said an organization may edit a row. diff --git a/.changeset/pre/15205-email-templates-resolve-registry.md b/.changeset/pre/15205-email-templates-resolve-registry.md deleted file mode 100644 index a9cc040ebdf..00000000000 --- a/.changeset/pre/15205-email-templates-resolve-registry.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/plugin-email': minor -'@objectstack/spec': patch -'@objectstack/service-automation': patch ---- - -feat(plugin-email)!: email templates resolve from the registry; the built-in seed, the declared-template boot sweep and the live projector retire - -Clause-②: yes (narrowing) - - - -**BREAKING** (retired exports and plugin options of `@objectstack/plugin-email`), graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, so this ships in an `18.0.0-next.N`. It carries ADR-0131 D6/D10 and the maintainer's ruling C on ADR-0131 §6 Q1. - -**What changes.** - -- **Templates resolve from the registry.** `sendTemplate` and `renderTemplate` read `email_template` metadata by `(name, locale)` at every send: the protocol's layered list (`GET /meta/email_template`'s items — the environment's item over each code package's declaration), or the ObjectQL registry on a host with no metadata door. The locale ladder is unchanged: the named locale exactly, then `en-US`, then — only for a call that named none — the bundle's lowest locale tag. -- **The built-in auth templates are code-declared items** of the email plugin's own package (`com.objectstack.service.email`), registered at plugin init, so they now appear in Studio's email-template list. An app that declares the same `(name, locale)` wins over the built-in; the built-in still serves the locales the app leaves out; a Studio save overrides either at environment level. Every built-in renders byte-identical to the row it used to be seeded into, in every locale (pinned on the rendered subject, HTML and text). -- **A Studio save is live on the next send**, with no projection and no restart. The `PUT /meta/email_template/:name` response no longer carries `projectionApplied`, because nothing is projected. -- **No `sys_email_template` row is written at boot or read at send.** The table and its rows stay; the organization door stays closed. -- **A malformed template fails its send loudly** with the schema's own message, instead of falling through to the built-in copy of the same slot. - -**What moves for consumers.** - -- FROM `createSysEmailTemplateLoader(engine, options?)` TO `createRegistryEmailTemplateLoader(sources)`, where `sources` is `{ protocol?, registry? }` (type `EmailTemplateRegistrySources`) or a function returning it. FROM the types `TemplateLoaderEngine` / `CreateTemplateLoaderOptions` TO `EmailTemplateRegistrySources`. -- FROM `bootstrapDeclaredEmailTemplates(...)`, `upsertDeclaredEmailTemplate(...)` or `deactivateDeclaredEmailTemplate(...)` TO nothing: templates are not copied into rows any more. Delete the call. -- FROM `mapTemplateToRow(tpl)` TO nothing: there is no row projection. To resolve a template the way the mailer does, call `createRegistryEmailTemplateLoader(...).load(name, locale)`. -- FROM `EMAIL_TEMPLATE_OBJECT` TO the literal `'sys_email_template'`, only if you still address the legacy table. FROM the type `BootstrapDeclaredEmailTemplatesResult` TO nothing. -- FROM `new EmailServicePlugin({ seedTemplates: false })` TO `new EmailServicePlugin()`: there is no seed to switch off. Delete the option. -- FROM `new EmailServicePlugin({ templates: [...] })` TO the stack's `emailTemplates: [...]` (or a `*.email-template.ts` file): declared templates resolve from the registry. Delete the option. - -**What a deployment observes.** A `sys_email_template` row an organization customized before its door closed is no longer what sends: the template's code declaration (or an environment-level Studio save) is. The v18 migration ceremony promotes those rows to environment-level templates; to make such an edit live before it runs, re-save the template in Studio. - -**`@objectstack/spec`** (patch): the `email_template` liveness ledger is re-anchored to the registry loader, and `category` and `isSystem` are graded `dead` — their only consumer was the retired row projection. The `notify` node's `template` description and refusal messages, and the `IEmailService` contract's documentation, name the email templates rather than `sys_email_template` rows. No schema shape changes. - -**`@objectstack/service-automation`** (patch): the notify node's `template` help text served at `GET /api/v1/automation/actions` names the email templates rather than `sys_email_template` rows. diff --git a/.changeset/pre/15206-boot-report-carry-overs.md b/.changeset/pre/15206-boot-report-carry-overs.md deleted file mode 100644 index a6bbff855b2..00000000000 --- a/.changeset/pre/15206-boot-report-carry-overs.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -The legacy-organization boot report reports each of its reads on its own, and a stored row under a code-defined datasource name is named once at boot - -Clause-②: no - -- `[metadata_org_scoped_unserved]` reads `sys_metadata` and counts the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit` as four separate terms. A read that fails for any reason but a missing table now costs only its own term: the line names it NOT MEASURED, with the reason, and still reports the other terms. Before, one failing read silenced the whole line. -- `[metadata_sealed_overlay_unserved]` no longer lists an environment row stored under the name of a datasource the host defines in code. The datasource restore's boot warning already names that row, with its own remedy, so the row was named twice. No read serves the row either way, and nothing is deleted or rewritten. diff --git a/.changeset/pre/15206-managed-content-sealed.md b/.changeset/pre/15206-managed-content-sealed.md deleted file mode 100644 index deb7db5b9b5..00000000000 --- a/.changeset/pre/15206-managed-content-sealed.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -feat(metadata-protocol)!: managed content is sealed — `OS_METADATA_WRITABLE` no longer opens a write onto, or a removal of, an item a managed package ships (ADR-0131 D6) - -Clause-②: no (narrowing) - - - -**BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. - -ADR-0131 D6: no door edits a managed definition. The operator hatch `OS_METADATA_WRITABLE` (and its legacy spelling `OBJECTSTACK_METADATA_WRITABLE`) used to open one: an item a managed package ships, of a type whose registry entry allows no environment overlay, could be overlaid and its overlay row removed once the type was named in the variable. Measured on the CRM example with `OS_METADATA_WRITABLE=flow,object,permission,position`, a shipped flow, an object field and a position each took an overlay row, `PUT /api/v1/automation/:name` re-registered the shipped flow, and `DELETE /api/v1/meta/object/crm_lead?dropStorage=true` took the managed object off the data plane. - -**What changes.** With the hatch open or shut, the metadata doors now answer the same thing for an item a managed package ships: `403 NOT_OVERRIDABLE` on a write, and on a removal of a type whose overlay does not merge at read. The protocol's two package doors (`PUT` / `DELETE /api/v1/meta/:type/:name`, and the `/automation` definition doors that ask them), the repository's write path (draft promotion, restore, revert) and the read envelope (`editable` / `deletable`) all read one predicate: the item is artifact-backed and its type's registry entry opens no overlay channel. - -- The refusal's first sentence names the managed package ("… is provided by a managed package and is sealed …"). A type with an ADR-0126 regime row (`flow`, `action`, `permission`) still names its sanctioned route, the clone or the switch. Every other type now reads the seal, says the hatch does not open a managed item, and keeps the source remedy; it no longer prescribes setting `OS_METADATA_WRITABLE`, which would no longer help. -- A write naming a read-only package (`?package=`) still answers `403 ITEM_LOCKED`, now without a hatch-dependent remedy. `SysMetadataRepository.readOnlyBaseOverrideError` takes `(type, packageId)`; its third `hatchOpen` parameter is gone, because nothing it chose survives the seal. - -**What does not change.** - -- The environment overlay of a `view`, `dashboard`, `report`, `translation` or `email_template` a package ships, which the registry allows. -- Everything about items no managed package ships: the hatch still opens their runtime creation for a type that allows none, and their organization-scoped write. -- Disabling a managed flow or action (`POST /api/v1/automation/:name/toggle`, `POST /api/v1/actions/_activation/:object/:action`), operator-gated under a wall as before, and cloning a flow under a new name, which records no linkage. -- Removing a stored overlay row of a type whose loader merges it at read (`permission`, `position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`): that removal restores the package's definition and stays allowed. -- Every overlay row a deployment already holds keeps loading and serving as before. - -**For an operator who set the hatch to customize a managed item.** Customize it through its type's route: an environment overlay for the five presentational types, the switch or a clone under a new name for a flow, the clone for a permission set, an extension package for an object. An overlay row the hatch wrote earlier onto a flow, action, hook, object or another type whose overlay does not merge at read keeps serving, and can no longer be edited or removed through the metadata API, with the hatch set or not. diff --git a/.changeset/pre/15206-meta-doors-environment-only.md b/.changeset/pre/15206-meta-doors-environment-only.md deleted file mode 100644 index 2f69ab79e38..00000000000 --- a/.changeset/pre/15206-meta-doors-environment-only.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/metadata-core': minor -'@objectstack/rest': minor -'@objectstack/runtime': minor -'@objectstack/plugin-email': minor -'@objectstack/spec': minor ---- - -feat(metadata-core,rest,runtime,plugin-email,spec)!: the `/meta` doors carry no organization; organization-admin metadata authoring closes and `manage_org_presentation` retires (ADR-0131 D6) - -Clause-②: no (narrowing) - - - -**BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. - -ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio belongs to the whole deployment. The `/meta` doors of both transports (`@objectstack/rest` and the runtime dispatcher's `/meta` branch) used to thread the caller's active organization into writes and reads of the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`). They no longer do, for any type, and the read and the write flip together. - -**What changes.** - -- **Writes land environment-wide.** `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` on `/api/v1/meta/:type/:name` hand the protocol no organization, so the row and its audit and history rows carry `organization_id` NULL, whatever the caller's active organization. -- **Reads are environment → code.** The item read (both cache arms), the list, the layered view (`/layers` and `?layers=true`), `/published`, `?state=draft`, `GET /meta/_drafts`, `/history`, `/diff`, `/audit` (the environment rows, `organizationId: null`), `GET /meta/diagnostics` and `/references` name no organization. -- **An organization admin's metadata write is refused.** `metaWriteCapabilityVerdict` admits `isSystem` or `manage_metadata` only. A caller holding `manage_org_presentation` and not `manage_metadata` is answered `403` on all four item doors (`FORBIDDEN` on REST, `PERMISSION_DENIED` on the dispatcher) with `… requires the \`manage_metadata\` capability.`, for every type and whatever its active organization. -- **`manage_org_presentation` retires** from `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`). A permission set naming it still parses and loads, and its other grants still apply; the grant itself admits nothing. The `sys_capability` row seeded for it earlier is not pruned (the seeder upserts only); an operator may delete it in Setup. -- **The email-template boot sweep** (`@objectstack/plugin-email`) reads the effective templates environment → code, as the door serves them; it no longer reads in the Default Organization. - -**What moves for consumers.** - -- FROM `import { organizationIdForMetaWrite } from '@objectstack/metadata-core'` TO nothing: a `/meta` write carries no organization. Delete the call and the `organizationId` it fed. -- FROM `import { ORG_PRESENTATION_AUTHORING_CAPABILITY } from '@objectstack/metadata-core'` TO nothing: delete the import. -- FROM `metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType, activeOrganizationId, operation })` TO `metaWriteCapabilityVerdict({ isSystem, systemPermissions, operation })`: drop the two members. -- FROM `import { metaReadOrganizationId } from '@objectstack/rest'` TO nothing: a `/meta` read carries no organization. `metaCallerOrganizationId` stays. -- FROM `bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy })` TO `{ protocol }`: the `tenancy` source is gone. -- A permission set granting `manage_org_presentation`: grant `manage_metadata` to whoever must author those five types, and delete the stale grant. - -**What a deployment observes.** An overlay row an earlier release stored under an organization stays in `sys_metadata` untouched, and is no longer served by any `/meta` read or projected by the email-template sweep until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Under the `single` posture that is every earlier Studio save of the five types, because it was filed under the Default Organization: on the `/meta` doors such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. - -**What does not change.** Saves of every other type were already environment-wide. Flow saves, the capability gate's answer for `manage_metadata` holders and `isSystem`, the protocol's own organization-scoped refusals, and the `/packages` doors are untouched by this change. diff --git a/.changeset/pre/15206-protocol-environment-only.md b/.changeset/pre/15206-protocol-environment-only.md deleted file mode 100644 index 34de9073ea6..00000000000 --- a/.changeset/pre/15206-protocol-environment-only.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/runtime': minor -'@objectstack/service-automation': minor -'@objectstack/spec': minor ---- - -feat(metadata-protocol,runtime,service-automation,spec)!: the metadata protocol refuses every organization-scoped write, and an uninstall is environment-wide (ADR-0131 D6/D12) - -Clause-②: yes (narrowing) - - - -**BREAKING, in both directions.** It narrows: an organization-scoped metadata write is refused, and request keys retire. It widens one refusal: a package uninstall naming neither `organizationId` nor `allTenants`, refused before with `400 TENANT_SCOPE_REQUIRED`, is now accepted and runs environment-wide. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. - -ADR-0131 D6 retires the per-organization overlay axis. The `/meta` doors already carry no organization; now the metadata protocol itself refuses an organization-scoped write from every door, and the per-organization write path behind it is deleted. - -**What changes.** - -- Every protocol write that names an organization is refused with `403 NOT_OVERRIDABLE`, before anything is read or written, for every metadata type and every tenancy posture. This covers `saveMetaItem` (draft and publish), `publishMetaItem`, `deleteMetaItem`, `rollbackMetaItem`, `revertCommit`, `rollbackToPackageCommit`, `publishPackageDrafts`, `discardPackageDrafts`, `revertStoredPackage`, `duplicatePackage` and `reassignOrphanedMetadata`. The message's first sentence names the tenancy posture in force. The five types that declared `allowOrgOverride` (`view`, `dashboard`, `report`, `translation`, `email_template`) and the `OS_METADATA_WRITABLE` hatch no longer open an organization scope. -- The audit ledger (`sys_metadata_audit`) and the commit ledger (`sys_metadata_commit`) record `organization_id` NULL. -- The `/packages` doors of the runtime dispatcher thread no organization into any verb: publish-drafts, discard-drafts, the commit list, commit revert, rollback, revert, adopt-orphans, duplicate, delete, and the package manifest read. -- `deletePackage` retires its `organizationId` and `allTenants` request keys and both `400 TENANT_SCOPE_REQUIRED` refusals. The dispatcher's `DELETE /api/v1/packages/:id` no longer refuses an operator with no active organization. An uninstall removes every row bound to the package in this environment. Who may uninstall is the door's operator gate, as before. -- A seed published with a package no longer takes the publisher's active organization: a seed dataset names the organization it populates (ADR-0131 D9). - -**What moves for consumers.** - -| From | To | -|:--|:--| -| `organizationId` on a `SaveMetaItem` / `PublishMetaItem` / `DeleteMetaItem` request (`@objectstack/spec`) | drop it: the write lands environment-wide. The key is stripped at a spec parse (the schemas are not strict) and refused at the protocol: a request still naming one answers `403 NOT_OVERRIDABLE` | -| `organizationId` on any other protocol write verb's request | drop it, same refusal | -| `deletePackage({ packageId, organizationId })` or `deletePackage({ packageId, allTenants: true })` | `deletePackage({ packageId })`; either retired key answers `400 INVALID_REQUEST` and removes nothing | -| `DeletePackageRequest.organizationId` / `.allTenants` (`@objectstack/metadata-protocol`) | gone from the type | -| `UninstallCleanup`'s `organizationId` argument | gone; a cleanup receives `{ packageId, actor? }` | -| `TENANT_SCOPE_REQUIRED` in the error-code ledger | retired; no producer emits it | -| `findPlatformScheduleOrgGaps`' `organizationId` input | gone: every write is platform-level | -| a `seed` draft whose records carry no `organization_id`, relying on the publisher's active organization under `group` | set `organization_id` on each record (ADR-0131 D12, item 12); under `group` a seed record that names no organization is refused at load, and under `single` the loader still derives the Default Organization | - -**What a deployment observes.** - -- Rows stored organization-scoped before this release are not touched by any write. `POST /meta/_migrate-stored` reports each one as `skipped` and names the promotion ceremony (ADR-0131 C7); the stored-flow credential move reports such a flow as not moved (`NOT_OVERRIDABLE`) and logs that its credential is still in cleartext in that row — rotate it. A legacy organization-scoped draft is no longer promoted, discarded or reverted by a package verb, a commit recorded in a legacy organization layer is refused by `revertCommit`, and `duplicatePackage` and adopt-orphans copy or adopt the environment's rows only. -- An uninstall removes the package's legacy organization-scoped rows with it, as the declared cross-tenant uninstall did. - -**What does not change.** The protocol's reads still accept an organization and still serve legacy organization rows; that narrowing is a later stage. The anonymous form doors' read of the Default Organization's layer is unchanged. diff --git a/.changeset/pre/15206-reads-environment-only.md b/.changeset/pre/15206-reads-environment-only.md deleted file mode 100644 index bdb377a53f6..00000000000 --- a/.changeset/pre/15206-reads-environment-only.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/metadata-core': minor -'@objectstack/objectql': minor -'@objectstack/rest': minor -'@objectstack/plugin-security': minor -'@objectstack/spec': minor ---- - -feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) - -Clause-②: no (narrowing) - - - -**BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. - -ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. - -**What changes.** - -- The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. -- `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. -- `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. -- `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. -- [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling instead: the reads do not decline it, a cold boot refuses a deployment that holds one (ADR-0048), and the offline `os migrate security-catalog-overlays` removes it. -- `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. -- Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. -- The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. -- The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. - -**What moves for consumers.** - -| From | To | -|:--|:--| -| `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | -| `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | -| `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | -| `SysMetadataRepository.listDrafts` row `organizationId` (`@objectstack/metadata-protocol`) | gone: every listed row is an environment draft, whichever repository asks | -| `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | -| `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | -| `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | -| `organizationId` on `projectPermissionMutation`'s `evt` (`@objectstack/plugin-security`) | drop it: the projection reads the environment → code layered read, whatever organization the event names | -| a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | -| an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | - -**What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. diff --git a/.changeset/pre/15206-sys-view-definition-retired.md b/.changeset/pre/15206-sys-view-definition-retired.md deleted file mode 100644 index 1b2c137555a..00000000000 --- a/.changeset/pre/15206-sys-view-definition-retired.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/metadata-core': minor -'@objectstack/metadata-protocol': minor -'@objectstack/metadata': minor -'@objectstack/platform-objects': minor -'@objectstack/spec': minor ---- - -feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: `sys_view_definition` retires as inert (ADR-0131 D13) - -Clause-②: no (narrowing) - - - -**BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N` like every other stage. - -`sys_view_definition` was declared for runtime-authored shared and personal views (ADR-0017) and never used for them: no framework code writes or reads its rows, and the Studio console's view doors write the ADR-0005 `view` overlay in `sys_metadata` through the metadata API. ADR-0131 D13 retires it. A runtime-authored view is a `view` metadata item, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`); nothing about that path changes. - -**What moves for consumers.** - -- **The object.** FROM `import { SysViewDefinitionObject } from '@objectstack/metadata-core'` (or from `@objectstack/platform-objects`, or its `/metadata` subpath) TO nothing: delete the import. Neither `MetadataPlugin` nor the metadata protocol assembly registers the object any more, so the generic data door no longer serves it. -- **The migration exports** of `@objectstack/metadata-protocol`. FROM `ensureViewDefinitionActiveIndex`, `resolveIndexExec`, `buildActiveIndexSql`, `VIEW_DEFINITION_TABLE`, `VIEW_ACTIVE_INDEX_NAME`, `VIEW_ACTIVE_PROBE_INDEX_NAME`, `VIEW_ACTIVE_INDEX_COLUMNS`, `EnsureViewIndexLogger`, `EnsureViewIndexStatus` and `EnsureViewIndexResult` TO nothing: delete the import. `classifyIndexFailure` and the `IndexExec` type are still exported, unchanged, from the shared index-migration module. -- **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['metadata-core']` (`@objectstack/spec/system`) lists four objects, and `isPlatformProvidedObjectName` answers `false` for `sys_view_definition`. A stack that names it (a lookup target, a flow trigger, a permission entry, a platform-global declaration) is now flagged as naming an unknown object: remove the reference. -- **`os migrate duplicates`.** `runtimeIndexPreflight` carries three entries (the two `sys_metadata` overlay indexes and `sys_setting`'s row identity), with no `idx_sys_view_def_active` entry. A serving boot no longer runs the active-row index migration for the table, and no longer logs its `sys_view_definition active-row index` lines. -- **Translations.** The platform translation bundles no longer carry `sys_view_definition` keys. - -**Existing databases.** Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_view_definition`, with any rows a caller wrote through the generic data door; nothing reads them. `os migrate apply --allow-destructive` does not drop it either, because it reconciles declared objects only (measured on one database: it dropped an orphaned column of a declared table and left this table and its row in place). On a project with a host config, `os migrate plan` lists it among the platform-prefixed tables nothing declares. Export any row worth keeping; dropping the table is the operator's call, by hand: `DROP TABLE sys_view_definition`. diff --git a/.changeset/pre/15207-audit-log-attribution-field.md b/.changeset/pre/15207-audit-log-attribution-field.md deleted file mode 100644 index a3df8112aa6..00000000000 --- a/.changeset/pre/15207-audit-log-attribution-field.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/plugin-audit': minor -'@objectstack/plugin-security': minor -'@objectstack/service-settings': minor -'@objectstack/spec': minor -'@objectstack/objectql': patch ---- - -feat(plugin-audit,plugin-security)!: the compliance ledger `sys_audit_log` loses its injected organization column; the organization a row is about stays in `tenant_id`, and an organization reader is scoped on it by a platform row policy (ADR-0131 D7) - -Clause-②: no (narrowing) - - - -**BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. - -Some ledger rows are about deployment-level actions no organization owns: a change of platform-administrator standing at boot, a change to a global setting, plugin-auth's administrative user writes. An injected organization column made the tenant wall the ledger's anchor, so under a walled posture those rows were hidden from every reader, platform administrators included. ADR-0131 D7 takes the column off: the ledger is governed by object permission, and the organization a row is about is the plain attribution field `tenant_id`, which the tenant-field resolver does not claim. - -- **`sys_audit_log`** (`@objectstack/plugin-audit`) declares `systemFields: { tenant: false }`, so the registry injects no `organization_id` and a new table is provisioned without it. `tenant_id` (a lookup to `sys_organization`) is unchanged and is the only organization column. The record mirror, the record-view writer and the sign-in writer stamp it as before and no longer stamp `organization_id`. A write that still names `organization_id` on the ledger is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. -- **The read scope** (`@objectstack/plugin-security`, the shipped permission sets): a platform row policy, `sys_audit_log_org` (`tenant_id == current_user.organization_id`), in `organization_admin` (and its no-bypass variant), `member_default` and `viewer_readonly`. `organization_admin` also names `sys_audit_log` explicitly, read only and without `viewAllRecords` / `modifyAllRecords`: its wildcard's superuser bypass would otherwise skip the policy on an object with no tenant column. Under an organization wall an organization administrator or viewer reads the rows about its active organization; a platform administrator (`admin_full_access`) reads every row, the rows about no organization included. Under `single` the policy is stripped by provenance (ADR-0105 D3), as every platform tenant policy is. -- **`config_change` rows** (`@objectstack/service-settings`): a GLOBAL-scope settings change is about no organization, so its ledger row carries no `tenant_id`, whatever organization the writing session had active. Tenant- and user-scope changes keep the writer's organization. The settings writer and plugin-security's platform-admin standing writer no longer probe for or stamp `organization_id`. -- **Retention** (`@objectstack/objectql`): a tenant-scope `lifecycle.retention_overrides` window on `sys_audit_log` partitions the reaper's and the archiver's passes on `tenant_id`, and the global pass keeps the rows with no `tenant_id`. -- **`view_all_audit_log`**: its description (`@objectstack/spec/security`) now names the row scope it does not lift; the capability still lifts only the parent-record read gate. - -**What moves for consumers.** - -- **Authored references.** A filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` names `tenant_id` instead; the two held the same value on every row a writer wrote. -- **Who reads what, under a wall.** A platform administrator now also reads the rows about no organization, global settings changes included. An organization administrator reads exactly its active organization's rows. Under the `group` posture an organization reader is scoped to the ACTIVE organization's rows, not the union of its memberships. A permission set an application ships that grants `viewAllRecords` on the ledger (directly or through a wildcard) skips the row policy, as it skips Layer 1 on every object the wall does not cover. -- **Who reads what, under `single`.** The row policy is stripped under `single` (ADR-0105 D3), as every platform tenant policy is. So a deployment that holds more than one organization under `single` serves every organization's ledger rows to every ledger reader. The remedy is a walled posture. -- **Existing databases — nothing moves automatically** (ADR-0131 D14). Schema sync is additive: the physical `organization_id` column stays and the boot drift report names it orphaned. The v18 upgrade ceremony confirms its values equal `tenant_id` and drops it (`os migrate apply --allow-destructive`), reporting any row where they differ. diff --git a/.changeset/pre/15207-deployment-plumbing-no-organization-column.md b/.changeset/pre/15207-deployment-plumbing-no-organization-column.md deleted file mode 100644 index 126fbaef36f..00000000000 --- a/.changeset/pre/15207-deployment-plumbing-no-organization-column.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/service-automation': minor -'@objectstack/service-realtime': minor -'@objectstack/spec': minor ---- - -feat(platform-objects,service-automation,service-realtime)!: seven deployment-level platform tables lose their injected organization column, and reading them needs `manage_platform_settings` (ADR-0131 D7) - -Clause-②: no (narrowing) - - - -**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). - -`sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` hold deployment-level state. No writer attributes a row of any of them to an organization: every write is a system-context write whose row names none, and nothing writes `sys_presence` through ObjectQL at all. So the injected `organization_id` column only ever held NULL. ADR-0131 D7 takes it off: each object now declares `systemFields: { tenant: false }`. - -With no column there is no tenant wall, so these tables are governed by object permission. Each also declares `requiredPermissions: ['manage_platform_settings']`. Without that gate, a walled deployment's `organization_admin`, whose grant carries the superuser bits on every object, would read every other organization's job errors, queued payloads, dispatch keys and migration traces. - -**What moves for consumers.** - -- **The column.** `organization_id` is no longer a field of these seven objects. A filter, list-view column, report grouping, formula or seed key naming it on one of them is now an unknown field. Delete the reference: no organization owns a row of these tables. -- **Who reads, on a walled posture** (`group` or `isolated`). Before: the wall compared the NULL column to the caller's organization, so every reader got zero rows, platform administrators included (unless the deployment declared the table platform-global, which stood the wall down). Now: a principal holding `manage_platform_settings` (platform administrators hold it) lists every row; anyone else is refused `403 PERMISSION_DENIED`. -- **Who reads, on the `single` posture.** Before: any principal with a read grant on the object read every row, an organization administrator included. Now: only a principal holding `manage_platform_settings` reads; an organization administrator who is not a platform administrator is refused `403 PERMISSION_DENIED`. Grant the capability to an operator who needs these tables. - -**Unchanged.** Every platform writer and reader of these tables uses a system context, which no capability gate applies to, so job scheduling, the queue, flow dispatch, migration flags and the migration journal behave as before. The physical unique indexes are unchanged: none of these objects declares an organization-scoped one. - -**Existing databases.** Schema sync only adds, so the physical `organization_id` column stays on each existing table (with its index, where the deployment indexed it), and the boot drift report names it orphaned. By the writer census it holds only NULL, so dropping it loses nothing: `os migrate apply --allow-destructive` drops it, the remedy the drift report names. diff --git a/.changeset/pre/15207-lifecycle-no-tenant-column-no-partition.md b/.changeset/pre/15207-lifecycle-no-tenant-column-no-partition.md deleted file mode 100644 index 6a413a92f32..00000000000 --- a/.changeset/pre/15207-lifecycle-no-tenant-column-no-partition.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): the lifecycle reaper and archiver no longer partition an object with no tenant column by organization - -A tenant-scope `lifecycle.retention_overrides` entry gives one organization its own retention window, and the reaper and the archiver apply it by partitioning the object's rows on `organization_id`: one pass for that organization's rows, then a global pass for everyone else's. On an object that has no `organization_id` column — one declaring `systemFields: { tenant: false }`, such as the deployment-level platform tables (`sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal`, `sys_presence`), or any other object the registry injects no tenant column into and whose author declares none — both passes named a column the table does not have. The SQL driver refused them (`INVALID_FILTER`), the sweep reported the object in its errors, and the table's retention stopped. - -Such an object now has no tenant partition, the answer a federated object already got: the sweep runs its one global pass at the global window. No row of it belongs to an organization, so a tenant override naming it has nothing to select, and it is not applied. An object that has the column keeps its per-tenant windows unchanged. diff --git a/.changeset/pre/15207-platform-global-no-organization-column.md b/.changeset/pre/15207-platform-global-no-organization-column.md deleted file mode 100644 index e49f98ca100..00000000000 --- a/.changeset/pre/15207-platform-global-no-organization-column.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/objectql': major -'@objectstack/plugin-security': major -'@objectstack/spec': minor -'@objectstack/core': minor -'@objectstack/organizations': patch ---- - -feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) - -Clause-②: yes (narrowing) - - - -**BREAKING** on a deployment whose `org-scoping` service declares `platformGlobalObjects`. Nothing changes on any other deployment. - -ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." Until now the declaration stood the organization wall down for the object while the object kept its `organization_id` column, so the SQL driver went on scoping a read by the caller's organization that the wall no longer scoped. - -- **The plan** (`@objectstack/spec`): `resolveInjectedSystemColumns(def, deployment?)` takes the deployment's validated `platformGlobalObjects` as an optional second argument. An object it names is planned with no `organization_id`, and nothing else in its plan moves. With no second argument, or an empty list, the plan is the same as before. Author-time callers (the linter, the import mapper, the tenancy census) have no deployment and pass none. -- **The registry** (`@objectstack/objectql`): `ObjectQLPlugin` reads the declaration at the top of `start()`, before the first schema sync, and installs it with the new `SchemaRegistry.setDeploymentPlatformGlobalObjects()`. A declared object is registered with no `organization_id`, no tenant index and `systemFields: { tenant: false }`, and its table is created without the column. An object registered earlier, inside another plugin's `init()`, is re-planned at that moment, before its table exists. The `/meta` read exits serve the same shape. On the way back in, the write path removes the recorded `tenant: false`, so a Studio save stores the body the author wrote. The plugin logs the declared list once at boot. -- **The order** (ADR-0116): every `init()` completes before any `start()`, so a provider that registers `org-scoping` in its `init()` has registered by the time the engine reads it. `OrganizationsPlugin` now declares `providesServices: ['org-scoping']` (`@objectstack/organizations`). A provider that registers the service later, with a different declaration than the one the columns were planned from, fails the boot at `kernel:ready` with an error that names both lists and the fix. -- **The stand-down is retired** (`@objectstack/plugin-security`): `getObjectSecurityMeta` no longer reads `platformGlobalObjects`. A declared object reaches the security layer as its own `systemFields.tenant: false`, the same way every deployment-level object does: Layer 0 composes no organization predicate on it, a wildcard `organization_id` policy does not apply to it, and the ADR-0123 D2 no-active-organization write refusal does not fire on it. The `[security] deployment declares N platform-global object(s)` boot line is gone; the engine logs the list instead. -- **One reader** (`@objectstack/core`): `readDeploymentOrgScopingEntitlement` moved from plugin-security into `@objectstack/core` and is exported there, with its rules unchanged. An absent key declares nothing. A malformed key is refused whole: no object is declared, and the consumer of that key warns once. The engine warns for a malformed `platformGlobalObjects`, and plugin-security for a malformed `suppressUnboundedOrgAdminGrant`. `suppressUnboundedOrgAdminGrant` and its behaviour are unchanged. - -**What moves for consumers.** - -- **On the declaring deployment**, a declared object has no `organization_id`. FROM an authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on that object, TO the same reference with that field removed. A write that still names it is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. The object is governed by object permission, not by the organization wall: a reader the object permission admits reads every row, where the SQL driver used to narrow a read to the caller's organization and the rows with no organization. -- **An object that declares its own `organization_id`** keeps that column, because it is the author's and not the platform's, and the organization wall keeps scoping it. The engine warns once at boot and names the object. Remove the authored field, or drop the object from the declaration. -- **Author-time tools** have no deployment, so they still list `organization_id` among a declared object's columns. Only the declaring deployment differs, and it refuses the name at runtime. -- **Existing databases: nothing moves automatically** (ADR-0131 D14). Schema sync is additive, so on a declaring deployment the physical `organization_id` column stays and the boot drift report names it orphaned. The operator removes it once the rows need nothing from it. No boot step reads or writes it. diff --git a/.changeset/pre/15207-settings-global-rung-platform-setting.md b/.changeset/pre/15207-settings-global-rung-platform-setting.md deleted file mode 100644 index 80fbf089e55..00000000000 --- a/.changeset/pre/15207-settings-global-rung-platform-setting.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/service-settings': minor -'@objectstack/spec': minor -'@objectstack/cli': minor ---- - -feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less `sys_platform_setting`, and `sys_setting.scope` no longer declares `global` (ADR-0131 D7) - -Clause-②: yes (narrowing) - - - -**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). - -A settings value for a key declared at `global` scope is a deployment-wide value: the mail transport, the SMS, storage, AI and knowledge providers, auth policy, the lifecycle retention defaults. It used to be a `scope: 'global'` row of `sys_setting`, a tenant-scoped table, where the injected `organization_id` column only ever held NULL and a walled posture hid the row from every reader. ADR-0131 D7 moves the rung out: it is now stored in a new platform object, `sys_platform_setting`. - -- **`sys_platform_setting`** (registered by the settings service, beside `sys_setting`): one row per `(namespace, key)` for the deployment, with the `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns of a settings row, and no `scope`, `user_id` or `organization_id` (`systemFields: { tenant: false }`). It is governed by object permission: a generic data read needs `manage_platform_settings` (`requiredPermissions`), which platform administrators hold. Writes go only through the settings door. -- **`SettingsService`** writes a global-scope key to `sys_platform_setting` and reads the cascade's global rung from there alone. Its `sys_setting` reads exclude `scope = 'global'`, so there is one source per rung. The cascade order, the lock semantics, `SpecifierScope` and the `source: 'global'` resolution value are unchanged. A global-scope change's `config_change` audit row names `sys_platform_setting` (`CONFIG_CHANGE_GLOBAL_OBJECT_NAME`); tenant- and user-scope changes still name `sys_setting`. -- **`sys_setting.scope`** no longer declares the `global` option: no write produces such a row. `sys_setting_audit.scope` keeps it, because a global-scope change is still audited there. -- **`os secret orphans` / `os secret rewrap`**: the settings family of the `sys_secret` reference union now reads both `sys_setting.value_enc` and `sys_platform_setting.value_enc`. Without that, every credential held at the global rung would read as unreferenced and be swept. An unreadable `sys_platform_setting` gaps the family, which refuses deletion. - -**What moves for consumers.** - -- **Existing databases — nothing moves automatically** (ADR-0131 D14). A `sys_setting` row at `scope = 'global'` is no longer read; until the v18 upgrade ceremony moves it, that key answers from its next rung or the manifest default. The ceremony moves each such row to `sys_platform_setting` by namespace and key, its `value_enc` handle included. An encrypted value needs no re-encryption: the ciphertext's associated data binds the settings scope, namespace and key, never the holding object or an organization. -- **Authored references.** A filter, list-view column or seed that names `scope = 'global'` on `sys_setting` matches nothing: point it at `sys_platform_setting`, which has no `scope` column. -- **Generic data reads of the global values.** Read `sys_platform_setting`; it needs `manage_platform_settings`. The settings door (`/api/settings/:namespace`) is unchanged and keeps applying each manifest's own read and write capability. -- **Kernels that register the settings objects by hand.** Register `SysPlatformSetting` (`@objectstack/platform-objects/system`) beside `SysSetting`; `SettingsServicePlugin` already does. The service reads both on every resolution, so a kernel missing one fails the read loudly rather than answering a cascade with a rung missing. diff --git a/.changeset/pre/15211-ceremony-metadata-promotion.md b/.changeset/pre/15211-ceremony-metadata-promotion.md deleted file mode 100644 index 1e766e7e671..00000000000 --- a/.changeset/pre/15211-ceremony-metadata-promotion.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -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 -``` diff --git a/.changeset/pre/15211-cli-apply-reviewed-plan.md b/.changeset/pre/15211-cli-apply-reviewed-plan.md deleted file mode 100644 index 18d7bacff12..00000000000 --- a/.changeset/pre/15211-cli-apply-reviewed-plan.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/cli': minor -'@objectstack/spec': patch ---- - -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 -``` diff --git a/.changeset/pre/15212-180-guards-fresh-single-only.md b/.changeset/pre/15212-180-guards-fresh-single-only.md deleted file mode 100644 index 8d8b0664003..00000000000 --- a/.changeset/pre/15212-180-guards-fresh-single-only.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -feat(cli)!: 18.0 supports fresh `single` installs only — `os migrate organization-ownership` refuses, and `os serve` refuses a walled posture at boot - -Clause-②: yes (narrowing) - - - -**BREAKING** (a command and a boot posture that used to run now refuse), shipped as `minor` under the launch-window convention for breaking changes. 18.0 supports fresh installs under the `single` tenancy posture only; upgrading a v17 database and the walled postures arrive in an 18.x release. - -- **FROM** `os migrate organization-ownership` (plan) and `os migrate organization-ownership --apply …` running the v18 ownership ceremony **TO** both refusing with exit `1` and the standard code `NOT_IMPLEMENTED` (under `--json`: `{ error: 'plan_refused' | 'apply_refused', reason: 'upgrade-unavailable', code: 'NOT_IMPLEMENTED', detail }`), reading and writing nothing. The 18.0 ceremony is knowingly incomplete, and an incomplete ceremony must never certify an old database. **Fix:** keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path. `os dev`'s in-place upgrade of a v17 development database runs this command, so it now stops with the same refusal and changes nothing. -- **FROM** `os serve` (and so `os dev` and `os start`) booting with `OS_TENANCY_POSTURE=group` or `isolated`, or with `OS_MULTI_ORG_ENABLED=true`, **TO** a FATAL refusal at the tenancy-posture gate, exit `1`, naming `NOT_IMPLEMENTED` and the 18.x release, before a config is read. `OS_ALLOW_DEGRADED_TENANCY` does not lift it, and no flag or variable does. **Fix:** set `OS_TENANCY_POSTURE=single` (or unset it and `OS_MULTI_ORG_ENABLED`), or keep a walled deployment on a 17.x release. - -**What stays as it was.** A fresh install under `single` boots exactly as before. The boot guard that refuses a v17-shaped database is unchanged. Kernel-level boots (`@objectstack/verify`'s `bootStack`, embedded hosts) still accept every posture. diff --git a/.changeset/pre/15212-8a-one-predicate.md b/.changeset/pre/15212-8a-one-predicate.md deleted file mode 100644 index c6400f436b4..00000000000 --- a/.changeset/pre/15212-8a-one-predicate.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/core': minor -'@objectstack/spec': minor -'@objectstack/plugin-security': patch ---- - -feat(objectql,core): the driver's tenant read scope is the organization wall's verdict — one predicate for Layer 0 and every driver (ADR-0131 D8) - -Clause-②: yes (widening) - -- **What changed.** The engine no longer derives `DriverOptions.tenantId` / `tenantIds` beside the organization wall. `buildDriverOptions` projects the wall's own verdict onto them: the verdict plugin-security records on the operation (`OperationContext.tenantLayer0Verdict`) where its wall ran, and otherwise the same function over the engine's own facts (the posture in force, the context's organization and membership set). The function, `computeTenantLayer0Verdict` with its input type `TenantLayer0Input`, moves to `@objectstack/core` (new export); `@objectstack/plugin-security`'s `tenant-layer.ts` re-exports it and its Layer 0 is byte-identical. -- **FROM → TO, per posture, for a read, count, aggregate, update or delete on an object with an organization column:** - - | posture / caller | driver scope before | driver scope now | - |:--|:--|:--| - | `single`, any caller naming an organization | that organization (plus organization-less rows) | **none** | - | `isolated`, member | the active organization | unchanged | - | `group`, member | the membership set | unchanged | - | walled, exempt `PLATFORM_ADMIN` on a posture-permitting object, or the verified platform owner's read | the active organization | **none** — what Layer 0 already admitted | - | walled, system context naming an organization | that organization | unchanged | - -- **Not changed.** Inserts and upserts keep the active organization as `tenantId` in every posture: the row is stamped with it, its counter scopes by it, and an upsert that would merge into another organization's row is still refused. Both `orWhereNull` compatibility arms in `SqlDriver.applyTenantScope` stay (their retirement is a later stage of ADR-0131 D13). -- **Who notices.** A `single` deployment that holds more than one organization was kept apart by the driver only by accident; postures now differ only in enforcement (ADR-0131 D11), and `single` enforces no wall. To keep organizations apart, run the `isolated` or `group` posture. Code that relied on a context organization to narrow a read on `single` names the organization in its own `where`. -- **Migration.** ADR-0087 semantic entry `driver-read-scope-follows-tenant-wall`. A deployment's `platformGlobalObjects` carve-out already reaches the driver through the column plan (ADR-0131 D7): a declared object carries no organization column, so neither wall has anything to scope. diff --git a/.changeset/pre/15212-8b0h-share-link-stamped.md b/.changeset/pre/15212-8b0h-share-link-stamped.md deleted file mode 100644 index 49c07b8cab5..00000000000 --- a/.changeset/pre/15212-8b0h-share-link-stamped.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/plugin-sharing": patch ---- - -fix(plugin-sharing): a minted share link carries an organization, never a NULL one (ADR-0131 D9) - -Clause-②: no - -- **What changed.** `ShareLinkService.createLink` stamps `sys_share_link.organization_id`. It takes the organization of the linked record, read under the system context. When the record names none, it takes the acting context's organization (`tenantId`). When neither names one, it applies the system-write rule: under `single`, the Default Organization (the only one there is); under a wall (`group` or `isolated`), the mint is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500) and no link is written. The same refusal answers a `single` install that holds no organization or several. Before, every link was written with `organization_id` NULL. -- **A link on another organization's record.** Under `group`, a member active in one organization can mint on a visible record of another. The link carries the record's organization, not the session's. -- **What stays the same.** Under `single` nothing a user sees changes. The mint response keeps the `ShareLink` shape and carries no organization field. A composition that registers no `sys_organization` object writes links as before. -- **Who can meet the refusal.** No in-repo caller reaches it. Both share-link routes need a signed-in caller. Under a wall, an organization-less API key is refused at admission, and an organization-less session cannot see a record that has a tenant column, so its mint is refused before the stamp. The refusal is reached by a walled, organization-less caller minting on an object with no tenant column, or by a system caller that threads no `tenantId`. The fix is to pass the organization on the context (`{ isSystem: true, tenantId }`). diff --git a/.changeset/pre/15212-8c0-unattributable-rows-block.md b/.changeset/pre/15212-8c0-unattributable-rows-block.md deleted file mode 100644 index 9b08ebbe542..00000000000 --- a/.changeset/pre/15212-8c0-unattributable-rows-block.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/cli': minor -'@objectstack/plugin-sharing': minor ---- - -feat(cli,plugin-sharing)!: in the 17 → 18 ownership ceremony, a row whose owner cannot be derived blocks certification, and a declared-rule mirror's grants are renamed only onto a rule plugin-sharing serves - -Clause-②: yes (narrowing) - - - -**BREAKING** (narrowings), shipped as `minor` under the launch-window convention for breaking changes. - -- **FROM** (`@objectstack/cli`): `os migrate organization-ownership --apply` on a database holding a row whose owner cannot be derived exited `0` with `blocking: 0`, counted the row in `advisory`, and verified the `sys_migration` marker. This covers a reported (fate 4) row, and a row whose anchor is missing under a wall (fate 3). **TO:** it exits `1`, counts the row in `blocking`, and leaves the marker unverified, so a v18 `os serve` / `os start` does not start on the database. **Fix:** give each row its organization (write its `organization_id`, or repair the anchor its reason names), or delete it by hand, then re-run `--apply` with the same plan file. -- **FROM** (`@objectstack/cli`): a database an earlier version of this command certified while it held such a row was certified again by the next `--apply`. **TO:** that run leaves its marker unverified, for the same rows. **Fix:** the same as above. -- **FROM** (`@objectstack/cli`): `os dev`'s in-place upgrade of a development database started the server again whenever every ceremony step finished. **TO:** it starts it only when the post-check also holds nothing (`blocking` 0); otherwise it prints each table that holds, with its count and remedy, and the command that resumes the run. **Fix:** resolve the rows it names, then run the printed command. -- **FROM** (`@objectstack/cli`): the `mirror-deletion` step renamed the grants of a declared-rule mirror onto its rule's name, and deleted the mirror, even when plugin-sharing does not serve that rule (a condition that does not translate, an unmappable recipient, a retired owner-based rule); the next boot's orphaned-grant sweep then revoked those grants. **TO:** while `sys_record_share` grants name such a mirror, the step blocks, naming the rule, its rows and the reason, and writes nothing. **Fix:** correct the rule's declaration in its package so it is served, or delete each mirror row and the grants that name it, then re-run `--apply`. - -`@objectstack/cli` — **unattributable rows hold certification (ADR-0131 D10 fate 4).** The ceremony still never guesses an owner for such a row and never deletes it. The post-check now holds each one against the verdict: - -- Each table that holds one carries `held: { rows, remedy }` in the apply report, and counts those rows in its `remaining`. The terminal output names each table, its row count and the remedy. -- `advisory` now counts only the rows of an advisory departure and the reported rows of a table whose `organization_id` is not the tenancy anchor (`sys_sso_provider`, the retired `sys_scim_provider`). A row there that is bound to no organization is legitimate, is reported, and does not block. -- So a verified marker means no organization-less row on any table that the tenancy anchor governs. -- The plan's summary line says that such rows will hold `--apply`'s certification until they are resolved. - -`@objectstack/cli` — **the ceremony asks plugin-sharing which declared sharing rules it serves.** The port the `mirror-deletion` step reads (`DeclaredSharingRulesPort.rules()`, formerly `names()`) judges each declared rule through plugin-sharing's own translation. A mirror's grants are renamed only onto a rule that is served. A mirror of a refused rule that no grant names is still deleted. - -`@objectstack/plugin-sharing` — **exports `translateDeclaredSharingRule`** with its `DeclaredSharingRule` and `DeclaredSharingRuleSkip` types. It is the one translation enforcement serves a declared rule through. It is pure, and it is unchanged. diff --git a/.changeset/pre/15213-install-modes.md b/.changeset/pre/15213-install-modes.md deleted file mode 100644 index 22f42630d89..00000000000 --- a/.changeset/pre/15213-install-modes.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/cloud-connection': minor -'@objectstack/cli': minor -'@objectstack/metadata-protocol': minor ---- - -A package declares how it may be installed — `installModes: ['managed', 'unmanaged']` — and `os package install --install-mode unmanaged` copies it once into the environment as the environment's own metadata (ADR-0131 D6) - -Clause-②: yes (widening: the new optional manifest key `installModes`; the new exports `INSTALL_MODES`, `InstallModeSchema`, `InstallMode`, `DEFAULT_INSTALL_MODES`, `resolveInstallModes`, `UNMANAGED_INSTALL_KEY_DISPOSITIONS`, `UNMANAGED_INSTALL_REFUSING_DISPOSITIONS`, `UnmanagedInstallKeyDisposition`, `unmanagedInstallValueCarries` and `unmanagedInstallPostureRefusal`; the install-local body field `installMode`; the CLI option `--install-mode`; the error codes `STACK_UNMANAGED_INSTALL_UNCOPYABLE` and `INSTALL_MODE_NOT_PERMITTED`) - -A package installs one of two ways. **Managed**: it is registered as code, stays the authority, upgrades, and nothing it ships is edited in place. **Unmanaged**: its metadata is copied once into this environment under a new writable base; the copy's items are this environment's own metadata, the next save edits them, and it follows no upgrade. This entry adds the manifest key that declares the modes a package permits, and the install door that honours it; the copy itself is the companion entry `15213-unmanaged-import-verb`. - -- **`installModes`** (`@objectstack/spec`, `ManifestSchema`). Optional, a non-empty list of `'managed'` / `'unmanaged'`. Absent means `['managed']`, read through one resolver, `resolveInstallModes(manifest)`; there is no parse-time default, so no existing manifest changes by a byte. A package may permit `unmanaged` alone. -- **Refused at authoring.** `defineStack` — so `os validate` and `os build` — refuses a package that permits `unmanaged` while it carries what the copy cannot write as environment metadata: a non-empty collection of a type the metadata-type registry declares runtime-uncreatable (`agents`, `apis`, `capabilities`, `jobs`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), or an extension of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`). The refusal is `STACK_UNMANAGED_INSTALL_UNCOPYABLE` (422), one `issues` entry per key. Fix: remove `'unmanaged'` from `manifest.installModes`, or move what cannot be copied out of the package. The install door judges the same body with the same key classification (`UNMANAGED_INSTALL_KEY_DISPOSITIONS`), so a package that passes `os validate` is not refused at install for a key nobody named. -- **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local`). The body takes `installMode: 'managed' | 'unmanaged'`; absent means `managed`, so every existing caller is unchanged. Before anything is written, on the catalog and the air-gapped source alike: - - a mode the package's `installModes` does not permit answers `422 INSTALL_MODE_NOT_PERMITTED`, with `details.permitted`; - - `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value) answers `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture; - - a managed install of a package whose namespace a writable base holds — its unmanaged copy — answers `409 RESOURCE_CONFLICT`, naming that base: a managed install would otherwise take the namespace and stop serving the edited copies. - An unmanaged install writes no install-ledger entry, registers nothing under the package's id and does not load its sample data (the answer says so under `seeded`). The answer names the new base, the items copied and `installedFrom` (the source package id and version, for information only). The package's translation bundles are copied as `translation` items, so they reload at every boot with the environment's own translations. -- **`os package install --install-mode managed|unmanaged`** (`@objectstack/cli`). Without the option, nothing is sent and the runtime installs managed. An unmanaged answer prints the base, the item count and the source. A runtime that predates the install mode ignores the option and installs managed; the command then exits 1 and says so. The command's help now calls the catalog and the air-gapped artifact the two *sources*, so "mode" means one thing. -- **Copy semantics** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` copies `translations` (each locale of each bundle as one `translation` item named `_`, bundles merged in order), reads `installModes` without writing it onto the base, and no longer refuses an empty code container (`functions: {}`, `plugins: []`). `unmanagedInstallPostureRefusal` is exported, so the door and the import answer the walled posture with one sentence. - -Nothing existing is removed: every new key, field and option is optional, and an install that does not name a mode is managed, as before, with one stricter answer. That answer is the refusal above: a managed install of a package whose namespace a writable base holds now answers `409 RESOURCE_CONFLICT` before anything is written, where a catalog install of it answered `200`. diff --git a/.changeset/pre/15213-readonly-package-remedy.md b/.changeset/pre/15213-readonly-package-remedy.md deleted file mode 100644 index 9ae7f70902e..00000000000 --- a/.changeset/pre/15213-readonly-package-remedy.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -The `/packages` refusals of a read-only package name the customisation that works today, instead of the retired organization overlay (ADR-0131 D6) - -Clause-②: no - -`DELETE`, `PATCH /disable`, `POST /publish` and `POST /revert` on a package provided by code or an installed app answer `422 WRITABLE_PACKAGE_REQUIRED`, and `POST /packages/:id/duplicate` on one answers `422 DUPLICATE_SOURCE_NOT_A_BASE`. Both refusals told the caller to customise the package "in place, with an ADR-0005 org overlay". ADR-0131 D6 retired the per-organization overlay axis: no metadata write lands organization-scoped any more, and the protocol refuses one that names an organization with `403 NOT_OVERRIDABLE`. The remedy named a mechanism that no longer exists. - -Both refusals now name the customisation that works: customise what the package provides beside it, as environment-level state. Saving an item under its own name overlays it where the item's type takes an environment overlay and the package's lock does not refuse it. Any other save is refused, and that refusal names the route the item's type has, or the lock. The duplicate refusal also names the editable copy of a whole package: install it unmanaged instead (`installMode: 'unmanaged'`), where its manifest's `installModes` permits that. - -Only the message text changes. Each refusal's `code`, status, `details`, condition and order are unchanged. diff --git a/.changeset/pre/15213-unmanaged-import-verb.md b/.changeset/pre/15213-unmanaged-import-verb.md deleted file mode 100644 index d9ca408b247..00000000000 --- a/.changeset/pre/15213-unmanaged-import-verb.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/spec': minor ---- - -`importUnmanagedPackage` copies one package into the environment ledger, editable from then on — the import behind the `unmanaged` install mode (ADR-0131 D6) - -Clause-②: yes (widening) - -A package installs one of two ways. Managed: the package is the authority, it upgrades, and nothing it ships may be edited. Unmanaged: every metadata item is copied once into this environment, is fully editable, and never follows an upgrade. This release adds the import; the install door calls it for an unmanaged install, and the manifest key `installModes` declares the modes a package permits. - -- **The verb** (`@objectstack/metadata-protocol`). `ObjectStackProtocolImplementation.importUnmanagedPackage({ manifest, targetPackageId?, targetName? })` writes every metadata item of ONE package body into a NEW writable base through the metadata save door: each row is owned by the base, carries no organization, is served as environment-authored (`_provenance: 'org'`), and the next save edits it. The base's id differs from the source (`targetPackageId`, defaulting to `-copy`), it keeps the source namespace so every object name stays valid, and nothing is registered under the source id. The answer names the source, the base, its namespace and every item copied, in write order. -- **All or nothing.** Every refusal that can be judged is raised before the base exists, so a refused import writes nothing: a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value); a body carrying what cannot be copied; a malformed name or an unusable target id; a protocol-incompatible package; a target id that names an installed package, or a namespace another package owns (a managed install of the same package included); an item that does not convert to this protocol; a name an environment row or a managed package already holds. A write that fails afterwards deletes the base — its rows and its tables — and the failure is thrown with the item's own code and status, naming the item and what the rollback did. -- **What is copied, and what is refused.** The items of every manifest collection and the assembled `viewItems` are copied. A body carrying a runtime-uncreatable type (`jobs`, `agents`, `apis`, `capabilities`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), extensions of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`) or an artifact envelope (`packages`) is refused whole; `OS_METADATA_WRITABLE` does not open a type for it. The package's `translations` are copied too, each locale of each bundle as one `translation` item, and an unmanaged install loads no seed `data`; deployment configuration (`i18n`, `requires`, `datasourceMapping`, …) is not part of a package install in either mode. -- **Two new error codes** (`@objectstack/spec`, registered to `@objectstack/metadata-protocol`). `UNMANAGED_INSTALL_WALLED_POSTURE` (409) and `UNMANAGED_INSTALL_UNCOPYABLE` (422). Both mean the package can be installed managed instead. A collision, an existing target or an owned namespace answers the standard `RESOURCE_CONFLICT` (409). -- **Two new schemas** (`@objectstack/spec/api`). `ImportUnmanagedPackageRequestSchema` is parsed by the verb, so a request key it does not declare is refused with `400`. `ImportUnmanagedPackageResponseSchema` declares the answer. - -Nothing existing changes: no schema, door or code is removed or narrowed. diff --git a/.changeset/pre/15213-unmanaged-lock-strip.md b/.changeset/pre/15213-unmanaged-lock-strip.md deleted file mode 100644 index c01f20d7eec..00000000000 --- a/.changeset/pre/15213-unmanaged-lock-strip.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-protocol': minor -'@objectstack/cloud-connection': minor -'@objectstack/cli': minor ---- - -An unmanaged install no longer copies a package's protection lock, in either spelling: every copied item is editable, and the install's answer names each lock it dropped with the package's reason (ADR-0131 D6, ADR-0010) - -Clause-②: yes (widening: the new response field `strippedLocks` on `ImportUnmanagedPackageResponseSchema`, relayed on the install-local answer for `installMode: 'unmanaged'`) - -An unmanaged install copies a package into this environment as the environment's own metadata. A package can lock an item in two spellings: the `_lock` envelope (`_lock`, with `_lockReason`, `_lockSource` and `_lockDocsUrl`), or the author-facing `protection: { lock, reason, docsUrl }` block that a managed install translates into it. Until now an item locked with `_lock` was copied with that lock, so the next save of the copy answered `403 ITEM_LOCKED`; an item locked with `protection` was copied with an inert block that bound nothing, so the vendor's lock vanished without a word. The package permitted the unmanaged install through its `installModes`, so it chose to let environments take editable copies; a lock whose source is the package does not survive the copy. - -- **The copy** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` writes every item without `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl`, and, on a type whose schema declares one, without its `protection` block. Its answer gains `strippedLocks`: one entry per lock that bound (`no-overlay`, `no-delete` or `full`), in the order the items were written — `{ type, name, lock, reason? }`, with `reason` the package's reason verbatim and absent when it gave none. Each entry is the lock a managed install of the same item would have bound: an item carrying both spellings is reported once, with the `protection` block's lock and reason, because that is the one a managed install binds. A package that locks nothing answers `strippedLocks: []`. -- **Refusals are unchanged.** A lock the spec's declarations refuse (an unknown `_lock` value, a reason that is not a string, a malformed `protection` block) is not stripped: the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back. So is a `protection` block on a type whose schema does not declare one (`action`, `hook`, `page`). A managed install is unchanged: the package stays the authority, and its locks bind. -- **The declaration** (`@objectstack/spec`). `ImportUnmanagedPackageResponseSchema` declares `strippedLocks`; `lock` takes the three binding values of `MetadataLockSchema`. -- **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local` with `installMode: 'unmanaged'`). The answer carries the import's `strippedLocks` as the import states it. -- **`os package install --install-mode unmanaged`** (`@objectstack/cli`). Each dropped lock prints as one line under `Locks removed`, spelled `.: — `, so the installer reads why the vendor locked an item before editing it. An answer from a runtime that states no list prints no lock line. - -Nothing is removed or narrowed: the answer gains a field, and every refusal of the unmanaged install is unchanged. diff --git a/.changeset/pre/15952-blank-template-test-story.md b/.changeset/pre/15952-blank-template-test-story.md deleted file mode 100644 index e2a50fac742..00000000000 --- a/.changeset/pre/15952-blank-template-test-story.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'create-objectstack': minor ---- - -The blank starter ships a test story: `pnpm test` boots the scaffolded app's own stack with `bootStack` from `@objectstack/verify` and asserts one business fact through its in-process handle - -Clause-②: no - -A project scaffolded with `npx create-objectstack` used to start with no `test` script and no way to assert its own behaviour, so the first test an author wrote was a hand-assembled kernel or a faked `ctx`. The blank template now carries: - -- **`note.test.ts`**, one starter test. It boots the app the project declares (its objects, its plugins, the services it requires) on the real engine, in-process, with an in-memory database and no server, and asserts that a note without a title is refused with the engine's own `VALIDATION_FAILED` error naming `title`, beside the same note with a title being saved. It addresses the object as `Note.name`, so it follows the namespace scaffolding writes. -- **`vitest.config.ts`**: a 60 s hook timeout for the boot, vitest's console capture switched off, so a log line the stack writes while a worker shuts down cannot fail a green run, and `OS_REGISTRY_LOG: 'warn'`, which drops the engine's one-line-per-registered-item boot log. -- **`package.json`**: a `test` script (`vitest run`), and `@objectstack/verify` (at the project's `@objectstack/*` range) and `vitest` as devDependencies. The vitest range is `~4.0.0`, the one `objectstack init -t plugin` and `objectstack create plugin` declare, and it is generated from that shared policy rather than written by hand, so the three scaffolders cannot drift apart. `~4.0.0` is the range that installs on npm 10, the npm Node 22 bundles; `^4.0.0` does not. -- **`tsconfig.json`** includes every root `*.ts` (`"*.ts"`, as `objectstack init` writes it), so `pnpm typecheck` checks the starter test and the vitest config. -- **`README.md`** lists `pnpm test` beside `validate`, `lint` and `typecheck`. - -The project's CI workflow is unchanged: its steps are the script set every scaffolder's project must declare, and `objectstack init -t app` declares no `test` script. diff --git a/.changeset/pre/15953-s8-cli-dashboard-windows-flag.md b/.changeset/pre/15953-s8-cli-dashboard-windows-flag.md deleted file mode 100644 index 28c4b97b22b..00000000000 --- a/.changeset/pre/15953-s8-cli-dashboard-windows-flag.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os verify --dashboard-windows` describes the measures it proves: counts, sums and derived measures - -Clause-②: no (the flag, its default and what it accepts are unchanged; only its help text moves) - -The `--dashboard-windows` flag description ended "(date and datetime fields; count measures)". The date-window family in `@objectstack/verify` now also proves sums and derived measures over counts or sums, so it reads FROM "(date and datetime fields; count measures)" TO "(date and datetime fields; count, sum and derived measures)". Nothing else about the flag changes. diff --git a/.changeset/pre/15953-s8-window-sum-derived.md b/.changeset/pre/15953-s8-window-sum-derived.md deleted file mode 100644 index 233853c6e96..00000000000 --- a/.changeset/pre/15953-s8-window-sum-derived.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/verify': patch ---- - -fix(verify): the dashboard date-window family also proves sums and derived measures, and says why an average stays out - -Clause-②: no (no export, option or type moves: the family reads the measures it already read, and a widget it used to exclude can now be proven or contradicted) - -`runDashboardWindowProofs` (and `os verify --dashboard-windows`) now judges every verdict in fixture rows, so it compares every measure whose difference can be read back as rows: - -- **A `sum`** is probed. Each fixture row holds the summed field to the value the CRUD derivation synthesizes for it, or to the value the widget's own selection already holds there. The sum's difference over that value is the rows the window took in. FROM: a sum-only widget was `excluded` (`no-count-measure`), and a sum beside a count was an `excluded` measure (`not-a-count`). TO: `proven` or `contradicted` like a count. A sum of a relationship path, a summed field with no synthesizable value, and a held value that is not a non-zero number are `excluded` as `count-field-unsynthesizable`, and the reason names which. -- **A `derived` measure** (`ratio`, `sum`, `difference`, `product`) is probed through its operands, which the widget's own answer carries beside it. Each operand must be a count or a sum of the same dataset, and the fixture rows meet every operand's filter. Under each preset every operand must take in exactly the rows the window selects. The value the widget shows must equal `evaluateDerivedMeasures` (the server's own evaluator, from `@objectstack/service-analytics`) over that row's operands, or the preset is `contradicted` and the detail names both values. A derived measure over a derived one, or over a measure that is not a count or a sum, stays `excluded` (`not-a-count`), and the reason names the operand. -- **An `avg`, `min`, `max` or `count_distinct`** stays `excluded` (`not-a-count`, or `no-count-measure` for a widget with nothing else), with a reason that says why. What a fixture row does to it depends on rows the app already holds in the window: their number for an average, their extreme for a minimum or maximum, their values for a distinct count. Nothing the widget answers reveals that, so deriving it would mean guessing the data. The reason text changes: FROM `"avg_effort" aggregates avg of effort, not a row count` TO `"avg_effort" averages effort: an average is not additive — …`. -- The widget-level `no-count-measure` reason reads `no measure the widget selects can be compared (…)`. The `excludedKind` values are unchanged. -- **A new `not-run` cause.** The family gives up on a preset when a sum's own total is so large that its floating-point difference cannot resolve one fixture row. That needs 2^34 times the held value, and the detail says so. -- **Upgrading:** on an app whose dashboards bind sums or derived measures, `os verify --dashboard-windows` now probes those widgets. A window that does not reach them now fails the run (`hardFailures`), where before they were only counted as `excluded`. diff --git a/.changeset/pre/15953-verify-dashboard-window-family.md b/.changeset/pre/15953-verify-dashboard-window-family.md deleted file mode 100644 index 770b9acb993..00000000000 --- a/.changeset/pre/15953-verify-dashboard-window-family.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/verify': minor -'@objectstack/cli': minor ---- - -feat(verify): the dashboard date-window family — every widget a dashboard's `dateRange` binds counts, under each preset of the picker, the rows that window selects, runnable as `os verify --dashboard-windows` (#15953) - -Clause-②: yes - -A seventh derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs`, `runDeleteBehaviorProofs`, `runUniqueScopeProofs` and `runParentWriteReachProofs`. It needs no hand-written test: from the app's own dashboards and datasets, read through the spec's own parse (`DashboardSchema`, `DatasetSchema`), it takes every dashboard that declares a `dateRange` and every widget that window binds, and asks the dataset door what each one counts. - -Where each half of the promise is read from: - -- the field a widget's window lands on is `DashboardWidgetSchema.filterBindings`' contract: `filterBindings.dateRange` names it, `false` opts the widget out (reported `unbound`), and absent means the dashboard's `dateRange.field` (`created_at` when unauthored). It resolves against the object behind the widget's dataset, as the stack registers it, and must be a `date` or `datetime` field; -- a preset's window is `DATE_RANGE_PRESET_MACRO_WINDOWS`, the `{date-macro}` pair each of the 13 `DATE_RANGE_PRESETS` names. It is handed to `POST /analytics/dataset/query` as written (`{ $gte: '{7_days_ago}', $lte: '{today}' }`, `{ $gte: '{month_start}', $lte: '{month_end}' }`), ANDed with the widget's own filter as `runtimeFilter`, and the server resolves the tokens; -- the days a window selects, for the ground truth, are that same window resolved by `@objectstack/core`'s `resolveFilterTokens` on the caller's reference timezone (UTC unless the `ExecutionContext` carries one), first and last day inclusive. - -Per bound widget and count measure, fixture rows are written through the seed door on every preset's first and last day and the day either side (never after today), each stamped at UTC noon (a `date` field takes the day itself), owned by the caller, with every column the widget's filter, the dataset's filter and the measure's filter compare held to a value they select, and read back before anything is judged. The widget is asked as the signed-in caller with no window and under every preset, before and after the fixtures, so each count is a difference and the app's own rows cancel out. With no window it must count every fixture row before any window is judged. Under each preset it must count exactly the fixture rows that window selects: a widget that shows nothing there is `blank`, one that counts another number is contradicted, and one whose every preset answers what no window answers is named inert. - -A widget is `proven`, `contradicted` (a preset went blank or counted other rows), `failed` (the door refused or faulted on a windowed query while the widget's own query answered), `inconclusive` (a fixture could not be written or did not read back as written, the widget with no window did not count every fixture row, its own query is refused, the day turned mid-probe, or the caller's timezone is not UTC), `unbound` or `excluded` (`excludedKind` names why: no count measure, a filter the ground truth cannot model, a window on a missing or non-temporal field, an undeclared or unparsed dataset or dashboard, an object the app does not declare or the CRUD derivation cannot synthesize). `contradicted + failed` widgets are the report's `hardFailures`. An average, a sum or a derived measure is counted as excluded; a held column is an equality, an `$in`, a `$nin` or `$ne` on a field that declares its options, or `{current_user_id}`. - -**`@objectstack/verify` — new exports.** - -- `runDashboardWindowProofs(stack, token, config, { now? })` → `DashboardWindowReport`. Run it on a fresh stack of its own, as a principal that reads the dashboards' objects: it writes fixture rows. -- `deriveDashboardWindowCases(config)`, `formatDashboardWindowReport(report)`. -- Types: `DashboardWindowExclusion`, `DashboardWindowMeasureCase`, `DashboardWindowCase`, `DashboardWindowDashboard`, `DashboardWindowPreset`, `DashboardWindowAnswer`, `DashboardWindowPresetProbe`, `DashboardWindowMeasureResult`, `DashboardWindowWidgetStatus`, `DashboardWindowWidgetResult`, `DashboardWindowReport`, `DashboardWindowProofOptions`. - -```ts -import { bootStack, runDashboardWindowProofs, formatDashboardWindowReport } from '@objectstack/verify'; - -const stack = await bootStack(myApp); -const report = await runDashboardWindowProofs(stack, await stack.signIn(), myApp); -console.log(formatDashboardWindowReport(report)); // report.presets names what each preset handed the door -expect(report.summary.hardFailures).toBe(0); -await stack.stop(); -``` - -**`@objectstack/cli` — `os verify --dashboard-windows`.** Opt-in, like `--unique-scopes` and `--parent-write-reach`, on a fresh boot of its own, as the signed-in admin. A widget that went blank or counted other rows than its window selects, or a windowed query the door refused or faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `dashboardWindows` when the flag is given; without the flag the run and its document are unchanged. diff --git a/.changeset/pre/15953-verify-dashboard-window-now-removed.md b/.changeset/pre/15953-verify-dashboard-window-now-removed.md deleted file mode 100644 index 625aa1e5cb7..00000000000 --- a/.changeset/pre/15953-verify-dashboard-window-now-removed.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/verify': minor ---- - -fix(verify)!: `runDashboardWindowProofs` takes no options, so the dashboard date-window run can no longer be pinned to an instant the server does not resolve on, and a run that crosses UTC midnight after it starts reports `not-run` instead of a false red - -`runDashboardWindowProofs(stack, token, config)` loses its fourth parameter, and `DashboardWindowProofOptions` (its only member was `now`) is retired. The server resolves a dashboard's date preset per request on its own clock, so any `now` other than the current instant made every window read `contradicted`: the option could only produce false reds, and nothing passed it. - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -- FROM `DashboardWindowProofOptions` TO nothing (no replacement): drop the fourth argument, so `runDashboardWindowProofs(stack, token, config, { now })` becomes `runDashboardWindowProofs(stack, token, config)`. - -Fix: the presets resolve when the run starts, and each count measure places its fixture rows on the day it is probed. A run that crossed UTC midnight between the two judged that measure against the previous day's edges and could read it `contradicted`. That measure is now `not-run`, and its detail names both days; the widget reads `inconclusive`. The guard that catches a day turning while a measure is being probed is unchanged. diff --git a/.changeset/pre/15953-verify-delete-behavior-family.md b/.changeset/pre/15953-verify-delete-behavior-family.md deleted file mode 100644 index b097c324b13..00000000000 --- a/.changeset/pre/15953-verify-delete-behavior-family.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/verify': minor -'@objectstack/cli': minor ---- - -feat(verify): the delete-behaviour family — every `deleteBehavior` a reference field declares is what a delete of the referenced record does, runnable as `os verify --delete-behaviors` (#15953) - -Clause-②: yes - -A fourth derived proof family beside `runCrudVerification`, `runRlsProofs` and `runReferenceShapeProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`) and reads what the declaration states through the spec's own parse of the field (`FieldSchema`), which is the value a built artifact carries: a `lookup` or `tree` that authors nothing states `set_null`, an authored value is kept, and a bare `master_detail` states none. For each field it creates a fresh target record and a holder referencing it, deletes the target by id, and reads back what the engine left, through two doors: the REST data route as the signed-in caller, and the engine's own delete as the system principal. - -- `cascade` holds when the holder is gone with the target. -- `set_null` holds when the holder survives and no longer references the deleted record. -- `restrict` holds when the delete is refused with `DELETE_RESTRICTED` naming the holder's object, and both records are still there. -- In every case a second holder, referencing another record of the same target object, must be untouched. - -A refusal for any other reason (a hook's veto, a validation rule, a permission) is `unattributed`, so an engine that refused every delete would not read as a proven `restrict`. A field is `proven`, `contradicted` (a delete did something else), `failed` (a door faulted), `inconclusive` (an unattributed refusal, or a fixture the app refused; the report says why), `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. - -Two shapes are `excluded` rather than given an expectation, because an engine rule decides what a delete does to them, not the declaration: a `set_null` on a required reference (it cannot be cleared), and a field whose parse carries no `deleteBehavior` (a bare `master_detail`, a `user`). A field is `not-derivable` when no fixture can be built from the app's metadata: its target is not an object the app declares (such as `sys_user`), it is `readonly` or `system`, or the CRUD derivation cannot synthesize its holder or target. Only by-id deletes are asked; a predicate or multi-row delete is outside the family. - -**`@objectstack/verify` — new exports.** - -- `runDeleteBehaviorProofs(stack, token, config, { doors? })` → `DeleteBehaviorReport`. Run it on a fresh stack of its own: every `restrict` probe leaves its records behind. -- `deriveDeleteBehaviorCases(config)`, `formatDeleteBehaviorReport(report)`. -- Types: `DeclaredDeleteBehavior`, `DeleteBehaviorCase`, `DeleteBehaviorDoor`, `DeleteBehaviorAnswer`, `DeleteBehaviorProbeResult`, `DeleteBehaviorFieldStatus`, `DeleteBehaviorFieldResult`, `DeleteBehaviorReport`, `DeleteBehaviorProofOptions`. - -```ts -import { bootStack, runDeleteBehaviorProofs, formatDeleteBehaviorReport } from '@objectstack/verify'; - -const stack = await bootStack(myApp); -const report = await runDeleteBehaviorProofs(stack, await stack.signIn(), myApp); -console.log(formatDeleteBehaviorReport(report)); -expect(report.summary.hardFailures).toBe(0); -await stack.stop(); -``` - -**`@objectstack/cli` — `os verify --delete-behaviors`.** Opt-in, like `--reference-shapes`, on a fresh boot of its own. A contradicted declaration, or a door that faulted, counts in `hardFailures` and fails the run. A `tree` field's delete is not honoured today (deleting the parent leaves the child's reference in place), so an app that declares one fails the run with the flag. The `--json` document carries the report as `deleteBehaviors` when the flag is given; without the flag the run and its document are unchanged. diff --git a/.changeset/pre/15953-verify-parent-write-reach-family.md b/.changeset/pre/15953-verify-parent-write-reach-family.md deleted file mode 100644 index c0ca2a2f98f..00000000000 --- a/.changeset/pre/15953-verify-parent-write-reach-family.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/verify': minor -'@objectstack/cli': minor ---- - -feat(verify): the parent-derived write reach family — a write on a `controlled_by_parent` detail is admitted exactly when the caller holds that verb on the detail and can edit its master, runnable as `os verify --parent-write-reach` (#15953) - -Clause-②: yes - -A sixth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs`, `runDeleteBehaviorProofs` and `runUniqueScopeProofs`. It needs no hand-written test: from the app's own objects it takes every object the spec's own parse (`ObjectSchema`) declares `sharingModel: 'controlled_by_parent'` with exactly one `master_detail` field, walks the chain of masters above it to the first one that governs its own rows, and asks the engine what a detail write meets as a signed-in caller: the insert, update and delete by id the REST data route makes, with the caller's context, where a refusal keeps the object and the operation it names (the REST envelope names the route's object whatever was refused). The system door bypasses authorization and is not asked. - -The promise is ADR-0055 §3 and its chain amendment: editing, deleting or creating a detail requires edit access to its master, and the detail's own object permission is the first door. So each write is expected admitted exactly when both halves hold: - -- the caller holds the verb on the detail's object — read from the server's effective permissions for the caller (`GET /auth/me/permissions`), folded by `objectPermissionGrants`; -- the caller can edit the master — measured, never re-derived: an empty update of the master as the caller, which runs every write gate the master has, the gate above a master that is itself controlled by its parent included. - -The writes are made by three verifier personas whose permission sets the family authors from the app's own objects — the root master by the all-records grant (`modifyAllRecords`) with every verb on the detail; the root master readable but not editable with every verb on the detail; the all-records grant with no delete on the detail — and by one persona per position the app declares, whose capability is what the app binds to the position. Each writes under a master chain another principal owns (so the all-records grant, not ownership, is exercised) and one it owns, on a detail another principal owns and one it owns. A refusal holds only as the platform's `403 PERMISSION_DENIED` naming the detail and the operation; any other refusal (a rule, a hook's own code, a refusal on another object) is `unattributed`. An object is `proven`, `contradicted` (a write met the other answer), `failed` (a door faulted), `inconclusive`, `excluded` (no one master: none, two, an undeclared one, a cycle, or a chain at the write gate's depth bound) or `not-derivable`. `contradicted + failed` objects, plus personas that could not be provisioned, are the report's `hardFailures`. - -A detail two levels below its root master (a line under a quote under an account) is answered the same way: its write is admitted exactly when the caller holds the verb on the detail and may update the middle master directly, which also needs edit access to the root, and refused otherwise, so the family reports such a chain `proven`. - -**`@objectstack/verify` — new exports.** - -- `runParentWriteReachProofs(stack, config)` → `ParentWriteReachReport`. Run it on a fresh stack of its own, booted with `parentWriteReachSecurity(config)`: it signs up personas and writes, deletes and creates details. On a stack booted without it the verifier personas are reported not run, with the reason, and counted. -- `parentWriteReachSecurity(config)` → the `SecurityPlugin` that registers the verifier personas' permission sets beside the app's declared default profile. -- `deriveParentWriteReachCases(config)`, `formatParentWriteReachReport(report)`. -- Types: `ParentWriteReachHop`, `ParentWriteReachCase`, `ParentWriteReachVerb`, `ParentWriteReachOwner`, `ParentWriteReachAnswer`, `ParentWriteReachProbeResult`, `ParentWriteReachReach`, `ParentWriteReachObjectStatus`, `ParentWriteReachObjectResult`, `ParentWriteReachPersona`, `ParentWriteReachReport`. - -```ts -import { bootStack, runParentWriteReachProofs, formatParentWriteReachReport, parentWriteReachSecurity } from '@objectstack/verify'; - -const stack = await bootStack(myApp, { security: parentWriteReachSecurity(myApp) }); -const report = await runParentWriteReachProofs(stack, myApp); -console.log(formatParentWriteReachReport(report)); // report.personas names who wrote -expect(report.summary.hardFailures).toBe(0); -await stack.stop(); -``` - -**`@objectstack/cli` — `os verify --parent-write-reach`.** Opt-in, like `--delete-behaviors` and `--unique-scopes`, on a fresh boot of its own with the verifier personas' sets registered. A write whose answer disagrees, a door that faulted, or a persona that could not be provisioned counts in `hardFailures` and fails the run. The `--json` document carries the report as `parentWriteReach` when the flag is given; without the flag the run and its document are unchanged. - -**The unique-scope family — one change and one correction.** `runUniqueScopeProofs` is new in this same release, and two sentences of its own entry in these notes no longer hold: - -- **Attribution changed.** A unique refusal that names no field is now `unattributed` where a unique `indexes[]` entry covers the probed column. That entry refuses the same second holder, naming no field either, and admits the twin, so there the refusal may be the entry's rather than the field's own declaration. This is a change to the family's attribution rule: a field-less refusal is attributed to the field only when its twin with only the probed value changed is admitted AND no unique `indexes[]` entry covers the field. -- **How the second organization is provisioned, corrected.** The family provisions it by `signUp`, then `POST /auth/organization/create`, not by a sign-up that creates its own organization as that entry's parenthetical says. diff --git a/.changeset/pre/15953-verify-reference-shape-family.md b/.changeset/pre/15953-verify-reference-shape-family.md deleted file mode 100644 index e9b40351ade..00000000000 --- a/.changeset/pre/15953-verify-reference-shape-family.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/verify': minor -'@objectstack/cli': minor ---- - -feat(verify): the reference-value shape family — every client-writable reference field refuses a value that is not a record id (ADR-0104 D1), runnable as `os verify --reference-shapes` (#15953) - -Clause-②: yes - -A third derived proof family beside `runCrudVerification` and `runRlsProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`), reads what the declaration promises from `valueSchemaFor(field, 'stored')` (a record-id string, or an array of them when the field is multi-valued), and asks the engine whether three wrong-shaped values are refused: a boolean, a number, and an embedded record object (the expanded read shape written back). A multi-valued field gets each one as an array element. Every probe is written on insert and on update, through two doors: the REST data routes as the signed-in caller, and the engine's own write as the system principal. - -A probe counts as refused only when the engine names the probed field in its refusal. A refusal that names something else is reported `unattributed`, so an engine that refused every write would not read as proven. A field is `proven` (every probe refused), `holes` (a probe was admitted), `failed` (a door faulted), `inconclusive` (a probe could not be attributed or written; the report says why) or `excluded` (declared `readonly` or `system`, which the caller does not write). `holes + failed` is the report's `hardFailures`. - -**`@objectstack/verify` — new exports.** - -- `runReferenceShapeProofs(stack, token, config, { doors? })` → `ReferenceShapeReport`. Run it on a fresh stack of its own: a door that admits a probe lands a row. -- `deriveReferenceShapeCases(config)`, `referenceShapeProbeValue(kind, multiple, id)`, `formatReferenceShapeReport(report)`, `REFERENCE_SHAPE_PROBE_ID`. -- Types: `ReferenceShapeCase`, `ReferenceShapeDoor`, `ReferenceShapeOperation`, `ReferenceShapeProbeKind`, `ReferenceShapeProbeResult`, `ReferenceShapeFieldStatus`, `ReferenceShapeFieldResult`, `ReferenceShapeReport`, `ReferenceShapeProofOptions`. - -```ts -import { bootStack, runReferenceShapeProofs, formatReferenceShapeReport } from '@objectstack/verify'; - -const stack = await bootStack(myApp); -const report = await runReferenceShapeProofs(stack, await stack.signIn(), myApp); -console.log(formatReferenceShapeReport(report)); -expect(report.summary.hardFailures).toBe(0); -await stack.stop(); -``` - -**`@objectstack/cli` — `os verify --reference-shapes`.** Opt-in, like `--rls`, on a fresh boot of its own. It is opt-in because the engine still admits wrong-shaped elements on a `multiple: true` `lookup` or `user` field (#22908), so an app that declares one would fail the run until the engine refuses them. A field with holes, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `referenceShapes` when the flag is given; without the flag the run and its document are unchanged. diff --git a/.changeset/pre/15953-verify-unique-scope-family.md b/.changeset/pre/15953-verify-unique-scope-family.md deleted file mode 100644 index b1c6a6f48bd..00000000000 --- a/.changeset/pre/15953-verify-unique-scope-family.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/verify': minor -'@objectstack/cli': minor ---- - -feat(verify): the unique-scope family — every field declared `unique` collides as its declared scope says, runnable as `os verify --unique-scopes` (#15953) - -Clause-②: yes - -A fifth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs` and `runDeleteBehaviorProofs`. It needs no hand-written test: from the app's own objects it takes every field whose `unique` the spec's own parse of the field (`FieldSchema`) declares — the value a built artifact carries, so an `autonumber` that authors no `unique` states `'organization'` — and asks the engine what a second holder of one value meets, through two doors: the REST data route as a signed-in caller, and the engine's own write as the system principal. - -- **Within one organization** — by insert and by update — the second holder must be refused, whatever the scope. On a `single`-posture boot that organization is the install's one: the untenanted install, where every write lands in it. -- **Across two organizations** — on an org-scoped boot only — the family provisions a second organization the way a user makes one (a sign-up that creates its own) and offers it the value. A `true` or `'organization'` field must admit it; a `'global'` field, or any field of an object with no organization column (`tenancy.enabled: false`), must refuse it. On a boot with one organization this leg is reported not run, with the reason, on the report's `boot`. - -A refusal proves the collision only when it is the platform's unique refusal (`UNIQUE_VIOLATION` at the REST door, `DUPLICATE_RECORD` at the system door) and it is this field's: it names the field, or it names none and the same record with only the probed value changed is admitted. A per-organization constraint is an expression index, which the driver reports by index name, so its refusal carries no `field`, and that twin is what attributes it. Any other refusal (an app hook's own dedupe, a validation rule, a permission, a constraint on another field) is `unattributed`, so an engine that refused every write would not read as a proven scope. A field is `proven`, `contradicted` (a second holder met something other than its scope), `failed` (a door faulted), `inconclusive`, `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. - -A field the caller cannot set (`readonly`, `system`, a runtime-issued `autonumber`) is probed through the system door only, which keeps an explicit value. A derived value (`formula`, `summary`) is `excluded`; a field with no fresh values to offer (a `boolean`, a `select`, a multi-valued reference, a target the app does not declare) is `not-derivable`. The population is the field-level declaration: a unique `indexes[]` entry is a different surface, with its own meaning of bare `true`, and is not in it. - -**`@objectstack/verify` — new exports.** - -- `runUniqueScopeProofs(stack, token, config, { doors? })` → `UniqueScopeReport`. Run it on a fresh stack of its own: it writes duplicates on purpose. Boot the stack with `{ multiTenant: true }` to run the cross-organization leg. -- `deriveUniqueScopeCases(config)`, `formatUniqueScopeReport(report)`. -- Types: `DeclaredUniqueScope`, `UniqueScopeCase`, `UniqueScopeDoor`, `UniqueScopeLeg`, `UniqueScopeOperation`, `UniqueScopeAnswer`, `UniqueScopeProbeResult`, `UniqueScopeFieldStatus`, `UniqueScopeFieldResult`, `UniqueScopeReport`, `UniqueScopeProofOptions`. - -```ts -import { bootStack, runUniqueScopeProofs, formatUniqueScopeReport } from '@objectstack/verify'; - -const stack = await bootStack(myApp, { multiTenant: true }); -const report = await runUniqueScopeProofs(stack, await stack.signIn(), myApp); -console.log(formatUniqueScopeReport(report)); // report.boot names the legs this boot ran -expect(report.summary.hardFailures).toBe(0); -await stack.stop(); -``` - -**`@objectstack/cli` — `os verify --unique-scopes`.** Opt-in, like `--reference-shapes` and `--delete-behaviors`, on a fresh boot of its own; with `--multi-tenant` it runs both legs. A second holder met wrongly, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `uniqueScopes` when the flag is given; without the flag the run and its document are unchanged. diff --git a/.changeset/pre/19939-flow-callee-input-value-slots.md b/.changeset/pre/19939-flow-callee-input-value-slots.md deleted file mode 100644 index 4f5e2f9b9d6..00000000000 --- a/.changeset/pre/19939-flow-callee-input-value-slots.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': major -'@objectstack/service-automation': major ---- - -The maps a flow node hands to a CALLEE are value slots now: a `subflow` node's `input`, a `map` node's `input` (evaluated once per item) and a `script` node's `inputs`. Each value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`, evaluated in the calling flow's scope and handed over as the value it computes, or a literal written as it is. A `{…}` template token there is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling, as in every other value slot. - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. These three maps are computed values: each one becomes the callee's input. Until now each executor handed its map to the single-brace interpolator, so a `{token}` resolved there, and a CEL envelope written there reached the callee as the object `{ dialect: 'cel', source: '…' }` it spells, with the run reporting success. - -**What changes for a value that may be absent.** Where a whole token resolved to nothing, the template handed the callee nothing. A child flow then seeded its input variable from the variable's `defaultValue`. Under CEL an absent variable or key fails the run, and the guarded form `has(vars.x) ? vars.x : null` hands `null`. A `null` the caller supplies is a value: it wins over the child's `defaultValue`. To keep the default for an absent value, write it in the guard, `has(vars.x) ? vars.x : 'standard'`. Leave the key out where the value is never meant to be supplied. A `script` function is handed `null` where the template handed `undefined`. The refusal at those positions says this. - -## FROM → TO - -| you wrote | write instead | what changes | -|:--|:--|:--| -| `input: { ownerId: '{record.owner}' }` on a `subflow` | `input: { ownerId: { dialect: 'cel', source: 'record.owner' } }` | an absent `owner` fails the run; guarded, it hands `null`, which wins over the child's `defaultValue` | -| `input: { ownerId: '{x}' }` where the child's default should apply when `x` is absent | `input: { ownerId: { dialect: 'cel', source: "has(vars.x) ? vars.x : 'standard'" } }` | the default is written in the guard, because a supplied `null` is not absent | -| `input: { row: '{item}' }` on a `map` | `input: { row: { dialect: 'cel', source: 'item' } }` | evaluated once per item, with the item variable bound; a list or a record is handed over with its type | -| `inputs: { lines: '{lines}' }` on a `script` | `inputs: { lines: { dialect: 'cel', source: 'lines' } }` | the function receives the list; guarded, an absent value is `null` where it was `undefined` | -| `input: { message: 'Task "{record.title}" is done.' }` | `input: { message: { dialect: 'cel', source: "'Task \"' + record.title + '\" is done.'" } }` | one CEL concatenation; wrap a hole that may be null in `coalesce(…, '')` | - -**The one-line fix: write each value of a `subflow` / `map` `input` and a `script`'s `inputs` as a CEL value envelope, and write a child's default into the guard where it should apply.** - -**Who is affected, measured.** In this repository: 5 values in 3 nodes of the showcase example (`showcase_task_completed`'s `script`, and the `subflow` nodes of `showcase_task_done_notify_owner` and `showcase_project_closure`), 3 values in the todo example's `task_completion` `script`, 2 docs pages, the service-automation README, 2 dogfood fixtures and the test fixtures; all are migrated in this change. hotcrm's sites at these positions were not measured. - -**Still accepted, unchanged.** A CEL value envelope and every literal. - -### The kit - -- **The contract.** `SubflowConfigSchema.input`, `ScriptConfigSchema.inputs` and `MapConfigSchema.input` take `FlowValueSlotSchema` per value (`@objectstack/spec/automation`), so the executor's contract parse refuses a token as a guard. Each published JSON Schema declares the dropped refinement (`dropped-refinements.baseline.json`). -- **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains `subflow.input.*`, `map.input.*` and `script.inputs.*` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries `MapConfigSchema`. `registerFlow`, `objectstack validate` and `flow-bare-dollar-reference` / `flow-double-brace-interpolation`'s value-slot hints follow the ledger, so all of them cover the three maps. -- **The executor.** One per-key resolver, shared with the CRUD `fields` map, evaluates an envelope through `AutomationEngine.evaluateValueEnvelope` in the parent's scope and run context. -- **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. diff --git a/.changeset/pre/19939-flow-collection-value-slots.md b/.changeset/pre/19939-flow-collection-value-slots.md deleted file mode 100644 index b1360ef5146..00000000000 --- a/.changeset/pre/19939-flow-collection-value-slots.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/spec': major -'@objectstack/service-automation': major -'@objectstack/lint': patch ---- - -A `loop`'s (with a `body`) and a `map`'s `collection` is a value slot now: the list the node iterates is a CEL value envelope, `{ dialect: 'cel', source: 'tasks' }`, that the executor evaluates in the run's scope, or the items written as an inline array, iterated as written. A string there is refused at `objectstack validate`, at `registerFlow` and by the executor: a `{…}` template token, naming its CEL spelling, and a variable's bare name, naming that variable in an envelope. The envelope is newly accepted there; the contract refused it before this change. - -Clause-②: yes (narrowing: a string is refused at loop.collection and map.collection, a {…} token and a variable's bare name alike; widening: both accept a CEL value envelope they refused, their published JSON Schema and inferred types gain the envelope arm, and FlowCollectionSlotSchema is a new export) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (both slots accept a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. The list a `loop` or `map` iterates is a computed value. Until now the executors ran a string `collection` through the single-brace interpolator. The contract also declared a variable's bare name there (`collection: 'tasks'`), but the structured `loop` and `map` never read one: the interpolator handed the name back unchanged, the value was no list, and every run failed at the node ("collection 'tasks' did not resolve to an array"), although `registerFlow` and `objectstack validate` accepted the flow. That arm retires with the token, and the refusal names the envelope that iterates. - -**What changes where the list may be absent.** The envelope must evaluate to a list, and anything else fails the node, `null` included, as a token that resolved to nothing did. One difference remains on an absent variable: the template failed the node with a returned failure (the step's `NODE_FAILURE`), and CEL fails it with an evaluation fault naming the variable (`EXECUTION_ERROR`). A `fault` edge routes both. To iterate nothing when the list may be absent, guard it to answer `[]`: `has(vars.tasks) ? vars.tasks : []`. - -**What does not change.** An inline array is iterated as written, and its elements are data: a `{…}` inside one was never interpolated, and is not judged now. A `loop` with no `body` is the legacy flat-graph form: it still reads `collection` as a bare variable name and sets `$loopItems` and `$loopIndex`, and no door judges it. - -## FROM → TO - -| you wrote | write instead | what changes | -|:--|:--|:--| -| `collection: '{tasks}'` | `collection: { dialect: 'cel', source: 'tasks' }` | an absent `tasks` fails the node with an evaluation fault instead of a returned failure | -| `collection: '{record.items}'` | `collection: { dialect: 'cel', source: 'record.items' }` | as above | -| `collection: '{tasks}'` where an absent list should iterate nothing | `collection: { dialect: 'cel', source: 'has(vars.tasks) ? vars.tasks : []' }` | the node iterates nothing where the template failed it | -| `collection: 'tasks'` (a bare name, on a `loop` with a `body` or a `map`) | `collection: { dialect: 'cel', source: 'tasks' }` | the node iterates; it failed every run before, so decide whether it should | -| `collection: ['a', 'b']` | unchanged | an inline array is the list | -| `collection: 'tasks'` on a `loop` with no `body` | unchanged | the legacy flat-graph loop is untouched | - -**The one-line fix: write a `loop`'s or a `map`'s `collection` as a CEL value envelope naming the list, `{ dialect: 'cel', source: 'tasks' }`, or as an inline array.** - -**Who is affected, measured.** In this repository: the showcase example (two `loop` collections and one `map` collection), the todo example (two `loop` collections), the flows guide, the service-automation README, and test fixtures; all are migrated in this change. hotcrm writes a `{…}` collection on eleven structured loops in nine flow files (and one test fixture) and no bare name; it is on spec 17.7.0, so nothing there moves until it upgrades. - -**Newly accepted.** A CEL value envelope at `loop.collection` and `map.collection`: the published JSON Schema for both gains the envelope arm (a list, or the envelope object), and so do the inferred `LoopConfig['collection']` and `MapConfig['collection']`, which lose `string`. `FlowCollectionSlotSchema` (`@objectstack/spec/automation`) is the contract both take. - -**Still accepted, unchanged.** An inline array at both, and on a body-less `loop` any `collection` its legacy form reads. - -### The kit - -- **The contract.** `LoopConfigSchema.collection` and `MapConfigSchema.collection` take `FlowCollectionSlotSchema`: a list or a CEL value envelope, so the executor's contract parse refuses a string as a guard, in the words above. -- **The ledger.** `FLOW_NODE_EXPRESSION_PATHS`' `loop.collection` and `map.collection` rows take the `value` role, and `loop.collection` is read only on a `loop` with a `body` (`onlyWithKey`). The `loop` and `map` descriptors declare the slot with `xExpression: 'value'`. The single-brace `flow-template` role, which recorded these two slots and no other, is gone from `FlowNodeExpressionRole`, so a descriptor marker `'template'` fails the reconciliation ratchet as unknown. -- **The executors.** The `loop` and `map` nodes evaluate an envelope through `AutomationEngine.evaluateValueEnvelope`, as every value slot does, and iterate an inline array as written. A value that is not a list fails the node, naming what it evaluated to and its source. -- **The lint.** `validate-flow-template-paths` reads a loop iterator's object off an envelope `collection` that names a variable, as it read the token before. -- **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. diff --git a/.changeset/pre/19939-flow-crud-filter-operands.md b/.changeset/pre/19939-flow-crud-filter-operands.md deleted file mode 100644 index 97b6f57a253..00000000000 --- a/.changeset/pre/19939-flow-crud-filter-operands.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -'@objectstack/spec': major -'@objectstack/service-automation': major -'@objectstack/lint': major ---- - -Each operand of a `get_record`, `update_record` or `delete_record` node's `filter` is a value slot now. A field's value, an operator's comparand and each element of a list comparand take a CEL value envelope, `{ id: { dialect: 'cel', source: 'record.id' } }`, which the executor evaluates in the run's scope before the query, or a literal handed to the query as written. A query placeholder — `{current_user_id}`, `{current_org_id}`, `{today}`, `{30_days_ago}` and the rest of the date macros — is such a literal: the query engine resolves it, and it passes untouched, as before. Every other single-brace `{…}` token at an operand is refused with its envelope: at `objectstack validate`, at `registerFlow`, at the metadata save door and by the executor's parse. An operand envelope that evaluates to `null` refuses the node, as a token that resolved to nothing did. - -Clause-②: yes (narrowing: a flow {…} token at a get_record, update_record or delete_record filter operand is refused, a whole-string query placeholder excepted, an object naming a string dialect at an operand must be a valid CEL value envelope, an operand envelope that evaluates to null refuses the node, and @objectstack/lint no longer exports validateFlowFilterTokens, FLOW_FILTER_TOKEN_UNKNOWN or FlowFilterTokenFinding and retires the flow-filter-token-unknown rule id; widening: a filter operand gains the CEL value envelope arm, the exported FlowNodeExpressionPath type gains the optional filterOperands field, ValueSlotTemplateOptions gains filterOperand, FLOW_NODE_EXPRESSION_PATHS gains the three filter rows, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains get_record and delete_record, the published GetRecordConfig, UpdateRecordConfig and DeleteRecordConfig JSON Schemas mark filter as a value slot, and flowFilterOperands, FlowFilterOperand, classifyFlowFilterOperandString and FlowFilterOperandStringKind are new exports) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL — its contract table names "filter values" — and Decision 3 deletes the single brace. A CRUD filter was the last value position that read the single-brace dialect: the executor interpolated the whole filter, resolving a flow token against the run's variables and handing a token no variable resolved on to the query engine when it was a query placeholder. Two `{…}` vocabularies met in one string. The query placeholders are the query-filter surface, which ADR-0032 puts out of its scope, so they stay; the flow token goes. - -**One classifier.** `classifyFlowFilterOperandString` (`@objectstack/spec/automation`) draws the line: a string that is one whole wrapped token the query engine resolves on the server (`isKnownFilterToken`) is a placeholder and passes; any other string carrying a `{…}` token is refused; the rest is a literal. The node contracts, `registerFlow`, `objectstack validate` and the executor's parse all read it, through the value-slot judge. - -**What an operand is.** The operands are the positions a value sits at, read the way the filter grammar reads a `FilterCondition` (`flowFilterOperands`): a field's value, each entry of an operator map (`$gt`, `$in`, `$between`, …), each element of a list comparand, inside `$and`, `$or` and `$not` and inside a nested-relation condition. An envelope there is one operand, and may evaluate to a list (`{ id: { $in: { dialect: 'cel', source: 'record.ids' } } }`). The filter's keys, operators and combinators are its structure, never evaluated. The top-level `filter` is never an operand. - -**What changes where a value may be absent.** The template erased a condition whose token resolved to nothing, and the node was refused. Under CEL an absent variable or key fails the run before any query, naming it, and an envelope that evaluates to `null` — a guarded form, a field that holds no value — refuses the node with the guard's message: a condition whose comparand is `null` or nothing does not narrow a query, it widens it. So route around the node with a `decision` where the value may be absent, rather than guarding it to `null`. To match an empty field, write the literal `null` (or `{ $null: true }`), never a computed one. - -**A flow variable named like a placeholder no longer shadows it.** Under the template a flow variable named `today` or `current_user_id` won over the placeholder of the same name. No flow token is read at an operand now, so the placeholder is always the query engine's; read the variable with an envelope, `{ dialect: 'cel', source: 'today' }`. No flow, variable or output variable in this repository is named like a placeholder. - -**A related field named `dialect`.** An object at a field's value that names a string `dialect` is read as an envelope. A nested-relation condition on a related object's field named `dialect` is written through an operator map, `{ rel: { dialect: { $eq: 'x' } } }`. No object in this repository declares such a field. - -**The lint rule `flow-filter-token-unknown` retires.** It reported a `{FUNC()}` token neither the flow template dialect nor the query placeholders define at a flow filter. Every token it reported is refused now by the value-slot judge at the same position and severity, with the envelope that replaces it, so the rule, its `os explain` entry and its exports (`validateFlowFilterTokens`, `FLOW_FILTER_TOKEN_UNKNOWN`, `FlowFilterTokenFinding`) are removed rather than left reporting the same defect a second time with the retired dialect's remedy. A consumer moves to the value-slot judge's findings: rule id `flow-filter-token-unknown` → `expression-invalid` (`EXPRESSION_INVALID`); `validateFlowFilterTokens(stack)` → `validateStackExpressions(stack)`, which reports each refused operand located at its node and `config.filter.`; `FlowFilterTokenFinding` → `ExprIssue`; `FLOW_FILTER_TOKEN_UNKNOWN` → `EXPRESSION_INVALID`. - -## FROM → TO - -| you wrote | write instead | what changes | -|:--|:--|:--| -| `filter: { id: '{record.id}' }` | `filter: { id: { dialect: 'cel', source: 'record.id' } }` | an absent `record.id` fails the run before the query instead of refusing the node | -| `filter: { amount: { $gt: '{record.cap}' } }` | `filter: { amount: { $gt: { dialect: 'cel', source: 'record.cap' } } }` | a `null` cap refuses the node, as before | -| `filter: { id: { $in: ['{a}', '{b}'] } }` | `filter: { id: { $in: [{ dialect: 'cel', source: 'a' }, { dialect: 'cel', source: 'b' }] } }`, or one envelope evaluating to the list | — | -| `filter: { owner: '{$User.Id}' }` | `filter: { owner: { dialect: 'cel', source: 'current_user.id' } }` | in a run with no user `current_user` is `null` and the read fails: skip the node with a `decision` on `current_user != null` | -| `filter: { due: { $lt: '{TODAY() - 3}' } }` | `filter: { due: { $lt: { dialect: 'cel', source: 'isoDate(daysAgo(3))' } } }` | none for a whole number of days | -| `filter: { owner: '{current_user_id}' }`, `{ due: { $lt: '{today}' } }` | unchanged | a query placeholder stays the query engine's | -| `filter: { owner: '{today}' }` meaning a flow variable named `today` | `filter: { owner: { dialect: 'cel', source: 'today' } }` | the placeholder no longer shadows the variable | - -**The one-line fix: write each filter operand the flow computes as a CEL value envelope, and keep the query placeholders as they are.** - -**Who is affected, measured.** In this repository, at the base: seven flow tokens at a filter operand in the shipped examples (`app-crm` 2, `app-showcase` 3, `app-todo` 2 — `app-todo`'s two query placeholders stay), five in the docs (`getting-started/common-patterns`, `kernel/runtime-services/examples` 2, the `service-automation` README 2), the flows guide's and the spec's own remedy texts (a `$User.Email` read named `filter: { id: '{uid}' }`; a `recordId` refusal named `filter: { id: '{record.id}' }`) and the `FlowSchema` example; all are migrated in this change. hotcrm was not measured. - -**Still accepted, unchanged.** Every literal operand, every query placeholder, a filter's keys, operators and combinators, an empty filter, and a filter on `create_record`, which has none. The `filters` alias still converts to `filter` at load. - -### The kit - -- **The contract.** `GetRecordConfigSchema`, `UpdateRecordConfigSchema` and `DeleteRecordConfigSchema` judge each filter operand: an envelope must be a valid CEL value envelope, and a `{…}` token in a literal is refused by the value-slot judge in its filter mode, naming the operand's envelope and what `null` does there. Each `filter`'s `describe()` and TSDoc is restated, and so are the `recordId` guidance and the run-user remedy, which named a single-brace filter value. -- **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains a `filter` row per node, with the new `filterOperands` flag: both walks emit each operand at its own path (`filter.amount.$gt`, `filter.id.$in[0]`), never the filter itself. `GetRecordConfigSchema` and `DeleteRecordConfigSchema` join `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`. -- **The executor.** The CRUD executors parse their config as authored, evaluate each operand envelope through `AutomationEngine.evaluateValueEnvelope` before the query, hand every other operand on as written, and refuse the node — as a guard a `fault` edge cannot route — when an operand evaluates to `null`. The single-brace `interpolateFilter` is deleted. -- **The lint.** `objectstack validate` refuses a token at each operand position and judges an operand envelope as CEL through the shared judges; the wrong-syntax hints at a filter operand name its envelope; `flow-filter-token-unknown` retires. -- **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. diff --git a/.changeset/pre/19939-flow-http-slots.md b/.changeset/pre/19939-flow-http-slots.md deleted file mode 100644 index a2481084956..00000000000 --- a/.changeset/pre/19939-flow-http-slots.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@objectstack/spec': major -'@objectstack/service-automation': major -'@objectstack/lint': patch ---- - -An `http` node reads each config key by what it is now. Its `url` and every `headers` value are text slots, like a notify `title`: they render `{{ }}` holes, `'https://api.example.com/v1/orders/{{ record.id }}'`. Its `body` is a value slot: a CEL value envelope, `{ dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }`, that the executor evaluates in the run's scope to the payload, or a literal sent as written. Its `method`, `durable`, `timeoutMs` and `signingSecret` are literals, read as written. A single-brace `{…}` token at any of them is refused, naming its `{{ }}` spelling at a text slot, its CEL spelling in the body, and at a literal the remedy to write the value: at `objectstack validate`, at `registerFlow` and by the executor, except at `signingSecret`, which the build doors leave to the run because the flow credential store may hold the secret it signs with, and which the executor's parse refuses there. - -Clause-②: yes (narrowing: a {…} token is refused at http url, each headers value, any string of a literal body, method, durable, timeoutMs and signingSecret, and as a whole headers map, and a body value that names a dialect must be a valid CEL value envelope; widening: the exported FlowNodeTextSlot type gains the optional eachValue field, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains http, and the published HttpConfig JSON Schema marks body as a value slot) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a url and a header are text. Until now the executor handed its whole config to the single-brace interpolator and parsed the result, so every key read the retired dialect, a whole token in a typed key (`timeoutMs: '{t}'`) resolved to its value's type, and a CEL envelope written as the body was sent to the endpoint as the object it spells. - -**The request is rendered once.** The executor parses the config as authored — after the flow credential store's `signingSecret`, unchanged — then renders the url and the headers and evaluates the body, before either mode. The durable outbox enqueues exactly the request the inline call would send, and nothing renders at delivery. - -**What changes where a value may be absent.** A url that renders to no text refuses the node, as an empty one did. A header that renders to no text is not sent, where the template sent it empty. In the body an absent variable or key fails the run under CEL, naming it, where the template wrote `null` for a whole token and nothing inside text; guard a value that may be unset, `has(record.owner) ? record.owner : null`. CEL holds a map's values to one type, so in a CEL map literal a literal or a text beside a field read is wrapped in `dyn(…)`. - -**The literals.** `method`, `durable`, `timeoutMs` and `signingSecret` passed through the interpolator too, but no shipped flow computed one: the census found no `{…}` token at any of them in this repository's examples, docs or dogfood fixtures, only in the tests that pinned the interpolation itself. So no value slot is built there. They are read as written, and a `{…}` token in one is refused with the literal's remedy; an envelope there is refused too, since nothing evaluates it. A token written as the whole `headers` map, which the template resolved to a map, is refused the same way. A signing secret that arrived through a template (`signingSecret: '{signing_key}'`) is refused: write the secret itself, which a flow saved through the metadata API keeps in the write-only flow credential store, or `''` to send unsigned on purpose. - -**What does not change.** Every literal is sent as written. The method still defaults to `GET` inline and `POST` durable. An authored `signingSecret: ''` still sends unsigned, and a secret the credential store holds still takes the literal's place. A url or header with no token, and a body with no token, behave as before. - -## FROM → TO - -| you wrote | write instead | what changes | -|:--|:--|:--| -| `url: 'https://api.example.com/v1/orders/{record.id}'` | `url: 'https://api.example.com/v1/orders/{{ record.id }}'` | a `Date` in the url renders as its ISO text, not JSON-quoted | -| `headers: { 'X-Ref': 'r-{record.id}' }` | `headers: { 'X-Ref': 'r-{{ record.id }}' }` | a header that renders to no text is not sent | -| `body: { id: '{record.id}', status: 'done' }` | `body: { dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }` | an absent `id` fails the run instead of sending `null` | -| `body: { text: 'Done: {record.title}' }` | `body: { dialect: 'cel', source: "{'text': 'Done: ' + record.title}" }` | an absent `title` fails the run instead of sending `'Done: '` | -| `body: '{payload}'` | `body: { dialect: 'cel', source: 'payload' }` | an absent `payload` fails the run instead of sending no body | -| `timeoutMs: '{timeout}'`, `durable: '{flag}'`, `method: '{verb}'` | the value itself: `timeoutMs: 5000`, `durable: true`, `method: 'POST'` | the key is a literal now; nothing computes it | -| `headers: '{headers}'` | the map itself: `headers: { 'X-Ref': '{{ record.id }}' }` | a map is not computed into `headers` | -| `signingSecret: '{signing_key}'` | the secret itself, or `''` to send unsigned | a secret read from a flow variable is refused at the run | - -**The one-line fix: double the braces in `url` and the `headers` values, write `body` as one CEL value envelope that builds the payload, and write `method`, `durable`, `timeoutMs` and `signingSecret` as the values themselves.** - -**Who is affected, measured.** In this repository, at the base: the showcase example (three bodies holding four tokens) and the flows guide (one body); both are migrated in this change. No url, header, method, `durable`, `timeoutMs` or `signingSecret` in the examples, docs or dogfood fixtures carries a token. hotcrm was not measured at these keys. - -**Newly refused in the body.** An object naming a `dialect` was data before and was sent as written; it is an expression now, evaluated per run, and a malformed one is refused. An envelope nested inside a literal body is still data, sent as written. - -**Still accepted, unchanged.** Every literal at these keys, and the published shapes of `url` (a string), `headers` (a map of strings), `method`, `durable`, `timeoutMs` and `signingSecret`. - -### The kit - -- **The contract.** `HttpConfigSchema`: `url` and each `headers` value refuse a single-brace token through the text-slot judge; `body` takes `FlowValueSlotSchema`; `method`, `durable`, `timeoutMs` and `signingSecret` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. The token refusals at `method`, `durable`, `timeoutMs` and a whole `headers` map are marked read-as-written, so the build doors' value arm reports them at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated. -- **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains the `http` body row. `FLOW_NODE_TEXT_SLOTS` gains `http.url` and `http.headers`, whose new `eachValue` flag reads each value of the map at its own path (`headers.X-Ref`). `HttpConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the body's marker to the reconciliation ratchet. The build doors' carve-out for a config parsed after interpolation is gone with the interpolation. -- **The executor.** The `http` node parses its config as authored, renders the url and headers through the text renderer, evaluates the body through the shared value-slot resolver, and reads the literals as written; it no longer imports the single-brace interpolator. -- **The lint.** The `flow-double-brace-interpolation` hint names the http url and headers among the text slots, and at an http literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract. -- **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused`, `flow-text-slot-unbound-dollar-root-refused` and `flow-builtin-node-config-values-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion. diff --git a/.changeset/pre/19939-flow-notify-value-slots.md b/.changeset/pre/19939-flow-notify-value-slots.md deleted file mode 100644 index 0e034db6910..00000000000 --- a/.changeset/pre/19939-flow-notify-value-slots.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@objectstack/spec': major -'@objectstack/service-automation': major -'@objectstack/lint': patch ---- - -A `notify` node's computed values are value slots now: `recipients`, `sourceId` and the values of `templateData` take a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, that the executor evaluates in the run's scope, or a literal written as it is. `recipients` holds one recipient or a list of them, each entry a string or an envelope, and an entry that evaluates to a list adds each of its recipients. The node's link, `actionUrl`, is a text slot like `title`: it renders `{{ }}` holes, `'/crm_case/{{ record.id }}'`. Its `sourceObject`, `actorId` and `payload` are literals, read as written: no flow computed one, so nothing is evaluated there. A single-brace `{…}` token at any of these positions is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling at a value slot, its `{{ }}` spelling at `actionUrl`, and, at a literal, the remedy to write the value. - -Clause-②: yes (narrowing: a {…} token is refused at notify recipients (the value or a list entry), templateData values, sourceId, actionUrl, sourceObject, actorId and any string of a payload value, a templateData value that names a dialect must be a valid CEL value envelope, and a CEL value envelope written as a payload value is refused; widening: recipients and sourceId accept a CEL value envelope they refused, a recipients list accepts envelope entries, and their published JSON Schema and inferred types gain the envelope arm) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (four positions accept a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a link with holes is text. Until now the executor ran each of these positions through the single-brace interpolator, and one of its answers reached nobody: a whole token holding a list, written as an entry of a `recipients` list — `recipients: ['{owners}']` with `owners = ['u1', 'u2']` — was handed to the messaging service as ONE recipient, `'u1,u2'`, and the run reported success. A token holding a record there became the recipient `'[object Object]'`, and so did one in a link or a `sourceId`. - -**What changes where a value may be absent.** A recipient envelope that evaluates to `null` or a blank string adds no recipient, as a token that resolved to nothing did, and a node left with none fails, naming the expressions that evaluated to nothing. An absent variable or key fails the run under CEL, naming it, where the template dropped the recipient; guard a field that may be unset, `has(record.assignee) ? record.assignee : null`. A relation hop on a scalar id (`record.account.owner` without the start node's `expand`) fails the evaluation itself, naming the key it could not read, where the template resolved it to nothing. `sourceId` must evaluate to a string; `null` leaves the key unset, and a half-specified click-through pair is still dropped. A recipient, or a `sourceId`, that evaluates to anything else — a map, a number — fails the node, naming what it evaluated to. - -**The literals.** `sourceObject`, `actorId` and the values of `payload` passed through the single-brace interpolator too, but no flow computed one: the census found no `{…}` token at any of them, in this repository or in hotcrm. So no value slot is built there. They are read as written, and a `{…}` token in one — in any string of a `payload` value, at any depth — is refused with the literal's remedy: a single-brace token is not resolved here; write the value. An envelope written there is refused too, since nothing evaluates it (a deeper one inside a `payload` value is data, as it always was). - -**What does not change.** A literal at every value slot is written as it is: a recipient string (`'team:ops'`, a user id, an email address) is one recipient, as before, and is not split. `template`, `topic`, `channels` and `severity` are read raw, as before. An explicit `actionUrl` still wins over the link synthesized from `sourceObject` / `sourceId`, and a link that renders no text yields to it, as an empty one did. - -## FROM → TO - -| you wrote | write instead | what changes | -|:--|:--|:--| -| `recipients: '{record.owner}'` | `recipients: { dialect: 'cel', source: 'record.owner' }` | an absent `owner` fails the run instead of leaving the node without a recipient | -| `recipients: ['{record.assignee}']` where it may be unset | `recipients: [{ dialect: 'cel', source: 'has(record.assignee) ? record.assignee : null' }]` | none: the `null` is dropped, as the unresolved token was | -| `recipients: ['{owners}']` (a list variable) | `recipients: [{ dialect: 'cel', source: 'owners' }]` | each of its recipients is notified, where the template sent one joined string | -| `recipients: ['{$User.Id}']` | `recipients: [{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }]` | none: a run with no user adds no recipient | -| `sourceId: '{record.id}'` | `sourceId: { dialect: 'cel', source: 'record.id' }` | an absent id fails the run instead of dropping the link | -| `sourceObject: '{record.object_name}'`, `actorId: '{record.owner}'`, `payload: { who: '{record.owner}' }` | the value itself: `sourceObject: 'crm_case'` | the key is a literal now; nothing computes it | -| `templateData: { name: '{record.name}' }` | `templateData: { name: { dialect: 'cel', source: 'record.name' } }` | the template gets `null` (a guard's answer) where the key was absent | -| `actionUrl: '/crm_case/{record.id}'` | `actionUrl: '/crm_case/{{ record.id }}'` | a `Date` in the link renders as its ISO text, not JSON-quoted | - -**The one-line fix: write each `notify` value position as a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, double the braces in `actionUrl`, and write `sourceObject`, `actorId` and `payload` as the values themselves.** - -**Who is affected, measured.** In this repository, at the base: the showcase example (16 recipient strings, 7 `sourceId`s and 4 links), the todo example (2 recipients and 2 `sourceId`s), the flows guide, the email-templates and common-patterns guides, two dogfood fixtures and test fixtures; all are migrated in this change, and a recipient field that may be unset is guarded. hotcrm at `1d7148bf2d` writes 19 notify nodes in 15 flow files, each with one `{…}` recipient (2 of them `{$User.Id}`), 35 `{…}` `templateData` values and a link with holes; it is on spec 17.7.0, so nothing there moves until it upgrades. - -**Newly accepted.** A CEL value envelope at `recipients` (the value, or each entry of a list) and `sourceId`: their published JSON Schema gains the envelope arm, and so do the inferred `NotifyConfig` types. At a `templateData` key an object naming a `dialect` was data before; it is an expression now, evaluated per run, and a malformed one is refused. `sourceObject`, `actorId` and `payload` keep their published shapes (a string, a string, a map of any values), with no envelope arm. - -**Newly refused at the literals.** A `{…}` token in `sourceObject`, in `actorId` or in any string of a `payload` value, and a CEL value envelope written as a `payload` value, which was handed on as the object it spells. - -**Still accepted, unchanged.** Every literal at these positions. - -### The kit - -- **The contract.** `NotifyConfigSchema`: `recipients` takes a string, a list of strings and envelopes, or one envelope; `templateData` takes `FlowValueSlotSchema` per key; `sourceId` takes a string or an envelope; `actionUrl` refuses a single-brace token through the text-slot judge; `sourceObject`, `actorId` and `payload` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. That refusal is marked read-as-written, so the build doors' value arm reports it at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated. -- **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains three `notify` value rows. `recipients` carries `eachElement`: a list is read entry by entry, at its index (`recipients[1]`). `FLOW_NODE_TEXT_SLOTS` gains `notify.actionUrl`. `NotifyConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the markers to the reconciliation ratchet. -- **The executor.** The `notify` node evaluates each envelope through the shared value-slot resolver, renders `actionUrl` through the text renderer and reads the literals as written; it no longer imports the single-brace interpolator. -- **The lint.** The `flow-double-brace-interpolation` hint names `actionUrl` among the text slots, and at a notify literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract. -- **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion. diff --git a/.changeset/pre/19939-flow-screen-value-slots.md b/.changeset/pre/19939-flow-screen-value-slots.md deleted file mode 100644 index edff5673343..00000000000 --- a/.changeset/pre/19939-flow-screen-value-slots.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/spec': major -'@objectstack/service-automation': major ---- - -A `screen` node's three positions that hand a value to the client are value slots now: an object form's `defaults` (each field's prefill), a field's `defaultValue`, and an object form's `recordId` (the record an `edit` form opens). Each value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`, that the executor evaluates in the run's scope before the screen goes on the wire, so the client receives the value it computes, never the envelope; or a literal, served as written. A `{…}` template token there is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling, as in every other value slot. `recordId` gains the envelope arm, which it refused before this change, and its literal stays a string. - -Clause-②: yes (narrowing: a {…} token is refused at screen.defaults.*, a field's defaultValue and screen.recordId; widening: screen.recordId accepts a CEL value envelope it refused, and its published JSON Schema and inferred type gain the envelope arm) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (`recordId` accepts a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. A screen's prefill and its record id are computed values handed to the client. Until now the executor ran each through the single-brace interpolator: a `{token}` resolved there, an envelope written in `defaults` or a `defaultValue` went to the client as the object `{ dialect: 'cel', source: '…' }` it spells, and `recordId` was the text of whatever the token found, so a token that found a record served the id `[object Object]`. - -**What changes where a value may be absent.** Where a whole token resolved to nothing, the template served nothing there. Under CEL an absent variable or key fails the run at the screen, with its source, and the guarded form `has(vars.x) ? vars.x : null` serves `null`: - -- In an object form's `defaults`, the form keeps a `null` prefill as a value and opens the field blank, over the object field's own `defaultValue`, which it applied when the prefill left the field out. To keep that default for an absent value, write it in the guard: `has(vars.x) ? vars.x : 'prospecting'`. -- A field's `defaultValue` seeds the field with `null`. -- `recordId` must evaluate to the record's id, a non-blank string. Anything else, `null` included, fails the run at the screen. The template served such a screen with no record id, and its `edit` form opened with no record and could not save. Where the record may be absent, route around the screen with a `decision` on the value instead of guarding the id. - -The refusal at each position says what it serves. - -## FROM → TO - -| you wrote | write instead | what changes | -|:--|:--|:--| -| `defaults: { account: '{account_id}' }` | `defaults: { account: { dialect: 'cel', source: 'account_id' } }` | an absent `account_id` fails the run; guarded, it prefills `null` and the field opens blank over the object field's `defaultValue` | -| `defaults: { stage: '{x}' }` where the object field's default should apply when `x` is absent | `defaults: { stage: { dialect: 'cel', source: "has(vars.x) ? vars.x : 'prospecting'" } }` | the default is written in the guard, because a `null` prefill is a value | -| `defaults: { parent: '{rec}' }` | `defaults: { parent: { dialect: 'cel', source: 'rec' } }` | the record is served as a map, a list as a list | -| `fields: [{ name: 'email', defaultValue: '{lead.email}' }]` | `fields: [{ name: 'email', defaultValue: { dialect: 'cel', source: 'lead.email' } }]` | guarded, it prefills `null` | -| `recordId: '{record.id}'` | `recordId: { dialect: 'cel', source: 'record.id' }` | the envelope must evaluate to a non-blank string id: `null`, a map or a list fails the run at the screen | -| `recordId: 'acc_1'` | unchanged | a string literal is the id | - -**The one-line fix: write each value of a screen's `defaults`, each field's `defaultValue` and an object form's `recordId` as a CEL value envelope; write the object field's default into the guard where it should apply, and route around a screen whose record may be absent.** - -**Who is affected, measured.** In this repository: the CRM example's lead conversion (`crm_convert_lead_wizard`: 3 `defaults` values in its two object-form steps), the object-form example in the flows guide, and test fixtures; all are migrated in this change. hotcrm's sites at these positions were not measured. - -**Newly accepted.** A CEL value envelope at `recordId`, which `z.string()` refused before this change: the published JSON Schema for `recordId` gains the envelope arm (a string, or the envelope object), and so does the inferred `ScreenConfig['recordId']`. - -**Still accepted, unchanged.** A CEL value envelope at `defaults` and at a field's `defaultValue`, and every literal at all three, a string `recordId` included. - -### The kit - -- **The contract.** `ScreenConfigSchema.defaults` and a field's `defaultValue` take `FlowValueSlotSchema`, and `recordId` takes a string or a CEL value envelope (`@objectstack/spec/automation`), so the executor's contract parse refuses a token as a guard. Each published JSON Schema declares the dropped refinement (`dropped-refinements.baseline.json`). -- **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains `screen.defaults.*`, `screen.fields[].defaultValue` and `screen.recordId` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries `ScreenConfigSchema`. `registerFlow`, `objectstack validate` and `flow-bare-dollar-reference` / `flow-double-brace-interpolation`'s value-slot hints follow the ledger, so all of them cover the three positions. The build doors keep refusing a `recordId` literal that is not a string, as before. -- **The executor.** The `screen` node resolves each position through the value-slot resolver the CRUD `fields` map and a callee's input share: an envelope is evaluated by `AutomationEngine.evaluateValueEnvelope` in the run's scope, before the screen is served. A screen that passes through without pausing goes on no wire, and evaluates nothing. -- **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. diff --git a/.changeset/pre/19939-flow-single-brace-resolver-deleted.md b/.changeset/pre/19939-flow-single-brace-resolver-deleted.md deleted file mode 100644 index 647fcaef82a..00000000000 --- a/.changeset/pre/19939-flow-single-brace-resolver-deleted.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/service-automation': major -'@objectstack/lint': major -'@objectstack/spec': patch -'@objectstack/verify': patch ---- - -No flow position reads the single-brace `{…}` template dialect any more, and its resolver is deleted (ADR-0032 Sequencing 6: "delete the single-brace resolver"). `AutomationEngine.evaluateCondition` evaluates every condition as CEL: a condition carrying a `{var}` hole is refused as the CEL brace trap, naming its source, and a `template` or `flow` envelope is refused before evaluation, where the deleted path substituted the variables and compared the text. A literal in a value slot is handed on as written: a `Date` in a code-defined flow reaches the write as the `Date`, where the deleted interpolator wrote `{}`. - -Clause-②: no (narrowing) - - - -**BREAKING**: a narrowing of a published runtime method and of published lint exports, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** ADR-0032 Decision 3 keeps one template delimiter, `{{ }}`, and its Sequencing 6 deletes the single-brace resolver. The earlier changes on this line retired the dialect position by position — every value slot, the text slots, each CRUD filter operand — so the resolver's last readers were two calls that saw literals only and a condition path that only a direct call reached. - -**What changes.** - -- **Conditions.** `AutomationEngine.evaluateCondition(condition, variables, context?)` reads every condition as CEL — bare text, an envelope with `dialect: 'cel'`, or one naming no dialect. The template path it kept for a string carrying a `{var}` hole is deleted: such a string is refused as a CEL fault that names its source and says not to wrap field references in `{…}`, and an envelope with `dialect: 'template'` or `dialect: 'flow'` is refused before evaluation. `registerFlow` already refused both at every condition slot (a start node's `condition`, an edge `condition`, a `decision` branch's `expression`, a screen field's `visibleWhen`), so no registered flow changes. What changes is a direct caller: a plugin calling the method, or `@objectstack/verify`'s `stack.automation.evaluateCondition`, which hands the condition over as given. -- **Value-slot literals.** A literal in a value slot — a `fields`, `input` or `templateData` value, an `assignment` value in any of its three shapes, a `notify` recipient, an `http` `body`, a screen's `defaults`, `defaultValue` or `recordId` — is handed on as a copy of what was written (arrays and plain objects copied, every other value as it is). The deleted interpolator walked every object's own entries, so a `Date` written in a code-defined flow reached the write as `{}`; it reaches it as the `Date`. -- **`@objectstack/lint`.** `validateFlowTemplatePaths` reads the `{{ }}` holes of a node's strings and no single-brace token; a CRUD node's `filter` is not scanned, since each operand is a value slot that renders no template. So `flow-template-unknown-field` and `flow-template-lookup-traversal` report only `warning` now: their `error` at a filter operand, and every finding on a single-brace token, are gone, and the value-slot and text-slot judges refuse those tokens at the same doors as `expression-invalid`. Messages and hints spell a hole `{{ record.x }}`. `flow-double-brace-interpolation` and `flow-bare-dollar-reference`, at a string outside every value slot, text slot and declared literal (a plugin node's own config, a `connector_action` input), say the value is read as written, where they prescribed a `{var}` token nothing reads. The lint's mirror of the retired grammar is removed with its exports. -- **`@objectstack/spec`.** The `FlowSchema` example's `connector_action` input holds a literal, since nothing reads a token there. The upgrade guide's step-18 text-slot entry says no other flow string reads the single-brace dialect, where it said every other one kept it, and its value-slot entry names every value slot the retirement reached on this line, where it named only the `assignment` values and the CRUD `fields` map. - -## FROM → TO - -| you wrote | write instead | what changes | -|:--|:--|:--| -| `engine.evaluateCondition('{amount} > 100', vars)` | `engine.evaluateCondition('amount > 100', vars)` | the template substituted text and compared it; CEL compares the values and fails the run on an absent variable | -| `"{status} == 'open'"`, `'{status} == open'` | `"status == 'open'"` | a string literal is quoted | -| `{ dialect: 'template', source: "{flag} == true" }`, `dialect: 'flow'` | `{ dialect: 'cel', source: 'flag == true' }` | — | -| `classifyFlowTemplateToken(inner)` (`@objectstack/lint`) | `valueSlotTemplateRefusals(value)` / `textSlotTemplateRefusal(text)` (`@objectstack/spec/automation`), which refuse a token with the spelling that replaces it | the grammar it classified is read by nothing | -| `FLOW_TEMPLATE_DATE_FUNCTIONS`, `FLOW_TEMPLATE_VALUE_FUNCTIONS` | the CEL stdlib: `isoDate(today())`, `isoDatetime(now())`, `round`, `floor`, `ceil`, `abs`, `min`, `max` | — | -| `FlowTemplateTokenVerdict` | nothing: the judges' reading of the retired grammar is internal to `@objectstack/spec` | — | - -**The one-line fix: write a condition as CEL, without `{…}` around a reference.** - -**Who is affected, measured.** In this repository at the base: no braced condition and no `template` or `flow` condition envelope in `examples/`, the docs or any package source; the deleted path's own pins in `@objectstack/service-automation` (`engine.test.ts`, `nested-region-parity.test.ts`) and `@objectstack/verify`'s door test are re-pinned to the refusal here. No authored `Date` literal in a flow was found; a compiled artifact serialises one to text, so only a flow handed to `registerFlow` in code carried one. Neither repository imports the removed lint exports. hotcrm at `0f84c9c` carries no braced condition and no `connector_action` input; its flows still author the single-brace dialect at value and text positions earlier changes on this line refuse, which its own upgrade migrates. - -### The kit - -- **The engine.** `AutomationEngine.evaluateCondition` (`@objectstack/service-automation`): the template path, its hole discriminator and its text comparison are deleted. The value-slot resolvers' literal arm and the `assignment` executor copy a literal (`copyLiteral`, `builtin/value-slot-map.ts`). The single-brace interpolator (`interpolate`, `interpolateString`, `resolveToken`, its function table and `FlowExpressionFunctionError`), which the package did not export, is deleted with its tests. -- **The lint.** `validateFlowTemplatePaths` and the two wrong-syntax hints (`lint-flow-patterns.ts`); `classifyFlowTemplateToken`, `FLOW_TEMPLATE_DATE_FUNCTIONS`, `FLOW_TEMPLATE_VALUE_FUNCTIONS` and `FlowTemplateTokenVerdict` are removed. -- **The verify handle.** `stack.automation.evaluateCondition`'s documentation states the engine's reading: CEL, a `{var}` hole refused. -- **ADR-0087.** No new entry: the step-18 D3 entries `flow-value-slot-template-dialect-refused` and `flow-text-slot-single-brace-refused` record the retirement. diff --git a/.changeset/pre/19939-flow-value-slot-date-macros-refused.md b/.changeset/pre/19939-flow-value-slot-date-macros-refused.md deleted file mode 100644 index f40ecc1f646..00000000000 --- a/.changeset/pre/19939-flow-value-slot-date-macros-refused.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -'@objectstack/spec': major -'@objectstack/service-automation': major -'@objectstack/lint': patch ---- - -A flow VALUE slot now refuses the date macros as well — `{TODAY()}`, `{NOW()}`, with an optional `± N` day offset — so it reads no single-brace `{…}` token at all. Each is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL string form: `isoDate(today())`, `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, `isoDatetime(now())`, `isoDatetime(addDays(now(), N))`. - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** The template dialect is retired from the value slots: one dialect per slot, with a remedy for every spelling. The date macros were the last spelling kept, because CEL had no string form for a timestamp: `today()` and `now()` reach the data engine as a `Date` object, not the ISO text the macros wrote. The CEL stdlib now has one — `isoDate(t)` (`YYYY-MM-DD`) and `isoDatetime(t)` (`YYYY-MM-DDTHH:mm:ss.sssZ`), on the UTC calendar the macros rendered on — so the macros can be refused with a remedy that writes the same text. - -**What is refused.** In every value slot — `create_record` / `update_record` `fields`, the `assignment` node's `assignments` map, and both legacy `assignment` shapes — a string carrying a date macro, alone or beside other text and tokens. A string that mixed a date macro with another token used to be kept whole, the run-user token included (`'Due {TODAY()} by {$User.Id}'`); it is refused now, with a remedy for every token. - -**Where the remedy parts from the template.** For no offset or a whole number of days the CEL form writes the template's text byte for byte, at every instant (measured live through both engines over DST transitions, month and year ends and a leap day). The refusal names the three places it does not: - -- a fractional offset: the template added it to the day of the month and truncated the sum, `addDays` truncates the offset itself — going back, a day apart on most days of a month — and `daysFromNow(1.5)` / `daysAgo(1.5)` are refused at build; -- a variable offset (`{TODAY() + days}`): the template looked the offset up as one variable name and added 0 days, without a word, when it found none or the value was not a number; CEL reads the path, and fails the run on an absent variable or a value that is not a number; -- an offset that is neither a number nor a variable name (`{TODAY() + 3d}`): the template added 0 days. - -**Where an envelope is literal data, the remedy names none there.** The refusal used to prescribe an envelope at whatever position it refused a token. Inside an object or list value, and in the legacy `assignment` shapes, the executor reads an envelope as data: the prescribed metadata registered and stored the envelope object. The refusal there now names what evaluates — the whole value built as one CEL map or list literal (mixed values each wrapped in `dyn(…)`), or the assignment moved into the `assignments` map. This holds for every token, not only the date macros. An envelope-shaped object in those positions is still data, unchanged. - -**The `has()` guard a refusal names now holds for an absent variable.** For a path that may be absent, the refusal named a guard on its last key, `has(source.id) ? source.id : null`. `has()` evaluates everything but its last selection, so where the variable itself was never bound — an `isInput` variable the caller left out, with no default — that guard failed the run (`Unknown variable: source`). The guard now tests each step off `vars`, which holds only the variables the run has bound: `has(vars.source) && has(vars.source.id) ? vars.source.id : null` answers `null` there, and where a key on the way is absent or `null`. `flow-bare-dollar-reference`'s value-slot hint carries the same guard. - -## FROM → TO - -| you wrote | write instead | what changes | -|:--|:--|:--| -| `'{TODAY()}'` | `{ dialect: 'cel', source: 'isoDate(today())' }` | nothing | -| `'{TODAY() + 7}'`, `'{TODAY() - 3}'` | `{ dialect: 'cel', source: 'isoDate(daysFromNow(7))' }`, `{ dialect: 'cel', source: 'isoDate(daysAgo(3))' }` | nothing for a whole number of days; a fraction truncates differently, and `daysFromNow` / `daysAgo` refuse one at build | -| `'{NOW()}'` | `{ dialect: 'cel', source: 'isoDatetime(now())' }` | nothing | -| `'{NOW() + 2}'`, `'{NOW() - 1}'` | `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), 2))' }`, `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), -1))' }` | nothing — `addDays` keeps the time of day, where `daysFromNow` would land on midnight | -| `'{TODAY() + days}'` | `{ dialect: 'cel', source: 'isoDate(addDays(today(), days))' }` | an absent `days`, or one that is not a number, fails the run where the template silently added 0 | -| `'Due {TODAY()} by {$User.Id}'` | `{ dialect: 'cel', source: "'Due ' + isoDate(today()) + ' by ' + current_user.id" }` | guard the run user where a flow can run without one | -| `payload: { due: '{TODAY()}' }` (a string inside an object value) | `payload: { dialect: 'cel', source: "{'due': isoDate(today())}" }` | an envelope written at `payload.due` would be stored as an object | -| the guard an earlier refusal named, `has(source.id) ? source.id : null` | `has(vars.source) && has(vars.source.id) ? vars.source.id : null` | `null` where `source` was never bound, where the last-key guard failed the run | -| a text slot's `assignments: { due: '{TODAY() + 7}' }`, then `'Due {{ due }}'` | `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' } }`, then `'Due {{ due }}'` | nothing for a whole number of days | - -**The one-line fix: write a date macro as `isoDate(…)` / `isoDatetime(…)` in a CEL envelope, with a whole number of days.** - -**Who is affected, measured.** This repository's examples carry no date macro in a value slot; its own test fixtures and docs that did are migrated in this change. The census taken for this retirement's first change measured 15 date-macro value-slot sites in hotcrm at `c529de2` (8 `{NOW()}`, 3 `{TODAY()}`, 3 `{TODAY() + N}`, 1 `{TODAY() + var}`); they migrate there. Deployed metadata and other repositories were not measured here. - -**Still accepted, unchanged.** A CEL value envelope and every literal. - -### The kit - -- **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`) keep no token; the text-slot judge's date-macro remedy (`textSlotTemplateRefusal`) names the CEL envelope. `FlowValueSlotSchema` / `AssignmentValueSchema` say so in their descriptions. -- **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the date macros and name their forms) and `flow-text-slot-single-brace-refused` (its date remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. -- **`@objectstack/lint`.** `flow-double-brace-interpolation`'s hint in a value slot names the CEL envelope for the holes the author meant, where it named `{record.title}`, which the slot refuses; `flow-bare-dollar-reference`'s value-slot hint asks the judge at the string's own position, so a nested or legacy-shape string gets the spelling that evaluates there, and carries the judge's step-by-step `has()` guard. -- **`@objectstack/service-automation`'s README.** Its *Expressions* section names the date macros' CEL forms. diff --git a/.changeset/pre/19939-flow-value-slot-run-user-refused.md b/.changeset/pre/19939-flow-value-slot-run-user-refused.md deleted file mode 100644 index 3eb82bdba1e..00000000000 --- a/.changeset/pre/19939-flow-value-slot-run-user-refused.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -'@objectstack/spec': major -'@objectstack/service-automation': major -'@objectstack/cli': patch -'@objectstack/lint': patch ---- - -A flow VALUE slot now refuses the run-user template token `{$User.}` as well, and every CEL expression in a flow sees **`current_user`**, the run's user. `{$User.Id}` in a `create_record` / `update_record` `fields` value or an `assignment` value is refused at `objectstack validate`, at `registerFlow` and by the executor, naming `current_user.id` and, for a flow that can run without a user, its guard; every other `$User` path is refused saying it never resolved. - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** The template dialect is retired from the value slots, one dialect per slot, with a remedy for every spelling. The run user was the one path spelling kept, because the flow CEL scope bound no user: `current_user.id` passed `objectstack validate` and `registerFlow`, then failed the run with `Unknown variable: current_user`. The scope now binds it, so the token can be refused with a remedy that evaluates. - -**What `current_user` is.** In every flow CEL expression — a start-node `condition`, an edge `condition`, a `decision` condition, a screen field's `visibleWhen`, an `assignment` or `fields` value envelope — `current_user` is the run's user, built through `createEvalUser` from what the run context holds: `id` (the run's `userId`), `positions`, `organizationId` (the run's `tenantId`) and the derived `isPlatformAdmin`. No run carries an email or a name, so neither is bound. A run with no user — a schedule, a record change made by a system write — sees `null`, never a stand-in user: `current_user.id` then fails the run loudly, and `current_user != null` can guard it. A `runAs: 'system'` run sees the user that triggered it, as `{$User.Id}` did. A flow variable named `current_user` is shadowed by the binding, as one named `vars` is by the variables namespace; both are read as `vars["current_user"]` / `vars["vars"]`, and the refusal of a `{vars.…}` or `{current_user.…}` path token prints that spelling. - -**The expression remedy.** A refused `{…}` arithmetic token's CEL spelling now reads every variable path in it the way a lone path token's does — `{int * 2}` is `vars["int"] * 2`, `{items.0 * 2}` is `items[0] * 2` — instead of rewriting only its divisors, which printed envelopes that did not evaluate. - -**Still accepted, unchanged.** A CEL value envelope and every literal. - -## FROM → TO - -| you wrote | write instead | what changes | -|:--|:--|:--| -| `'{$User.Id}'` | `{ dialect: 'cel', source: 'current_user.id' }` | nothing when the run has a user | -| `'{$User.Id}'`, in a flow that can run without a user | `{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }` — or skip the node with `current_user != null` as a start condition or a `decision` | the guarded form writes `null` where the template wrote nothing, so on `update_record` it clears a stored value the template left alone | -| `'{$User.Email}'`, `'{$User.Name}'`, any other `$User` path | a `get_record` on `sys_user` with `filter: { id: { dialect: 'cel', source: 'current_user.id' } }` and `outputVariable: 'me'`, then `{ dialect: 'cel', source: 'me.email' }` | these never resolved in any shipped run: they wrote nothing | -| `'Owner: {$User.Id}'` | `{ dialect: 'cel', source: "'Owner: ' + current_user.id" }` | in a user-less run, write the hole as `(current_user != null ? current_user.id : '')` | -| a text slot's `assignments: { by: '{$User.Id}' }`, then `'By {{ by }}'` | `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `'By {{ by }}'` | nothing when the run has a user; where the flow can run without one, compute `current_user != null ? current_user.id : null`, which renders nothing where the template did | - -**The one-line fix: write `current_user.id` where you wrote `{$User.Id}`, and guard it where the flow can run without a user.** - -**Who is affected, measured.** This repository's two authored value-slot sites are migrated in this change: the `examples/app-todo` quick-add screen flow (a screen flow always has a user, so the bare read) and the `os explain flow` catalog example, an `update_record` under a record-after-create trigger, which a system write fires with no user; it now gates on `current_user != null` in its start condition, so the stored value is left alone there as before. `examples/app-showcase`'s `{$User.Id}`, a `notify` `recipients` entry, is migrated to `current_user` with the `notify` value slots on this line. Other repositories and deployed metadata were not measured here. - -### The kit - -- **The binding.** `AutomationEngine.celScope` (`@objectstack/service-automation`); `evaluateCondition` and `evaluateValueEnvelope` take the run context as an optional last argument, which every engine site and the `assignment`, `decision`, `create_record` and `update_record` executors pass. Without one, `current_user` is `null`. -- **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`); the text-slot judge's run-user remedy (`textSlotTemplateRefusal`) now computes the id with the CEL envelope. -- **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the run-user paths), `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` (their run-user remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. -- **`@objectstack/cli`.** `os explain flow`'s example writes `current_user.id` and gates on `current_user != null`. -- **`@objectstack/lint`.** `flow-bare-dollar-reference`'s hint for a bare `$name.path` in a value slot names the CEL envelope the value-slot refusal writes for it — `current_user.id` and its guard for `$User.Id`, the variable the reference names for any other — where it named `{source.id}` and `{$User.Id}`, both refused there. A text slot's hint keeps the `{{ }}` hole, and the hint at a node's literal, or at any other position, names the value itself. -- **`@objectstack/service-automation`'s README.** Its *Expressions* section states the value-slot envelope and `current_user`. diff --git a/.changeset/pre/19939-flow-value-slot-template-dialect-refused.md b/.changeset/pre/19939-flow-value-slot-template-dialect-refused.md deleted file mode 100644 index 3a9b4b12ff9..00000000000 --- a/.changeset/pre/19939-flow-value-slot-template-dialect-refused.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/spec': major -'@objectstack/service-automation': major -'@objectstack/lint': major ---- - -A flow VALUE slot no longer reads the single-brace `{…}` template dialect: a `create_record` / `update_record` `fields` value or an `assignment` value that carries a `{…}` token is refused at `objectstack validate`, at `registerFlow` and by the executor, with the CEL spelling of each token. A computed value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`; a string is the literal text it spells. - -Clause-②: yes (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** Since #19938 the value slots also evaluate a CEL value envelope, so a flow had two dialects for one job — two function sets, two meanings of `/`, and a template that wrote nothing where CEL refuses. The maintainer's ruling D on the flow expression dialects put the template's retirement from these slots on the v18 train, with a remedy for every spelling and an automatic conversion only where one is lossless (ADR-0087 D2). - -**What is refused.** In every value slot — `create_record.fields.*`, `update_record.fields.*`, the `assignment` node's `assignments` map, and the two legacy `assignment` shapes the executor still reads (the `assignments: [{ variable, value }]` array and the bare `{ : }` config) — a string, or a string at any depth of an array or object value, that carries a `{…}` token the interpolator would resolve. One judge says it everywhere (`flowNodeValueTemplateRefusals` / `valueSlotTemplateRefusals` in `@objectstack/spec/automation`): `FlowValueSlotSchema`, `AssignmentValueSchema`, `CreateRecordConfigSchema`, `UpdateRecordConfigSchema` and `AssignmentConfigSchema` refuse it at the value's path; `objectstack validate` reports it as `expression-invalid` at `error`, located at the node and `config.`, which also refuses it at the metadata save door; `registerFlow` refuses the flow (a stored flow carrying one is skipped at boot with a warn naming it); and the `create_record` / `update_record` / `assignment` executors refuse the node before writing anything. Each refusal leads with `VALUE_SLOT_TEMPLATE_REFUSAL` and then names the CEL spelling of the string's tokens. - -**Not converted.** No D2 conversion rewrites any spelling: every token spelling authored in flows answers differently under CEL for some input (measured through both engines over the same variables, 13 of 25 probes the same, 12 different), so a rewrite would change what some flow writes. Where a value may be absent, whether the field should take nothing, `null` or a default was decided silently by the template; it is now the author's decision. - -**What is still accepted, unchanged.** A CEL value envelope and every literal (a token-free string, numbers, booleans, `null`, arrays, objects). A text slot (`notify` `title` / `message`, a screen `description`, …) reads `{{ }}` holes and refuses a single-brace token. - -## FROM → TO - -| you wrote | write instead | what changes | -|:--|:--|:--| -| `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — guard one that may be absent a step at a time from `vars`: `has(vars.record) && has(vars.record.owner) ? vars.record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) | -| `'{items.0}'` | `{ dialect: 'cel', source: 'items[0]' }` | an empty list fails the run | -| `'{$error.message}'` | `{ dialect: 'cel', source: 'vars["$error"].message' }` | a `$`-named variable is read through `vars` | -| `'{round(x * 100) / 100}'` | `{ dialect: 'cel', source: 'round(x * 100) / 100.0' }` | CEL divides two integers as integers: keep a decimal operand on every division, or `123.46` becomes `123` | -| `'Renewal — {contract.number}'` | `{ dialect: 'cel', source: "'Renewal — ' + contract.number" }` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` | -| braces meant literally, `'{"a": 1}'` | `{ dialect: 'cel', source: "'{\"a\": 1}'" }` | a CEL string literal | - -**The one-line fix: write the CEL spelling the refusal names, and guard a value that may be absent.** - -**Who is affected, measured.** A TypeScript-AST census of every `create_record` / `update_record` `fields` value and `assignment` value (all three shapes, same-file spreads included): this repository at `959c209d5` carried 21 authored sites (`examples/**` and `packages/verify/src`) — 20 refused by this change (9 bare references, 10 dotted paths, 1 `$error` path, all migrated in this change) and 1 `{$User.Id}` (`examples/app-todo`), which the run-user refusal on this line refuses and migrates; hotcrm at `c529de2` carries 91 (2 of them through a same-file spread) — 71 refused by this change (31 bare references, 36 dotted paths, 4 text with holes) and 20 that the date-macro and run-user refusals on this line refuse (15 date macros, 5 `{$User.Id}`). Deployed metadata and other repositories were not measured. - -### The kit - -- **The refusal.** `automation/flow-value-slot-template.ts` (`VALUE_SLOT_TEMPLATE_REFUSAL`, `valueSlotTemplateRefusals`, `flowNodeValueTemplateRefusals`), composed into the value-slot contracts in `automation/builtin-node-config.zod.ts`; one new dropped-refinement site (`automation/AssignmentConfig` `out.catchall`, the bare legacy shape's values). -- **The doors.** `AutomationEngine.registerFlow` and the `create_record` / `update_record` / `assignment` executors (`@objectstack/service-automation`); `validateStackExpressions` (`@objectstack/lint`), whose `warning` hint pointing a template expression at the envelope this refusal replaces. -- **The ledger.** The D3 semantic entry `flow-value-slot-template-dialect-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: no authored spelling maps losslessly. diff --git a/.changeset/pre/20471-component-row-anchor-quotes.md b/.changeset/pre/20471-component-row-anchor-quotes.md deleted file mode 100644 index 0b337c0f465..00000000000 --- a/.changeset/pre/20471-component-row-anchor-quotes.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Three `ComponentPropsMap` read-point records now quote the objectui line they cite - -Clause-②: no - -The docblocks of `action:button`, `action:icon` and `element:definition-list` in -`src/ui/component.zod.ts` each quote the first line of the row's props-read site, re-read -against objectui at the pin this package builds against (`a58626c88`): the runner-forward -literal of the two action blocks, and the `readProps` call of the definition list. A pin bump -that moves one of those lines, or changes it, now fails `check:objectui-pin-citations` and -names where the quoted line went, where before only the cited sha was checked. The other -three rows of that section (`action:group`, `action:menu`, `element:repeater`) carry no quote -yet. Comment text only: no schema, key, type or export changes. diff --git a/.changeset/pre/20471-component-row-anchor-requote.md b/.changeset/pre/20471-component-row-anchor-requote.md deleted file mode 100644 index 5a196de2cca..00000000000 --- a/.changeset/pre/20471-component-row-anchor-requote.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The `action:group`, `action:menu` and `element:repeater` read-point records are re-measured at the objectui pin and quote the line they cite - -Clause-②: no - -The docblocks of `action:group`, `action:menu` and `element:repeater` in `src/ui/component.zod.ts` -cited objectui lines that had moved without any gate noticing: the two containers' anchors by 3 to 8 -lines since objectui#11638, every repeater anchor by 28 lines since objectui#11168 slice 2, and the -repeater's `data-objectstack` filter and sort anchors since earlier pins. Each is now re-pointed at -the pin this package builds against (`a58626c88`), as are `action:button`'s `static-params.ts` -citation and the `element:definition-list` registration notes, which said the registration -publishes the strings `'1'` / `'2'` and marks `items` required (it no longer does either). Each of -the three rows now quotes the first line of its props-read site (the member forward of the two -containers, the `readProps` call of the repeater), so a pin bump that moves or changes one of those -lines fails `check:objectui-pin-citations`. All six rows of that section now carry a quote. Comment -text only: no schema, key, type or export changes. diff --git a/.changeset/pre/20749-reader-needles-in-words.md b/.changeset/pre/20749-reader-needles-in-words.md deleted file mode 100644 index 30f646beea6..00000000000 --- a/.changeset/pre/20749-reader-needles-in-words.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Source comments that cited a tracker number now say in words which decision they mean, and the pin tests that read those comments match the new words - -Clause-②: no - -The package ships its `src/**/*.zod.ts` sources, and `dist` carries the member docblocks. Four of those texts named a decision only by its tracker number. Each now names the decision in words. The pin test that reads each text matches the new words, at the places the old number stood: - -- `ai/build-progress.zod.ts`: the cloud card that ruled the build-progress phase vocabulary in, and that owns where the emitter sits, is now "the cloud build-progress card" in all seven places. The consumer's strict-parse carrier is "block 2 of the objectui build-panel card". The `ai/build-progress` reference page is regenerated from the module docblock, so its description now ends with "(the cloud build-progress card, ruling A)". -- `contracts/approval-service.ts`: the `IApprovalService.continueRestoredRun` docblock says the verb is declared "exactly as the automation-service contract declared" `IAutomationService.cancelRun` / `restoreConsumedSuspension`. -- `ui/notification.zod.ts`: the tombstone note for the removed `./ui` `Notification` / `NotificationConfig` wrappers is labelled `[dual-source removal]`. The `NotificationAction` removal is called "the no-door retirement". -- `ui/sharing.zod.ts`: `EmbedConfigSchema` is recorded as removed "at the no-door retirement (ADR-0049 enforce-or-remove)". The `ui/sharing` reference page is regenerated from the module docblock. - -Comments and docblocks only. No key, type, default, accepted value, `.describe()` string or export changes. diff --git a/.changeset/pre/21886-add-member-platform-admin-visibility.md b/.changeset/pre/21886-add-member-platform-admin-visibility.md deleted file mode 100644 index 4bf3beef56b..00000000000 --- a/.changeset/pre/21886-add-member-platform-admin-visibility.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -"Add Member" is offered only to a platform administrator, the one standing its endpoint admits. - -Clause-②: no - -- `sys_member`'s `add_member` toolbar action now declares `visible: 'current_user.isPlatformAdmin == true'`. `requiresFeature: 'organization'` composes onto it at parse time, so the served predicate reads `(current_user.isPlatformAdmin == true) && features.organization != false`. -- Its endpoint, `POST /api/v1/auth/organization/add-member`, has always admitted a platform administrator alone (ADR-0068) and answered every other caller, org owners and admins included, with 403 `PERMISSION_DENIED`. Before this change the button was still shown to every member of the organization. -- ⛔ Nothing you author changes. The endpoint and the callers it admits are unchanged, and no key, export or parameter is added. The action's label is unchanged. diff --git a/.changeset/pre/21898-flow-builtin-node-config-values-refused.md b/.changeset/pre/21898-flow-builtin-node-config-values-refused.md deleted file mode 100644 index 3f9576ec5c3..00000000000 --- a/.changeset/pre/21898-flow-builtin-node-config-values-refused.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -A builtin flow node's `config` value that its executor contract refuses is refused at parse, with a location, in the contract's own words: `create_record` `outputVariable: 42`, a screen field `min: '1'`, a `get_record` `limit: '10'` and the like no longer pass the build doors and then fail every run. - -Clause-②: yes (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. - -**Why.** Every builtin executor parses its node's `config` against the contract `getBuiltinNodeConfigContracts()` names before it acts, and refuses the node on any finding. The build doors judged only the keys that contract requires, left out, so a present value it refuses passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied it into `dist/objectstack.json`), registered, and failed every run that reached the node: `create_record 'mk': config does not satisfy the create_record contract — config.outputVariable: Invalid input: expected string, received number`. - -**What is refused.** A node of any builtin type (`get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `script`, `subflow`, `map`, `loop`, `parallel`, `try_catch`), at any depth, whose present config value its executor contract refuses — a wrong type, a value outside the declared set or range, an empty `function` / `flowName`, or a rule finding on present keys (a `notify` `template` beside an inline `title`). The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, anchored at the key (`nodes.N.config.outputVariable`, `nodes.N.config.fields.0.min`), from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the metadata save door (`422 INVALID_METADATA`). - -**What the build doors still accept, byte for byte.** Every value its contract accepts, and the values this arm holds back: - -- a value carrying a `{token}` (also spelled with double braces or a leading `$`) — never refused at the build doors for its pre-interpolation type. That is not a promise it runs: every builtin parses its config as authored, so a token in one of its number or boolean slots (`limit: '{n}'`, `maxIterations: '{cap}'`, a screen field `min: '{m}'`, `multi: '{bulk}'`) still fails at its first run, exactly as before — write a literal there (an `http` node's `timeoutMs` and `durable` refuse the token itself at the build doors); -- on `http`, `signingSecret` (the credential channel may supply it); -- a `loop` with no `body` (its executor does not parse it), and the region slots of `loop`, `parallel` and `try_catch`; -- an undeclared or retired key, a screen field's `visibleWhen` and a CRUD `fields` value — each keeps the judge it had. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `outputVariable: 42` | `outputVariable: 'taskId'` — the variable's name | -| a screen field `min: '1'`, `max: '10'` | `min: 1`, `max: 10` | -| `limit: '10'`, `maxIterations: '5'` (any number slot outside `http`) | `limit: 10`, `maxIterations: 5` — a literal number only: these executors parse the config as authored, so a `{token}` here passes the build and fails every run | -| `multi: 'true'`, a screen field `required: 'yes'` (any boolean slot outside `http`) | `multi: true`, `required: true` — a literal boolean only, for the same reason | -| `http` `timeoutMs: '5000'`, `durable: 'yes'` | `timeoutMs: 5000`, `durable: true` — a literal only: `http` parses its config as authored, and a `{token}` there is refused at the build doors | -| `severity: 'loud'`, `mode: 'view'` | one of the declared values (`'info'` / `'warning'` / `'critical'`; `'create'` / `'edit'`) | -| a `notify` with both `template` and `title` | one content path, as the refusal's sentence says | - -**The one-line fix: write the value the contract declares at the key the refusal names.** The runtime never ran such a node, so the fix changes nothing a working flow does. - -**Who is affected, measured.** At `833d57c9cf`, every builtin node `config` authored in this repository's examples, docs, skills and `packages/qa` fixtures (96 nodes), and every one in hotcrm at `4054ec2680` (138 nodes), parses under this arm. A second census at `d1c7d8d392` that also reads helper calls, same-file constants and assignments into a node config (1065 configs in this repository, 138 in hotcrm) found no other real writer; 64 configs here take a value from an import, a call or a spread that no static reading evaluates, and are not counted either way. The one real writer found to store a refused value is the Studio flow designer, which saved a screen field's Min / Max as strings until objectui `5ba255538a`. Deployed metadata, and other repositories, were not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. - -### The kit - -- **The refusal.** The value half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`; no new code joins `FLOW_SLOT_REFUSAL_CODES`, and `getBuiltinNodeConfigContracts()` keeps its 13 entries. -- **The ledger.** The D3 semantic entry `flow-builtin-node-config-values-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know the value the author meant. diff --git a/.changeset/pre/21903-platform-admin-affordance-visibility.md b/.changeset/pre/21903-platform-admin-affordance-visibility.md deleted file mode 100644 index 1100cee5ef9..00000000000 --- a/.changeset/pre/21903-platform-admin-affordance-visibility.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -The user, OAuth-application and SSO-provider actions whose endpoint admits only a platform administrator are now offered only to a platform administrator. - -Clause-②: no - -- These thirteen actions now declare `visible: 'current_user.isPlatformAdmin == true'`, composed with their existing terms: - - `sys_user`: `ban_user`, `unban_user`, `unlock_user`, `create_user`, `set_user_password`, `impersonate_user` and `set_user_manager`; - - `sys_oauth_application`: `disable_oauth_application` and `enable_oauth_application`; - - `sys_sso_provider`: `register_sso_provider`, `register_saml_provider`, `request_domain_verification` and `verify_domain`. -- Where an action also carries `requiresFeature`, the feature gate composes onto it at parse time. For example, `ban_user` now serves `(current_user.isPlatformAdmin == true) && features.admin == true`. -- Each endpoint (`/api/v1/auth/admin/*`) has always admitted a platform administrator alone (ADR-0068) and answered every other caller, org owners and admins included, with 403 `PERMISSION_DENIED`. Before this change the buttons were still shown to those callers. -- `create_oauth_application`, `rotate_client_secret`, `delete_oauth_application` and `delete_sso_provider` are unchanged: their endpoints authorize the signed-in user or the record's owner, not the platform administrator. -- ⛔ Nothing you author changes. The endpoints and the callers they admit are unchanged, and no key, export or parameter is added. The actions' labels are unchanged. diff --git a/.changeset/pre/21908-by-id-producers-opt-in.md b/.changeset/pre/21908-by-id-producers-opt-in.md deleted file mode 100644 index b1d6f8b1cae..00000000000 --- a/.changeset/pre/21908-by-id-producers-opt-in.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/service-storage": patch -"@objectstack/service-messaging": patch ---- - -The storage store's by-id methods and the HTTP outbox's `redeliver` now pass the explicit system opt-in (`{ isSystem: true }`) on their data-engine calls. Until now they reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. - -Clause-②: no - -- **service-storage.** `StorageMetadataStore.getFile`, `updateFile`, `deleteFile`, `getSession`, `updateSession` and `deleteSession` take the opt-in inside the store. Access stays by id, and the reads stay unscoped by organization, as before. On update and delete the acting organization still reaches the driver beside the opt-in, so a row stamped for another organization is still out of reach of these doors. The doors keep the authorization they already ran. -- **service-storage, the update payload.** `updateFile` and `updateSession` now send the caller's patch alone, where they used to send the whole row read back merged with it. The engine's read-only strip, which does not run for a system write, used to take `organization_id` and the four audit columns out of that row; now the store never sends them. The stored row is the same as before, and a column another writer changed between the read and the write is no longer reverted by it. -- **service-messaging.** `SqlHttpOutbox.redeliver` takes the opt-in on both of its reads and on its reset write. The caller's `tenantId` stays on every call as the driver-level scope, so a delivery in another organization is still not found. The reset write states `bypassTenantAudit: false`, so a redelivery from a caller with no organization is still reported by the driver's tenant audit. -- None of the gates the security middleware runs before its hand-off applies to these calls. ⛔ No new export on either package entry, and no new elevation API. diff --git a/.changeset/pre/21908-principal-less-producers-final.md b/.changeset/pre/21908-principal-less-producers-final.md deleted file mode 100644 index ee1db8afa59..00000000000 --- a/.changeset/pre/21908-principal-less-producers-final.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/service-messaging": patch -"@objectstack/service-storage": patch -"@objectstack/service-settings": patch -"@objectstack/metadata-protocol": patch ---- - -The remaining platform producers in these four packages now pass the explicit system opt-in (`{ isSystem: true }`) on their data-engine calls. Until now they reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. - -Clause-②: no - -- **service-messaging, the inbox read state.** `listInbox` (and its unread total), the receipt read behind it, and mark-read / mark-all-read take the opt-in inside the service. Their scope is unchanged: every read of a user's rows is keyed on the user id the door derived from the session, the receipt a mark-read inserts is stamped with it, and the receipt it updates is one a user-keyed read returned. -- **service-messaging, `owner_of:` audiences.** The record read takes the opt-in, the same posture as the email lookup beside it. It reads only `id` and the owner fields, and only the owner id leaves the resolver. An `owner_of:` audience on an object whose sharing model is `private` now resolves its owner; before, it resolved to nobody. -- **service-messaging, the rest of the fan-out and the outboxes.** The `role:` and `team:` membership reads, the email and SMS recipient reads, the notification template read, the dedup lookup in `emit()`, and both outboxes' enqueue, ack and list. -- **service-storage.** `StorageMetadataStore.createFile` and `createSession` insert under the opt-in. The organization still reaches the driver beside it, so the stored organization is unchanged, and the file's `owner_id` is still the uploading user. -- **service-settings.** The `sys_secret` store the plugin builds (insert, get, update), and the read that verifies a rotation before the old secret is reaped. A store `update` now writes the `ciphertext` it is given; without a context the engine's read-only strip dropped it. No caller in this repository uses `update`. -- **metadata-protocol.** `SysMetadataRepository.getByHash`, `list`, `history` and the history replay of `watch()`. -- None of the gates the middleware runs before its hand-off applies to these calls. ⛔ No new export on any package entry, and no new elevation API. diff --git a/.changeset/pre/21908-principal-less-strict-mode.md b/.changeset/pre/21908-principal-less-strict-mode.md deleted file mode 100644 index 81eb9364b3c..00000000000 --- a/.changeset/pre/21908-principal-less-strict-mode.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/plugin-security': minor -'@objectstack/metadata-protocol': patch ---- - -feat(plugin-security)!: a data-engine context that carries no principal and is not a system context is refused (ADR-0096 D5 strict mode) - -Clause-②: no (narrowing) - - - -**BREAKING** for in-process code: an accept-set narrowing of the data engine's security middleware, shipped as `minor` under the launch-window convention for breaking changes. - -**What is refused now.** A data-engine operation whose execution context carries no principal (no user id, no position, no permission set) and is not a system context. That covers a call that passes no context at all, an empty one, one with only a tenant id, and one with only provenance fields. Every layer answers it the same way: - -- the engine middleware throws `PermissionDeniedError`, `403 PERMISSION_DENIED`, for every verb (find, findOne, count, aggregate, insert, update, delete), before the operation runs; -- `canReadObject`, `canExport` and `canWriteObject` answer `false`; -- `getReadFilter` answers the deny filter (zero rows). - -It used to be handed straight through, with no CRUD gate, no row-level security, no field mask and no tenant wall. That contradicted the published contract for an empty tool-execution context, "unauthenticated (RLS-on, sees-nothing)", which this change now keeps. The field projections (`getReadableFields` and its siblings) answer such a context as they answer any caller that resolves no permission set. - -**What is unchanged.** - -- A system context (`isSystem: true`) is admitted everywhere, as before. -- A context that carries a principal without a user id is decided by what it carries: a named permission set, the guest principal, or the public-form grant. -- An unauthenticated HTTP request is answered as before: a door that requires a session answers `401 UNAUTHENTICATED` before the engine is asked anything, and an endpoint an application declared open runs it as the guest principal. - -**What to do.** Code that calls the data engine in-process must state who it acts for. FROM: an engine call with no context, or with a context that names nobody. TO: either - -- pass the caller's execution context, so the caller's own permissions decide; or -- for platform plumbing whose own door already authorized the caller (a store read or write the platform owns), pass the explicit system opt-in, `context: { isSystem: true }`. - -The one-line fix: give every in-process engine call a context, the caller's or `{ isSystem: true }`. The refusal message names both. - -`@objectstack/metadata-protocol`: the protocol's own platform-store reads and writes behind the publish, history, audit, diff, commit, migration and code-only delete doors now pass the explicit system opt-in, like its other store calls. Nothing those doors answer changes. diff --git a/.changeset/pre/21922-metadata-protocol-meta-read-declines-code-datasource-row.md b/.changeset/pre/21922-metadata-protocol-meta-read-declines-code-datasource-row.md deleted file mode 100644 index a7fd70272eb..00000000000 --- a/.changeset/pre/21922-metadata-protocol-meta-read-declines-code-datasource-row.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -The metadata door serves a code-defined datasource's code definition while a stored row under its name still exists - -Clause-②: no - -- `GET /api/v1/meta/datasource/:name`, the `GET /api/v1/meta/datasource` list and the `effective` layer of `GET /api/v1/meta/datasource/:name/layers` now skip a stored `sys_metadata` row under a datasource name the host registers from code: one an installed package declares in `*.datasource.ts`, or the host's `default`. They serve the in-memory code definition instead. The datasource admin door and the boot restore already serve that ("code wins on collision"). Before this change the stored row was served first, so the two doors answered with two different bodies for one name. -- The decision is made by name, through the same predicate the reads already ask for a shipped flow name. It never reads a row's `origin`. Every other type keeps ADR-0005's read order, in which the stored overlay wins. -- Unchanged: the row stays at rest and is still reported in the layered read's `overlay`. The read envelope stays `deletable: true` while the row exists, and `DELETE /api/v1/meta/datasource/:name` still removes it as the repair. A draft read (`state: 'draft'`, or the draft preview) is still answered from the draft row. A runtime datasource's stored row is served as before. -- The `/meta` and admin doors already refuse to write such a row. This change affects only how a row left from before that refusal is read. -- Not moved: `GET /api/v1/meta/datasource/:name/published` still serves the stored row, which is the active overlay row that route describes. -- ⛔ No public export, signature, schema or accept-set change. The built entry declarations gain two `private` member names on `ObjectStackProtocolImplementation`. diff --git a/.changeset/pre/21923-datasource-origin-from-provenance.md b/.changeset/pre/21923-datasource-origin-from-provenance.md deleted file mode 100644 index 955db84b499..00000000000 --- a/.changeset/pre/21923-datasource-origin-from-provenance.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/service-datasource": patch ---- - -The datasource admin door and the metadata door agree on a runtime datasource: a datasource saved through `/api/v1/meta/datasource/:name` is listed and editable through `/api/v1/datasources` in the same boot and after a restart, and one created through the admin door can be edited and removed through the metadata door (#21923) - -Clause-②: no - -`DatasourceSchema.origin` publishes `runtime` as "created via the Studio wizard, persisted in the runtime metadata store, environment-scoped, editable", and says `origin` is never accepted from client input. Three things broke that for a datasource the metadata door wrote, or one the admin door wrote: - -- **The admin door read `origin` from the record.** It served `origin ?? 'code'`, so after a restart a datasource saved through the metadata door (whose body carries no `origin`, or asserts `origin: 'code'`) was served as code-defined, and `PATCH /api/v1/datasources/:name` answered `400 DATASOURCE_ADMIN_ERROR` ("… is code-defined and cannot be edited at runtime."). It now serves `code` only for a name the host registers from code (the host's code-datasource set, the one the boot restore and the metadata door's refusal read), and `runtime` for every other name, whatever the record says. Boot pool rehydration follows the served origin, so such a datasource also gets its live pool after a restart. -- **A metadata-door write never reached the admin door until a restart.** The metadata door persisted the row but never registered it where the admin door lists, so in the same boot `GET /api/v1/datasources` omitted it and `PATCH` answered "not found". The datasource-admin plugin now registers the protocol's awaited `datasource` mutation projector: after a metadata-door save, publish, revert, rollback or delete, the admin door's registry follows the stored row and the live pool converges on it (opened on a create, rebuilt on a connectivity change, evicted on a delete) before the metadata door answers. A write under a name the host defines in code changes nothing here, so the metadata door's `DELETE` of a leftover row under such a name still leaves the code definition served. -- **An admin-created row could not be edited or removed through the metadata door.** The admin door stored its `sys_metadata` row with no `checksum`, the column the metadata door's optimistic lock compares, so `PUT` and `DELETE /api/v1/meta/datasource/:name` answered `409 METADATA_CONFLICT` for every admin-created datasource. The admin door now stamps the checksum the metadata door's repository stamps (`hashSpec` from `@objectstack/metadata-core`, which becomes a runtime dependency of this package). A row stored before this release has no checksum until the admin door next writes it: editing that datasource through the admin door once makes it editable through the metadata door. - -Cluster convergence (a peer replica's signal after an admin-door write) decides "code" from the same set, so a stored row under a code-defined name never opens a pool there, and every other stored row is pooled as runtime. - -**Unchanged.** A code-defined datasource stays read-only through both doors: the admin door's `PATCH` and `DELETE` still answer `400 DATASOURCE_ADMIN_ERROR`, and the metadata door's still answer `403 NOT_OVERRIDABLE`. A host that composes no code-datasource producer (neither `AppPlugin` nor `DefaultDatasourcePlugin`) registers no set, and the admin door then serves every datasource as runtime, as its boot restore already treats every stored row. diff --git a/.changeset/pre/21967-view-expansion-per-package.md b/.changeset/pre/21967-view-expansion-per-package.md deleted file mode 100644 index 7798183f66a..00000000000 --- a/.changeset/pre/21967-view-expansion-per-package.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-protocol): another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of a view container are saved - -Clause-②: no - -- **What was wrong.** Where packages ship the same view container, the view list (`getMetaItems` for `view`) served one item for each name a saved environment-wide copy of that container expands: the copy's own expansion. Every other package's item of that name, shipped or saved, was left out. The anonymous form endpoints judge a withdrawal against the environment-wide view list, so they could miss another package's withdrawal of such a form. -- **What it does now.** The view list serves each package its own item of such a name: - - a package's saved copy of the container serves that package's item of each name it expands; - - a package-less saved copy stands in for every package that has no copy of its own (ADR-0048); - - any other package keeps its own item. - - So another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of the container are saved. The organization-scoped save check reads the same list, so it judges each package's item too. -- **A stored view row of exactly such a name** keeps its own package's slot only. A package-less row still serves every package's slot. Before, any package's row of the name kept every package's copy expansion of it out of the list. -- **The by-name read agrees.** `getMetaItem` naming a package serves the item that package's slot in the list serves. Where no copy belongs to that package, a package-less copy now stands in for it. A list scoped to a package (`GET /api/v1/meta/view?package=`) serves the same item in each slot the package lists. A package-less copy adds no item to that list. -- **What does not change.** Within one package, a later expansion of a name still replaces an earlier one, and the save door's view container collision check is unchanged. A by-name read that names no package answers as before. No key, export, status or error code changes. diff --git a/.changeset/pre/21968-spec-default-agent-chat-door.md b/.changeset/pre/21968-spec-default-agent-chat-door.md deleted file mode 100644 index a835eda35c8..00000000000 --- a/.changeset/pre/21968-spec-default-agent-chat-door.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`App.defaultAgent` docblock: the agent route is the one chat door, and the console is what reads the key. - -Clause-②: no - -- The docblock no longer says the assistant chat endpoint (`POST /api/v1/ai/assistant/chat`) resolves this agent from `context.appName`. That route, with `GET /api/v1/ai/assistant` and `GET /api/v1/ai/assistant/skills`, was retired in the cloud AI runtime (objectstack-ai/cloud#2621, objectstack-ai/cloud#2651), and no server route reads `defaultAgent`. -- It now says who does read it. The console's chat dock hands the active app's `defaultAgent` to its one surface-to-agent resolver, which honours only `ask` or `build` (legacy aliases included) and otherwise falls back to the surface default. The resolved agent is then called by name on `POST /api/v1/ai/agents/:agentName/chat`, where the path segment, not this key, selects the agent. -- The ADR-0063 surface-binding paragraph and the rule that only the two platform agents resolve are unchanged, as is the note that the bare `POST /api/v1/ai/chat` resolves no agent. -- The docs page `ai/actions-as-tools` lists the agent route as the only in-product chat route. -- ⛔ No schema, parse, `.describe()`, export, type or accept-set change. The docblock ships in the published package, in the `dist/ui` and `dist/browser` JavaScript bundles and in the shipped `src/ui/app.zod.ts`, which is why this is a patch. diff --git a/.changeset/pre/21972-caller-scoped-views-not-first.md b/.changeset/pre/21972-caller-scoped-views-not-first.md deleted file mode 100644 index fc2859c9ab7..00000000000 --- a/.changeset/pre/21972-caller-scoped-views-not-first.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -Setup's identity pages open on the tenant-wide list, not on the administrator's own rows. Before this, Setup → API Keys, Sessions, OAuth Applications, Identity Links and User Preferences opened each object's first declared list view, which was the caller-scoped "My …" view (`user_id = {current_user_id}`), so an administrator saw only their own keys, sessions, applications, links and preferences. - -Clause-②: no - -- On `sys_api_key`, `sys_session`, `sys_oauth_application`, `sys_account`, `sys_user_preference` and `sys_user`, the unscoped "All" view (`all_keys`, `all_sessions`, `all_apps`, `all_links`, `all_preferences`, `all_users`) is now declared first, and the caller-scoped view (`mine`, `me`) second. A route that names no view, such as a record page's object breadcrumb or the object switcher, now opens the "All" view. No view is added, removed or changed. -- The Setup entries `nav_api_keys`, `nav_sessions`, `nav_oauth_apps`, `nav_accounts` and `nav_user_preferences` now name that view with `viewName`, as `nav_users` already did. The Account app's Linked Accounts entry (`nav_account_linked`) now names `mine`, like the other Account entries, so neither app depends on the declared order. -- The "My …" views are still tabs on each page. The declared order decides which view opens, not which rows a caller may read: row-level security still scopes a member's rows. -- The generated translation bundles follow the new view order. No translated text changed. -- ⛔ No schema, parse, export or accept-set change. diff --git a/.changeset/pre/21972-record-shares-all-first.md b/.changeset/pre/21972-record-shares-all-first.md deleted file mode 100644 index b1bef852c37..00000000000 --- a/.changeset/pre/21972-record-shares-all-first.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/plugin-sharing": patch ---- - -Setup → Record Shares opens on every share, not on the shares granted to the administrator. Before this, the entry named no view, and `sys_record_share` declared the caller-scoped "Granted to Me" view (`recipient_id = {current_user_id}`) first. - -Clause-②: no - -- `sys_record_share` now declares its unscoped "All" view (`all_shares`) first. "Granted to Me" and "Granted by Me" follow it, still as tabs. No view is added, removed or changed. -- The Setup entry `nav_record_shares` now names `all_shares` with `viewName`, so it does not depend on the declared order. -- The generated translation bundles follow the new view order. No translated text changed. -- ⛔ No schema, parse, export or accept-set change. diff --git a/.changeset/pre/21973-nav-view-name-default.md b/.changeset/pre/21973-nav-view-name-default.md deleted file mode 100644 index 6e80d205830..00000000000 --- a/.changeset/pre/21973-nav-view-name-default.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`ObjectNavItemSchema.viewName`'s describe no longer says the default is "all". It now states what the console does when an object nav entry names no view: it opens the object's default list view, else its first declared list view. `all` is only the console's fallback tab, and it exists only for an object that declares no list view. - -Clause-②: no - -- The rule is read from objectui at the `.objectui-sha` pin. `ObjectView` opens `defaultViewId || views[0]`, where `defaultViewId` is the view `buildViewTabs` marks `isDefault` (the default `list`). `buildViewTabs` adds the `all` tab only when the object has no list view at all. -- An author who omitted `viewName` expecting all records got that default or first declared view instead. When the object declares more than one list view, name the one the entry should open in `viewName`. -- The generated app reference page follows (#21973). The lint header that quoted the old sentence follows too, as a comment only. -- ⛔ No schema, type, optionality, default, export or accept-set change. The console's behaviour does not change. diff --git a/.changeset/pre/21978-checksum-less-row-served-version.md b/.changeset/pre/21978-checksum-less-row-served-version.md deleted file mode 100644 index c6d515e4951..00000000000 --- a/.changeset/pre/21978-checksum-less-row-served-version.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -A metadata row stored with no `checksum` can be edited and removed through the metadata door (#21978) - -Clause-②: no - -- `SysMetadataRepository` serves a `sys_metadata` row that carries no `checksum` as the hash of its stored body, but its `put` and `delete` compared the caller's parent with the raw column (`null`). So `PUT` and `DELETE /api/v1/meta/:type/:name` answered `409 METADATA_CONFLICT` ("Expected parent … but current is null") for every such row, with `If-Match` set to the version the door served and with no `If-Match` (last-write-wins) alike. A publish over such a row was refused the same way, as were the rollback and commit-revert doors, which take their parent from the same read. The datasource admin door stored such rows before it stamped them. -- `put` and `delete` now accept the version such a row is served as. A `null` parent still matches it, and a row with a `checksum` is judged exactly as before. A stale version is still refused with `409 METADATA_CONFLICT`, and the refusal now names the row's served version as the current one instead of `null`. -- The next write stamps the row's `checksum`, as every write does. Stored rows are not rewritten. -- Publishing a draft row stored with no `checksum` now also removes that draft row. Before, the post-promotion cleanup was refused by the same lock and the draft stayed pending, with nothing reported. diff --git a/.changeset/pre/21980-copy-overlays-shipped-names.md b/.changeset/pre/21980-copy-overlays-shipped-names.md deleted file mode 100644 index f3ef8468e20..00000000000 --- a/.changeset/pre/21980-copy-overlays-shipped-names.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -A package's stored copy of a view container it ships overlays that package's shipped views, so a withdrawal saved in the copy holds at the anonymous form endpoints; the runtime save door refuses three copies it accepted before - -Clause-②: no (narrowing) - - - -**BREAKING** accept-set narrowing at the runtime save door, shipped as `minor` under the repo's launch-window convention for breaking changes, the grade the same door's earlier view container refusals shipped with. - -- **What was wrong.** The source loaders register a view container a package ships as `OBJECT.KEY` views for that package, whichever package owns the object. When that package stored a copy of the same container (a `PUT /api/v1/meta/view/NAME` of the container) and the object belonged to another code package, the copy expanded under its own name instead, as `OBJECT.CONTAINER.KEY` (a bare `list` as `OBJECT.CONTAINER`). So the copy overlaid none of the views its package ships: a form withdrawn from anonymous intake in the copy stayed open in the package's shipped form of that name, and the anonymous form endpoints kept serving it. -- **What it does now.** A package's stored copy of a container that package ships, bound to the same object, expands as the loaders expand the shipped container: each member is served under the loaders' name, `OBJECT.KEY`, in the copying package's own slot on the view list and on the by-name read naming that package. So a withdrawal saved in the copy holds at the anonymous form endpoints. The copy still declares no default view for an object another package owns. Any other container on another package's object keeps expanding under its own name, unchanged. -- **The by-name read on an unscoped kernel.** On a kernel with no environment id, a stored container's expanded views are also registered in the schema registry, under the bare name. A view whose name another package also ships is no longer registered there: the registry answered that bare entry ahead of the other package's own view, so `getMetaItem` naming the other package served this container's view. Every kernel's by-name read already serves such a view from its stored row, for its own package and for a read that names no package. Two packages that ship one container, with one of them storing a copy, were affected before this change too. - -**What is refused now.** `saveMetaItem`, which `PUT /api/v1/meta/view/NAME` and the dispatcher's metadata save both call, judges a copy at the names it now expands to, so three copies it accepted before are refused with `VALIDATION_ERROR` / 400, before anything is stored. Each is a package's copy of a container it ships on another package's object: - -- The copy adds a bare `list` whose name, `OBJECT.default`, only the other package ships. Before: accepted, served as `OBJECT.CONTAINER`. After: refused, naming the package that ships `OBJECT.default`. -- The copy adds a keyed member (a `formViews` or `listViews` entry, a named `list`, or a `form`) whose name, `OBJECT.KEY`, only the other package ships. Before: accepted, served as `OBJECT.CONTAINER.KEY`. After: refused, naming the package that ships `OBJECT.KEY`. -- Another stored container, saved under a different name, already expands a name the copy now expands. Before: accepted. After: refused, naming that stored container. - -**The fix.** For the first two, give the added member a key of its own that no package ships and no stored container expands, or save a view item (`name`, `object`, `viewKind`, `config`) under that name to override the other package's view. For the third, add the view as a member of the stored container that already expands the name, or save a view item under that name. - -**What still saves.** A copy that keeps the members its package's shipped container has, their contents edited, under the container's own name. A copy that adds a member under a key no package ships and no stored container expands. A view item under any of these names. Every container that is not a copy of its own package's shipped container, as before. - -**Rows stored before this change.** They keep their bytes. A stored copy of a container its package ships, on another package's object, is now served under the loaders' names (`OBJECT.KEY`) instead of `OBJECT.CONTAINER.KEY`, so it overlays the package's shipped views from the next read on, with no re-save. A reference to one of its old names (a navigation `viewName`, a form action `target`) no longer resolves; point it at `OBJECT.KEY`. A new save of a stored copy in one of the refused shapes, a re-save included, is refused until its body stops colliding. Delete stays open. diff --git a/.changeset/pre/21982-flow-builtin-node-config-undeclared-keys-refused.md b/.changeset/pre/21982-flow-builtin-node-config-undeclared-keys-refused.md deleted file mode 100644 index 36003ff0e79..00000000000 --- a/.changeset/pre/21982-flow-builtin-node-config-undeclared-keys-refused.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-automation': patch -'@objectstack/metadata-protocol': minor ---- - -A key a builtin flow node's executor contract does not declare is refused at parse, with a location, on every builtin but `try_catch`: a `notify` `bogusKey`, a screen field's `visibleIf`, a `create_record` `fieldValues`, an `http` `outputVariable` and the like no longer pass `objectstack validate` and `objectstack compile` and then get the whole flow refused at registration. - -Clause-②: yes (narrowing: an undeclared config key on 10 more builtins, every strict-contract builtin but try_catch, is refused at the build doors and the save door, where it passed; widening: a save door with no flow canonicalizer judges the D2-converted body, so a D2 alias spelling it refuses today, script functionName / input and subflow flow, is accepted again, as at every other door) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. - -**Why.** Each of these executors parses the node's `config` against a strict contract before it acts. The build doors' executor-contract arm judged required keys and present values on these types, but held key membership back, because `registerFlow`'s undeclared-key walk judged it against the node type descriptor's `configSchema`. So a `notify` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied the key into `dist/objectstack.json`), and then registration refused the whole flow: at boot it was skipped with a warn, and a flow saved from Studio was stored and then silently not registered. - -**What is refused.** A `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node, at any depth, whose config carries a key its executor contract does not declare: at the config itself, on a `screen` field, or on one of a field's `options`. That includes a body-less legacy `loop`, which is judged on key membership alone. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`, `nodes.N.config.fields.0.visibleIf`). It comes from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share. Its message carries the contract's own sentence, with the contract's prescription for a known slip (`fieldValues` → `fields`, `bulk` → `multi: true`, `visibleIf` → `visibleWhen`, a did-you-mean for a near miss), and closes with the remedy: rename the key to one the contract declares there, or remove it. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the save door. `script` and `subflow` keys were already refused this way. The new `builtinNodeConfigKeysJudged(nodeType)` export names exactly the builtins the spec judges. - -**`@objectstack/service-automation`.** `registerFlow`'s descriptor walk (`validateNodeConfigKeys`) stands aside for every type `builtinNodeConfigKeysJudged` names, so each node type has one judge. Before the move, the declared key sets were measured equal: on each of these types the descriptor's declared keys, at every position the walk descends to, equal the keys the contract accepts there. So registration refuses exactly what it refused before. The refusal now arrives as the parse's located issue instead of the walk's `Flow '…' rejected: N undeclared config key(s)` text. - -**`@objectstack/metadata-protocol`.** The save door judges a flow in its canonical spelling on every path. When no flow canonicalizer resolved (a host with no automation service), or the canonicalizer threw (a draft with a temporary cycle), the schema gate and the runtime authoring gate now judge the body with the ADR-0087 D2 conversions applied (`applyConversionsToFlow`), for the verdict only. The stored body stays the raw request body, exactly as before. A D2 spelling the load path still rewrites (`filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`; `functionName` and `input` on a `script`; `flow` on a `subflow`) is therefore accepted at that door, as at every other one, and an undeclared key that no conversion rewrites is refused there, located. This widens the fallback path back for the `script` and `subflow` aliases the previous key arm had begun refusing there. `duplicatePackage` re-saves through the same gate. - -**What stays as it was.** - -- `try_catch`: its undeclared keys stay registration's, judged by the walk. Its contract's `retry` is the shared `RetryPolicySchema`, which strips an unknown key, while its descriptor closes `retry` to `maxRetries`, `backoffMs`, `backoffMultiplier`, `maxRetryDelayMs` and `jitter`. Moving the judge would have widened registration. -- Every plugin node type (the spec does not declare its contract): judged by the walk against its descriptor's `configSchema`, with the walk's own prescriptions. -- A key at or under a region slot (a `loop` body or a `parallel` branch, and their nodes and edges): judged at registration by the region check, as before. -- A key inside a free-form map (`filter`, `fields`, `headers`, `defaults`, `input`, `payload`, `templateData`): author data, never judged. -- `assignment`, whose top-level keys are the author's variable names, and `decision`, which parses no contract: unjudged. -- A spelling an ADR-0087 D2 conversion still rewrites at load (`object` and `filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`, the save door). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| a typo of a declared key (`titl`, `outputVariabel`) | the declared key the refusal's did-you-mean names | -| `fieldValues` on `create_record` / `update_record` | `fields` | -| `bulk`, `all`, `multiple` or `options: { multi: true }` on `update_record` / `delete_record` | `multi: true` at the top level of `config` | -| `visibleIf` on a screen field | `visibleWhen` | -| `itemVariable` on a `loop` | `iteratorVariable` | -| `outputVariable` on an `http` node | delete it: the http executor binds no output variable | -| a key nothing reads | delete it | - -**The one-line fix: rename or delete the key the refusal names.** Registration already refused such a flow, so the fix changes nothing a running flow does. - -**Who is affected, measured.** At `fbcbcf124`, every builtin flow node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (95 nodes) carries only keys its contract declares. The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys whenever the engine publishes the node's descriptor. Its fallback form (while the descriptor list is loading, or when it cannot be read) offers `outputVariable` on an `http` node and the `url` alias on a `notify` node. Registration already refused the first and the conversion rewrites the second; the designer fix is tracked in objectui. hotcrm, deployed metadata and other repositories were not measured. - -### The kit - -- **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, over every builtin in `getBuiltinNodeConfigContracts()` but `try_catch`, named by the new export `builtinNodeConfigKeysJudged`; no new code joins `FLOW_SLOT_REFUSAL_CODES`. -- **The ledger.** The D3 semantic entry `flow-builtin-node-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. diff --git a/.changeset/pre/21982-flow-script-subflow-config-undeclared-keys-refused.md b/.changeset/pre/21982-flow-script-subflow-config-undeclared-keys-refused.md deleted file mode 100644 index a75c5ca923f..00000000000 --- a/.changeset/pre/21982-flow-script-subflow-config-undeclared-keys-refused.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': patch ---- - -A `script` or `subflow` flow node whose `config` carries a key its executor contract does not declare is refused at parse, with a location, in the contract's own words: a `script` `bogusKey`, a `subflow` `timeoutMs` written inside `config`, and the like no longer pass the build doors and registration and then fail every run. - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. - -**Why.** The `script` and `subflow` executors parse the node's `config` against a strict contract (`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared key. No door before the run judged one: `registerFlow`'s undeclared-key check reads the node type descriptor's `configSchema`, and these two descriptors publish none, while the build doors' executor-contract arm judged required keys and present values but not key membership. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied it into `dist/objectstack.json`), registered, and failed every run that reached the node: ``script 'n': config does not satisfy the script contract — config: Unrecognized key(s) on this script node config: `bogusKey` ``. - -**What is refused.** A `script` node, at any depth, whose config carries a key other than `function`, `inputs` and `outputVariable`, or a `subflow` node whose config carries a key other than `flowName`, `input` and `outputVariable`. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`), from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the metadata save door. - -**What stays as it was.** - -- Every other builtin node type: its undeclared keys are judged at registration against its descriptor's `configSchema`, with that check's own prescriptions, and the build doors do not judge them. -- `decision`: it publishes no descriptor `configSchema` either, but its executor parses no contract, so an undeclared key fails no run and stays unjudged. -- A retired `script` key (`actionType`, `template`, `recipients`, `variables`, `script`) keeps its tombstone path. -- A spelling an ADR-0087 D2 conversion still rewrites at load (`functionName` and `input` on a `script`, `flow` on a `subflow`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key, as its missing canonical key already was. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| a typo of a declared key (`funtion`, `outputVariabel`) | the declared key: `function`, `inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow` | -| a value the function or child flow should receive, as its own config key (`config: { function: 'f', taskId: '{record.id}' }`) | inside the input map: `config: { function: 'f', inputs: { taskId: { dialect: 'cel', source: 'record.id' } } }` (`input` on a `subflow`) | -| a `subflow` `config.timeoutMs` | on the node: `{ id, type: 'subflow', timeoutMs: 30000, config: { … } }` | -| a key nothing reads | delete it | - -**The one-line fix: rename, move or delete the key the refusal names.** The runtime never ran such a node, so the fix changes nothing a working flow does. - -**Who is affected, measured.** At `15ec50e528`, every `script` and `subflow` node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (8 nodes: 6 `script`, 2 `subflow`) carries only declared keys, and so does every one in hotcrm at `c9678036d9` (5 `subflow`, no `script`). The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys for both types (its `timeoutMs` field writes the node, not `config`), and seeds a new node with an empty `config`. Deployed metadata, and other repositories, were not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. - -**`@objectstack/lint`.** `validateStackExpressions` keeps the pre-conversion tolerance it declares: on a raw source, a `script` node's `functionName` alias stays the callable check's to read, not an undeclared-key error, while every other undeclared `script` key is refused there as at the build doors. - -### The kit - -- **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, judged for the builtins in the spec's schemaless class (`SCHEMALESS_NODE_CONFIG_SCHEMAS`) that have an executor contract; no new code joins `FLOW_SLOT_REFUSAL_CODES`, and `getBuiltinNodeConfigContracts()` keeps its 13 entries. -- **The ledger.** The D3 semantic entry `flow-script-subflow-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. diff --git a/.changeset/pre/21984-approval-requests-all-first.md b/.changeset/pre/21984-approval-requests-all-first.md deleted file mode 100644 index 59a12113fc3..00000000000 --- a/.changeset/pre/21984-approval-requests-all-first.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -Setup → Approvals → Requests opens on every approval request, not on the requests pending on the administrator. Before this, the entry named no view, and `sys_approval_request` declared the caller-scoped "My Pending" view (`pending_approvers contains {current_user_id}`) first, so the console opened it. - -Clause-②: no - -- `sys_approval_request` now declares its unscoped "All" view (`all_requests`) first. "My Pending", "I Submitted" and "Completed" follow it in their previous order, still as tabs. A route that names no view, such as a record page's object breadcrumb or the object switcher, now opens "All". No view is added, removed or changed. -- The Setup entry `nav_approval_requests` now names `all_requests` with `viewName`, so it does not depend on the declared order. The Account app's Approvals entry opens the Approvals Inbox component and reads neither. -- The declared order decides which view opens, not which rows a caller may read. -- The generated translation bundles follow the new view order. No translated text changed. -- ⛔ No schema, parse, export or accept-set change. diff --git a/.changeset/pre/21986-metadata-protocol-declines-stored-row-public.md b/.changeset/pre/21986-metadata-protocol-declines-stored-row-public.md deleted file mode 100644 index 79fad4e69de..00000000000 --- a/.changeset/pre/21986-metadata-protocol-declines-stored-row-public.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor ---- - -`ObjectStackProtocolImplementation.declinesStoredRow(type, name)` is now public, so a door that serves a stored row out of the layered read can ask the same decision the reads make - -Clause-②: yes (widening) - -- The method answers `true` for exactly the names whose stored `sys_metadata` row the active reads (`getMetaItem`, the list, and the `effective` layer of `getMetaItemLayered`) do not adopt: a flow name a managed package ships (the answer `isShippedFlowName` gives), and a datasource name the host registers from code (one an installed package declares in `*.datasource.ts`, or the host's `default`). Every other type and name answers `false`. -- The `GET /meta/:type/:name/published` doors in `@objectstack/rest` and `@objectstack/runtime` now ask this method in place of `isShippedFlowName`. A door asks it, and does not restate either half or the host's code-datasource set. -- `isShippedFlowName` stays public and unchanged. -- The only change to the public surface is this one added method. No signature, schema or accept set changes, and no behaviour of this package changes. diff --git a/.changeset/pre/21986-rest-published-door-code-datasource.md b/.changeset/pre/21986-rest-published-door-code-datasource.md deleted file mode 100644 index 95f62eea4f8..00000000000 --- a/.changeset/pre/21986-rest-published-door-code-datasource.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -`GET /api/v1/meta/datasource/:name/published` serves a code-defined datasource's code definition while a stored row under its name still exists - -Clause-②: no - -- For a datasource name the host registers from code (one an installed package declares in `*.datasource.ts`, or the host's `default`), the published door now serves the layered read's `effective` layer, which is the code definition. Before this change it served the leftover stored `sys_metadata` row, with that row's label, `origin` and connection settings, while `GET /api/v1/meta/datasource/:name`, the `/meta/datasource` list and `/layers` all served the code definition. The door now asks the protocol's `declinesStoredRow`, the one decision those reads make, in place of `isShippedFlowName`. A shipped flow name is answered as before. -- Unchanged: a runtime datasource's stored row is still what the door serves, and so is every stored row of every other type. A protocol that does not provide `declinesStoredRow` still gets the stored row. The row itself stays at rest, `/layers` still reports it in `overlay`, and `DELETE /api/v1/meta/datasource/:name` still removes it as the repair. diff --git a/.changeset/pre/21986-runtime-published-door-code-datasource.md b/.changeset/pre/21986-runtime-published-door-code-datasource.md deleted file mode 100644 index d721fe19249..00000000000 --- a/.changeset/pre/21986-runtime-published-door-code-datasource.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -The runtime dispatcher's `GET /meta/datasource/:name/published` serves a code-defined datasource's code definition while a stored row under its name still exists - -Clause-②: no - -- This is the dispatcher twin of the `@objectstack/rest` published door, and it now answers the same way. For a datasource name the host registers from code (one an installed package declares in `*.datasource.ts`, or the host's `default`), the door serves the layered read's `effective` layer, which is the code definition, instead of the leftover stored row. It asks the protocol's `declinesStoredRow` in place of `isShippedFlowName`. A shipped flow name is answered as before. -- Unchanged: a runtime datasource's stored row, and every stored row of every other type, is served as before. So is every row when the protocol does not provide `declinesStoredRow`. diff --git a/.changeset/pre/21995-measure-column-aggregate.md b/.changeset/pre/21995-measure-column-aggregate.md deleted file mode 100644 index 06b997295f9..00000000000 --- a/.changeset/pre/21995-measure-column-aggregate.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-analytics": minor ---- - -feat(spec,analytics): a dataset answer's measure column states its aggregate, labelled or not (`fields[].aggregate`) - -Clause-②: yes (widening) - -- **What a renderer can now read.** Each measure column of a dataset answer (`POST /analytics/dataset/query`) carries `fields[].aggregate`: the aggregate its dataset measure declares, in the closed `AggregationFunction` vocabulary (`count`, `sum`, `avg`, `min`, `max`, `count_distinct`). It is there whether or not the author gave the measure a `label`. So a chart can tell a count from a sum, for example to draw whole-number axis ticks for a count instead of 0.75 / 1.5 / 2.25. -- **What was missing.** The only aggregate on the wire was `builtinAggregate`, and it is present only when the measure has no `label`. A labelled measure, such as a `count` named "Tasks", reached the wire as `{ name, type: 'number', label }`, with nothing to say what kind of number it was. -- **Where it is set.** `AnalyticsService` writes it in the one step that describes a dataset answer's columns from the dataset's own measures. That step runs for both the live query and the draft-data preview, so the two answers agree. A measure's `__compare` column carries the same aggregate. -- **Where it is absent.** Dimension columns. Derived measures, which combine other measures and have no single aggregate (a stray `aggregate` written beside `derived` is ignored when the dataset compiles, so it is not stated here either). And a cube query answer (`POST /analytics/query`), which does not run through the dataset column step. -- **Unchanged.** `builtinAggregate` keeps its meaning: present only on a label-less measure column, to mark a header that is the server's default. No authoring key is added; `aggregate` is a response member only. `AnalyticsResultResponseSchema` and the `AnalyticsResult` contract declare the member, and the REST route relays it as it does every other column key. diff --git a/.changeset/pre/22012-isplatformadmin-standing.md b/.changeset/pre/22012-isplatformadmin-standing.md deleted file mode 100644 index 5741e597ee9..00000000000 --- a/.changeset/pre/22012-isplatformadmin-standing.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`EvalUserSchema.isPlatformAdmin` is no longer marked deprecated. Its describe and docblock now say what the key reports: the `PLATFORM_ADMIN` standing of ADR-0095 D3. - -Clause-②: no - -- The platform resolves that standing per request, from the deployment's declared administrator list (`OS_PLATFORM_OWNER_EMAIL`) under every tenancy posture, or from an unscoped `admin_full_access` grant under the `single` posture. -- It is the predicate platform-operator gates read: `current_user.isPlatformAdmin == true` (ADR-0068 D4). The session payload emits it from the posture rung, and the platform-admin route gate reads it. -- The resolver projects the `platform_admin` name into `positions` from the same grant, so the name and the key agree for every genuine administrator. Gate on the key, never on `'platform_admin' in current_user.positions`. -- The old text, "DERIVED alias of 'platform_admin' in positions. Deprecated.", described a reading ADR-0095 D3 superseded. ADR-0068 carries dated notes under D2 and D4 that say so. -- ⛔ Nothing you author changes. There is no schema, type, optionality, default, export or accept-set change, and `createEvalUser` computes exactly what it did. A predicate that already reads `current_user.isPlatformAdmin` keeps working and is the supported form. diff --git a/.changeset/pre/22019-object-save-door-formula-verdict.md b/.changeset/pre/22019-object-save-door-formula-verdict.md deleted file mode 100644 index ee415ce3801..00000000000 --- a/.changeset/pre/22019-object-save-door-formula-verdict.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor ---- - -fix(lint)!: the object save door refuses a formula field whose expression `os build` refuses (#22019) - -Clause-②: no (narrowing) - -`formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. At the object save door it did not. A formula field calling an unregistered function, such as `sqrt(record.amount)`, was refused by `os build` as an unknown function, but `PUT /api/v1/meta/object/:name` answered 200, stored it, and the field read `null` on every row. - -The runtime publish gate now runs the build's own formula check on an object write. The registry entry for the build's expression rule (`validateStackExpressions`) declared the flow, action and hook writes and never the object write, so the gate never dispatched it there. It now declares `object` as well, for one of its passes: a formula field's `expression`. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' expression`), message and hint. - -**BREAKING — what moves for consumers.** - -- An object write in publish mode answered 200 for a formula field whose expression the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that field's `expression`. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). -- The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), and the other errors in the build's formula check. Its warnings now ride the save response as advisories, as they already did for a flow write. - -**Remedy.** Fix the expression: the message names the unknown function or field and the position, as `os build` already requires. Use one of the functions `introspectScope` lists, qualify field reads as `record.FIELD`, or compute the value in a stored field and reference it. Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. - -**Unchanged.** - -- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps reading, with the formula still `null`, until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. -- This entry's crossing covers formula fields alone. The other expressions an object carries (validation-rule predicates, the field-rule slots `requiredWhen`, `readonlyWhen` and `visibleWhen`, option `visibleWhen`, and the object's own action predicates) are judged at this door through their own #22032 entries, each its own crossing, measured over the stored corpus first. -- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. -- Measured before crossing: every formula field this repository ships has 0 refusals and 0 advisories at the door. That is 29 fields on 28 objects: examples 7 on 6, and the platform `display_title` formulas 22 on 22. -- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature. The registry entry reaches the passes through an internal function that is not on the package's entry. The built entry declarations differ only in one doc comment, on `AuthoringRuleContext.runtimeWriteType`. - - diff --git a/.changeset/pre/22019-objectql-formula-fault-log.md b/.changeset/pre/22019-objectql-formula-fault-log.md deleted file mode 100644 index 0dc203b573c..00000000000 --- a/.changeset/pre/22019-objectql-formula-fault-log.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a formula field that does not evaluate is logged once per object and field, instead of reading `null` in silence (#22019) - -A formula the engine cannot evaluate reads `null`, on `find`, on `findOne` and on the write response. Before this change nothing said why. A formula calling an unregistered function (`sqrt(record.amount)`) read `null` on every row with no log line anywhere. ADR-0032 says a call site must not silently swallow an expression fault. - -The engine now reports the fault through its logger at `warn`, once per (object, field) per engine instance, however many rows and reads hit it. The line names the object, the field and the evaluator's error (kind and first line; the full message is in the log metadata). It also says where the repair is: `os validate` or a re-save of the object refuses an expression-level fault with a located message, and a fault that depends on a record's values needs a guard on the operands it reads. - -Unchanged: the field still reads `null`, because what a read returns is protocol. `evaluateFormulaField`, the hook-side helper with no engine, still returns `null` without a log line. The built entry declarations gain three `private` member names on `ObjectQL`. diff --git a/.changeset/pre/22024-no-package-read-wears-served-package.md b/.changeset/pre/22024-no-package-read-wears-served-package.md deleted file mode 100644 index 6746c586e44..00000000000 --- a/.changeset/pre/22024-no-package-read-wears-served-package.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -A by-name metadata read that names no package now wears the package of the body it serves - -Clause-②: no - -- **What was wrong.** `getMetaItem` with no `packageId` (behind `GET /api/v1/meta/TYPE/NAME` when no package is named) merges the registry artifact's protection envelope, `_packageId`, `_packageVersion` and `_provenance`, over the body it serves. With no package named, that envelope was the first-registered package's. When two installed packages ship one name and the body served was the other package's, the answer carried that body under the wrong package. Two cases were measured. In the first, both packages ship a view container and one of them stores a copy of it: the read served the copy's view and named the first-registered package. In the second, a stored row of the name is bound to one package. The answer's top-level `packageId` / `provenance` / `packageVersion` fields, which are read off the served item, said the same wrong thing. -- **What it does now.** With no package named, the envelope is looked up at the package the served item is bound to. That is the stored row's package, the package of the container copy that expands the name, or the `_packageId` of the MetadataService or registry item. This is the rule the `GET /api/v1/meta/TYPE` list already applies to each item it serves, so the list and the by-name read now give one envelope for one served body. -- **Unchanged.** Which body the read serves. A read naming a package. The lock family and the `lock` / `editable` / `deletable` envelope, which still come from the item-lock resolution over the read's own address. A served item bound to no package (a package-less stored row or copy, or a registry entry with no package) keeps the package-less lookup it had, so for a name only one package ships, a tenant's package-less overlay still wears that package's envelope. The layered read (`/layers`) is not changed. -- ⛔ No public export, signature, schema or accept-set change. Nothing is accepted or refused differently. diff --git a/.changeset/pre/22026-read-receipt-recipient-only.md b/.changeset/pre/22026-read-receipt-recipient-only.md deleted file mode 100644 index 173f412407f..00000000000 --- a/.changeset/pre/22026-read-receipt-recipient-only.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/service-messaging': minor ---- - -fix(service-messaging)!: mark-read writes a read receipt only for a notification delivered to that user - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention: `MessagingService.markRead` — the method behind the notifications mark-read door, and behind `markReadAsCaller` — writes a `read` receipt only for a notification that was delivered to that user. A read receipt belongs to a recipient (ADR-0030 keys it by recipient). - -- **Delivered** means a receipt keyed on that user already exists (it is flipped to `read` in place, as before), or the user's inbox holds a message for that notification. The inbox message is enough on its own, because the inbox channel's `delivered` receipt is best-effort. -- **Any other id** writes no receipt, is not counted in `readCount`, and its notification's organization is not read. The response shape `{ success, readCount }` is unchanged. -- **`markAllRead`** is unchanged: every id it sweeps comes from the user's own inbox. - -What changes for you: nothing in what you write. A `readCount` lower than the number of ids sent means some of them were not notifications delivered to that user, and nothing was written for those. diff --git a/.changeset/pre/22028-control-plane-url-docblock.md b/.changeset/pre/22028-control-plane-url-docblock.md deleted file mode 100644 index 7e62b103dc5..00000000000 --- a/.changeset/pre/22028-control-plane-url-docblock.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/cloud-connection": patch ---- - -`RuntimeConfigPluginConfig.controlPlaneUrl`'s published docblock no longer says that an empty string declares "this runtime IS the cloud". It now says what the constructor does: `''` keeps marketplace and install requests on this origin, and that is all it says. - -Clause-②: no - -- The runtime that passes `''` may serve the catalog itself or proxy a control plane it does not name. The CLI's cloud-connected `os serve` passes `''` while its marketplace proxy forwards to the control plane `resolveCloudUrl()` answers. So `''` reads neither as "this runtime is the cloud" nor as "there is no upstream". This matches the `AppShellRuntimeConfig.cloudUrl` doc in `@object-ui/app-shell`. -- A runtime with no control plane says so with a decline spelling (`'off'` / `'none'` / `'local'` / `'disabled'`), in `controlPlaneUrl` or in `OS_CLOUD_URL`. The docblock now says this too. -- ⛔ No code, type, export or default change. The served `cloudUrl` and the telemetry posture do not change. diff --git a/.changeset/pre/22032-object-save-door-action-predicates.md b/.changeset/pre/22032-object-save-door-action-predicates.md deleted file mode 100644 index ddde3eeba1b..00000000000 --- a/.changeset/pre/22032-object-save-door-action-predicates.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor ---- - -fix(lint)!: the object save door refuses an object action's `visible` or `disabled` predicate that `os build` refuses (#22032) - -Clause-②: no (narrowing: the object save door refuses an action visible or disabled predicate os build already refuses) - -`formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For an object's own actions it did not, at the object save door. An action whose `visible` read a bare field, such as `amount > 1`, or called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The action runtime evaluates such a predicate fail-closed, so the action was hidden on every record. - -The runtime publish gate now runs the build's action check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields, validation-rule predicates, the field-rule slots and option `visibleWhen`. On an object write it now also judges each `actions[].visible`, and each `actions[].disabled` that is not a boolean literal, as a predicate over `record`, the way the build does. Every expression an object carries is now judged at this door. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · action 'ACTION' visible`, or `disabled`), message and hint. One location differs, never the verdict: an action that is also declared in the stack's top-level `actions` is located by `os build` at `stack · action 'ACTION' visible`, and by the door, which judges the object alone, at the object. - -**BREAKING — what moves for consumers.** - -- An object write in publish mode answered 200 for an action whose `visible` or `disabled` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that action. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). -- The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), and a syntax error. Its warnings now ride the save response as advisories. -- The platform's own `sys_approval_request` (shipped by `@objectstack/plugin-approvals`) carries 8 action `visible` predicates that read `record.viewer`, a block the approvals service attaches on read and the object does not declare. The shared validator refuses all 8 (``unknown field `viewer` on `sys_approval_request` ``). Until #22211 lands, a save of that packaged object answers as follows. The first two rows were measured on the showcase's `objectstack dev` boot; the third is a source reading. - - | kernel and setting | publish save, before → after | draft save | - |:--|:--|:--| - | host-config kernel, no `OS_METADATA_WRITABLE` | 403 `NOT_OVERRIDABLE` → 422 `INVALID_METADATA` (8 issues) | 403 `NOT_OVERRIDABLE` | - | `OS_METADATA_WRITABLE=object` | 200 → 422 `INVALID_METADATA` (8 issues) | 200 | - | environment-scoped kernel | 403 `NOT_OVERRIDABLE`, unchanged | 403 `NOT_OVERRIDABLE` | - - On a host-config kernel the authoring gate answers before the package door, so there the 422 is returned where the more basic 403 belongs; #22220 carries that order. The object itself is unchanged: it registers, is served, and its actions evaluate as before. - -**Remedy.** Fix the predicate: the message names the unknown function or field, the bare reference, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and write a boolean literal (`true` / `false`) for a constant. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. For `sys_approval_request` there is nothing to repair on the consumer side: its predicates are the platform's, and #22211 carries the fix. - -**Unchanged.** - -- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. -- The stack's top-level `actions`, its flows, its sharing rules and its hooks are not judged on an object write: each is judged at its own type's door, as before. -- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. -- Measured before crossing: every object this repository ships (118 objects from its `*.object.ts` files) carries 58 action predicates on 16 objects, and the example stacks as `defineStack` composes them (33 objects, standalone actions merged in) carry 59 on 5. The shared validator refuses the 8 on `sys_approval_request` above and nothing else, and the door refuses the same 8 after this change and none before it. There are 0 other refusals and 0 advisories, at both doors. -- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. The built entry declarations differ only in one doc comment, on `AuthoringRuleContext.runtimeWriteType`. - - diff --git a/.changeset/pre/22032-object-save-door-field-rule-slots.md b/.changeset/pre/22032-object-save-door-field-rule-slots.md deleted file mode 100644 index 978f4b18cce..00000000000 --- a/.changeset/pre/22032-object-save-door-field-rule-slots.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor ---- - -fix(lint)!: the object save door refuses a field-rule slot whose predicate `os build` refuses (#22032) - -Clause-②: no (narrowing) - -`formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field's rule slots it did not, at the object save door. A field whose `requiredWhen` read a bare field, such as `amount > 1`, or whose `visibleWhen` called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. - -The runtime publish gate now runs the build's field-rule-slot check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields and validation-rule predicates. On an object write it now also judges each field's `requiredWhen`, `readonlyWhen` and `visibleWhen` the way the build does, with the build's three gates on them: the `parent` gate, the null-guard check over `requiredWhen`, and the refusal of a `requiredWhen` or `readonlyWhen` that reads through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' SLOT`), message and hint. - -**BREAKING — what moves for consumers.** - -- An object write in publish mode answered 200 for a field whose `requiredWhen`, `readonlyWhen` or `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that slot. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). -- The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, a root a field-level rule never binds (such as `current_user`), a `parent` read on an object that does not declare exactly one `master_detail` relationship, an ordering or arithmetic operator in `requiredWhen` applied to a nullable field with no `!= null` guard, and a `requiredWhen` or `readonlyWhen` that reads through a reference field (`record.account.tier`, or `parent.REF.FIELD`). Its warnings now ride the save response as advisories. -- A detail object's `requiredWhen` or `readonlyWhen` that reads through one of its master's reference fields (`parent.REF.FIELD`) is judged whenever the master is in the write's context, and that includes a save of the master itself. So a master save can answer 422 with an issue located at a stored detail's field. Fix the detail's predicate, then save the master again. - -**Remedy.** Fix the predicate: the message names the unknown function or field, the unbound root, the unguarded operand or the reference read, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, guard a nullable operand in `requiredWhen` with `record.FIELD != null && …`, and move a check that must read through `record.REF` into a `validations[]` `script` rule, whose `condition` is read one hop through a reference; a read through `parent.REF` has no such surface, so read a column the master declares instead (denormalise the value onto it). Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. - -**Unchanged.** - -- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. -- `conditionalRequired` is still refused at the save door's schema step, before this gate, as a key retired in protocol 17; `os build` judges it as a field-rule slot as before. -- Option `visibleWhen` and the object's own action predicates are judged at this door through their own #22032 entries, each its own crossing, not through this one. -- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. -- Measured before crossing: every field-rule slot this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 9 slots on 8 fields of 3 objects (examples: 8 on `showcase_invoice` and `showcase_invoice_line`, three of them `parent`-scoped; the platform: 1 on `sys_permission_set`), over the 118 objects this repository ships. -- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. - - diff --git a/.changeset/pre/22032-object-save-door-option-visible-when.md b/.changeset/pre/22032-object-save-door-option-visible-when.md deleted file mode 100644 index 3724a66aff3..00000000000 --- a/.changeset/pre/22032-object-save-door-option-visible-when.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor ---- - -fix(lint)!: the object save door refuses a field option's `visibleWhen` that `os build` refuses (#22032) - -Clause-②: no (narrowing) - -`formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field option's `visibleWhen` it did not, at the object save door. An option whose `visibleWhen` read a bare field, such as `amount > 1`, or called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The server's option check cannot evaluate such a predicate and lets the value through, so the gate it declares is never enforced. - -The runtime publish gate now runs the build's option check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields, validation-rule predicates and the field-rule slots. On an object write it now also judges each `fields[].options[].visibleWhen` the way the build does: as a predicate over `record` and `previous`, and with the build's refusal of a read through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`), message and hint. - -**BREAKING — what moves for consumers.** - -- An object write in publish mode answered 200 for an option whose `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). -- The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, and a read through a reference field (`record.account.tier`, `previous.account.tier`). Its warnings now ride the save response as advisories. - -**Remedy.** Fix the predicate: the message names the unknown function or field, the bare reference or the reference read, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and compare a reference field as a value (`record.account != null`) rather than read through it. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. - -**Unchanged.** - -- `current_user` is still accepted in an option's `visibleWhen`, as the build accepts it: the option evaluator binds the acting user (ADR-0068 D1). A role gate such as `'org_admin' in current_user.positions`, or a grant check such as `current_user.can('OBJECT', 'edit')`, still saves. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen` it is still refused, as before. -- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. -- The object's own action predicates (`actions[].visible`, `actions[].disabled`) are judged at this door through their own #22032 entry, its own crossing, not through this one. -- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. -- Measured before crossing: this repository ships 5 option predicates, all on `showcase_cascade` (four `record.country` cascades and one `current_user.positions` role gate), among the 118 objects it ships. They have 0 refusals and 0 advisories, at the build and at the door. -- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. - - diff --git a/.changeset/pre/22032-object-save-door-validation-predicates.md b/.changeset/pre/22032-object-save-door-validation-predicates.md deleted file mode 100644 index ec00a3cdf57..00000000000 --- a/.changeset/pre/22032-object-save-door-validation-predicates.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor ---- - -fix(lint)!: the object save door refuses a validation rule whose predicate `os build` refuses (#22032) - -Clause-②: no (narrowing) - -`formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a validation rule's predicates it did not, at the object save door. A rule whose `condition` called an unregistered function, such as `sqrt(record.amount) > 1`, or read a bare field, such as `amount > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The rule then faulted on every write it judged. - -The runtime publish gate now runs the build's validation-rule check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields alone. On an object write it now also runs its validation-rule pass: each `validations[]` rule's `condition` and a `conditional` rule's `when`, plus the null-guard check over every predicate the rule carries, its nested `then` and `otherwise` rules included. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · validation 'RULE'`, or `… validation rule 'RULE' then → 'CHILD'` for a nested predicate), message and hint. - -**BREAKING — what moves for consumers.** - -- An object write in publish mode answered 200 for a validation rule whose predicate the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). -- The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, an ordering or arithmetic operator applied to a nullable field with no `!= null` guard (`has()` is no guard here), and the other errors in the build's validation-rule check. Its warnings now ride the save response as advisories. - -**Remedy.** Fix the predicate: the message names the unknown function or field, or the unguarded operand, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and guard a nullable operand with `record.FIELD != null && …`. Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. - -**Unchanged.** - -- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. -- This entry's crossing covers validation-rule predicates. The field-rule slots (`requiredWhen`, `readonlyWhen`, `visibleWhen`), option `visibleWhen` and the object's own action predicates are judged at this door through their own #22032 entries, each its own crossing. -- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. -- Measured before crossing: every validation rule this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 21 rules carrying 13 predicates on 10 objects: examples 11 predicates on 7 objects, and the platform objects 2 on 3 (one rule on `sys_user` carries no predicate). -- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. - - diff --git a/.changeset/pre/22042-nested-validation-predicate-verdict.md b/.changeset/pre/22042-nested-validation-predicate-verdict.md deleted file mode 100644 index 25f5dadf369..00000000000 --- a/.changeset/pre/22042-nested-validation-predicate-verdict.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor ---- - -fix(lint)!: a `conditional` validation rule's nested `then` / `otherwise` predicate meets the same expression verdict as the rule's own (#22042) - -Clause-②: no (narrowing) - -A `conditional` validation rule applies its `then` rule when its `when` holds and its `otherwise` rule when it does not, and the rule validator evaluates either branch as a rule of its own. `os build` judged a rule's own `condition` and `when` with the shared `validateExpression` validator, but reached the predicates inside `then` / `otherwise` with the null-guard check alone. So a nested `condition` that called an unregistered function, such as `sqrt(record.amount) > 1`, or read a bare field, such as `amont > 1`, passed `os build`. The object save door gives the build's verdict, so `PUT /api/v1/meta/object/:name` stored it, and the rule then refused every write it judged, because a validation rule that cannot be evaluated fails closed. The same predicate one level up was refused at both doors. - -The build's expression rule (`validateStackExpressions`) now runs the same check on every predicate nested in a `conditional` rule, at every depth: each nested `condition`, and the `when` of a `conditional` nested inside a branch. A nested `condition` also gets the relationship-traversal checks, because ObjectQL hydrates a one-hop read there, as it does at the top level. A nested `when` does not, because the evaluator never hydrates a `when`, as at the top level. A nested finding is located at the nested rule, the location the null-guard check already gave it: `object 'OBJECT' · validation rule 'OUTER' then → 'INNER'`, with `when-predicate` appended for a nested `when`. A rule's own `condition` and `when` keep their findings and their location (`object 'OBJECT' · validation 'NAME'`) and are judged once. - -**BREAKING — what moves for consumers.** - -- `os build`, `os validate` and `os lint` now refuse, at `error`, a stack whose `conditional` validation rule carries a nested predicate the shared validator refuses. The validator's warnings on a nested predicate are now reported too, and at the save door they ride the response as advisories. -- An object write in publish mode that carries such a rule answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at the nested rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode) and the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`). -- The verdict is the one a rule's own `condition` already got: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, and, for a nested `condition`, a reference field read both through the relationship and as a value, or a read deeper than one hop. - -**Remedy.** Fix the nested predicate the way the same predicate is fixed at the top level: the message names the unknown function or field and the position. Qualify field reads as `record.FIELD`, and use one of the functions `introspectScope` lists. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. - -**Unchanged.** - -- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. -- A rule's own `condition` and `when` are judged exactly as before, at the same location; the null-guard check over every predicate is unchanged. -- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. -- Measured before crossing: this repository ships one `conditional` validation rule with nested predicates (`showcase_account.churn_reason_consistency`, two nested `condition`s), among 21 validation rules on the 118 objects it ships. Both have 0 refusals and 0 advisories, at the build and at the door. -- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. - - diff --git a/.changeset/pre/22044-shared-entry-drops-conversion-table.md b/.changeset/pre/22044-shared-entry-drops-conversion-table.md deleted file mode 100644 index ad8e1965bd4..00000000000 --- a/.changeset/pre/22044-shared-entry-drops-conversion-table.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -A browser bundle that imports from `@objectstack/spec/shared` or the package root no longer keeps the ADR-0087 conversion table unless it uses it - -Clause-②: no - -Each published entry is one flat file, so a consumer's bundler keeps every top-level call it cannot prove pure, together with everything that call references. Two such calls built the conversion table when the module loaded: the major-18 list's `inApplicationOrder(...)` and the flattening into `ALL_CONVERSIONS`. So every bundle of an entry that reaches the table kept all of it: every conversion, plus the view, field, page-component, dashboard, chart and report schemas the conversions read. `./shared` reaches the table only through `normalizeStackInput`, so a bundle that imported an expression schema from it carried the table too, and 17.7.0's new conversions made that copy larger. Both calls now carry a `@__PURE__` annotation, so a bundle keeps the table only when something it keeps reads it, for example `defineStack`, `normalizeStackInput` or `applyConversions`. - -Measured on objectui's console (objectui `c0862c1c`), against the same build with this package's previous source: the first screen's eager closure is 226,238 bytes gzip smaller, all of it in the `vendor-objectstack` chunk. Every entry's export list, every declaration and every runtime value is unchanged. Only the bytes a bundler keeps change. diff --git a/.changeset/pre/22046-upload-doors-uploader-only.md b/.changeset/pre/22046-upload-doors-uploader-only.md deleted file mode 100644 index 87419941eb8..00000000000 --- a/.changeset/pre/22046-upload-doors-uploader-only.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/service-storage': minor ---- - -fix(service-storage)!: the upload commit, chunked-completion and progress doors act only for the user who started the upload - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -`POST /api/v1/storage/upload/complete`, `POST /api/v1/storage/upload/chunked/:uploadId/complete` and `GET /api/v1/storage/upload/chunked/:uploadId/progress` now act only for the user who started the upload. The rule is declared once and each of the three doors consults it: the signed-in user must be the file's `owner_id`, which both upload-start doors already stamp from the session. A chunked upload reaches its uploader through its file. - -- **Refused now:** every other caller, with `403 PERMISSION_DENIED`. The answer is the same whichever organization the caller acts in, and it comes before anything is written or returned. There is no administrator exception. A file with no recorded uploader, or a chunked upload whose file is gone, is refused the same way rather than given a guessed owner. -- **Unchanged:** the uploader's own calls, who may start an upload, who may download a file (the download doors' own authorization), the organization the doors stamp and scope by, and the not-found answers. A deployment with no `auth` service, whose upload routes run without a session resolver, keeps them open as before. - -What changes for you: complete and poll an upload as the same signed-in user who started it. An upload left pending with no recorded uploader cannot be committed; start it again. diff --git a/.changeset/pre/22047-spec-ui-anonymous-form-intake.md b/.changeset/pre/22047-spec-ui-anonymous-form-intake.md deleted file mode 100644 index 251ce8161f0..00000000000 --- a/.changeset/pre/22047-spec-ui-anonymous-form-intake.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-core': patch ---- - -`@objectstack/spec/ui` now exports the rule that decides which forms a `view` body opens to anonymous intake, so a console reads "published" from the same rule the server's anonymous form doors serve - -Clause-②: yes (widening) - -- **New on `@objectstack/spec/ui`:** `publicFormSlug`, `anonymousFormIntakeSlug`, `anonymousFormIntakeCandidates`, `anonymousFormIntakeSlugs` and the `AnonymousFormIntakeCandidate` type. They lived only in `@objectstack/metadata-core`, which a browser console should not depend on. They are pure functions with no imports, beside the `SharingConfigSchema` they read. -- **What they decide is unchanged.** A form is open when its `sharing` has `enabled === true`, `allowAnonymous === true` and a non-empty `publicLink`. The scan covers the same three shapes in the same order: the nested `form`, every `formViews` entry, then the `config` of a `viewKind: 'form'` item. -- **`@objectstack/metadata-core` re-exports the same functions** from `@objectstack/spec/ui`. They are the spec's own bindings, not wrappers or copies, so there is still one copy of the rule. Its exports, names and types are unchanged, and `@objectstack/rest` and `@objectstack/metadata-protocol` keep importing from it. Its built output now loads `@objectstack/spec/ui` to get them. -- **Not covered by the new export:** whether another metadata layer withdraws a form (`anonymousFormIntakeWithdrawnIn`), and whether the deployment's tenancy posture lets the form take an anonymous submission (`anonymousFormIntakeUnavailability`). These two read server state and stay in `@objectstack/metadata-core`. `anonymousFormObjectName`, which names the object a form submits into, stays there beside them; it is a pure read of the form and the view, not of server state. A form the new functions call open can still be withheld by a withdrawal in another layer or by the posture. diff --git a/.changeset/pre/22049-share-password-header-encoding.md b/.changeset/pre/22049-share-password-header-encoding.md deleted file mode 100644 index bd58704fb11..00000000000 --- a/.changeset/pre/22049-share-password-header-encoding.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/types": minor -"@objectstack/plugin-sharing": minor -"@objectstack/runtime": minor -"@objectstack/plugin-hono-server": minor -"@objectstack/hono": minor ---- - -feat(sharing): a share-link password can be sent in the `X-Share-Password` header whatever its characters, under a declared encoding (`X-Share-Password-Encoding: utf-8`) - -Clause-②: yes (widening) - -- **What was missing.** A browser cannot put a character above U+00FF in a request header (`Headers` throws a `TypeError` before the request leaves), and it strips leading and trailing spaces. `createLink` accepts any password, so a link whose password has a CJK character or an emoji could not be opened through the header. -- **What is now accepted.** A new companion request header, `X-Share-Password-Encoding`, declares how `X-Share-Password` is encoded. Its one value is `utf-8`, compared case-insensitively. Under it, `X-Share-Password` carries the password's UTF-8 bytes percent-encoded, as `encodeURIComponent(password)` produces them, and both public routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) decode it on both mounts: the sharing plugin's routes and the runtime dispatcher's `/share-links` domain. Both read the pair through one helper, `readSharePasswordHeader`, exported from `@objectstack/types` with the header-name constants. -- **Unchanged.** Without `X-Share-Password-Encoding`, `X-Share-Password` is read raw, exactly as before, so every value a client sends today resolves as it did. That includes a Latin-1 password and a raw password containing `%`; the server never percent-decodes a value nobody declared encoded. The `?password=` query parameter is still read first, and when it is present the header pair is not read. -- **What is refused.** `X-Share-Password-Encoding` naming any other value, or a password header that is not percent-encoded UTF-8 under `utf-8` (a `%` without two hex digits, octets that are not well-formed UTF-8, a character outside visible ASCII), answers `400 VALIDATION_FAILED` before the token is looked up. It is never compared raw instead. The message names the headers and the rule, never the presented value. -- **Response headers.** Both public routes now answer `Vary: X-Share-Password, X-Share-Password-Encoding`, still beside `Cache-Control: no-store`. -- **Cross-origin clients.** `X-Share-Password-Encoding` is in the default CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS` in `@objectstack/plugin-hono-server`, which the `@objectstack/hono` adapter also applies). A deployment that passes its own `allowHeaders` must add `X-Share-Password-Encoding` beside `X-Share-Password` to let a cross-origin client send an encoded password. diff --git a/.changeset/pre/22054-notify-title-template-input.md b/.changeset/pre/22054-notify-title-template-input.md deleted file mode 100644 index 67820c15a14..00000000000 --- a/.changeset/pre/22054-notify-title-template-input.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-automation": patch ---- - -feat(spec)!: `NotifyConfigSchema.title` and `NotifyConfigSchema.message` are template slots: each takes a bare string or a `{ dialect: 'template', source }` envelope (the `tmpl` helper), as the expression dialect table already listed notification subjects and bodies among the `template` slots, and a blank bare string is now refused there - -Clause-②: yes (narrowing) - - - -**BREAKING** accept-set narrowing at two authorable keys (`automation/NotifyConfig:title`, `automation/NotifyConfig:message`), shipped as `minor` under the repo's launch-window convention for breaking changes. It is the grade `TemplateExpressionInputSchema`'s blank-string rule shipped with when it reached the first twelve typed keys. - -- **What widens.** Both keys are typed with `TemplateExpressionInputSchema`, the input every other `template` slot uses. Before, both were `z.string()`, so a notify node written with `` tmpl`…` `` passed `defineFlow` and registration and then failed every run at the execute-time contract parse (`expected string, received object`). It now parses and runs. -- **What narrows.** A blank bare string (`''` or whitespace-only) at either key is newly refused, by the shared template input's non-blank rule (`invalid_union`, with the `TYPED_EXPRESSION_SOURCE_REQUIRED.template` sentence). Before, every blank value parsed: - - `title: ''` then failed every run at the executor's guard ("notify: title is required"), so it fails either way, now earlier; - - a whitespace-only `title` passed that guard and was delivered as the notification title, and it is now refused; - - `message: ''` or a whitespace-only `message` was delivered as an empty or blank body, and it is now refused. - - The fix is to write the text, or to delete the key (`message` is optional). -- **Parse output.** `NotifyConfigSchema.parse(...).title` and `.message` go from `string` to `{ dialect: 'template', source }`, for both spellings, because the parse normalizes a bare string to that envelope. The exported `NotifyConfigParsed` type changes with them. Code that reads parse output reads `.source`. The `notify` executor, the one reader in this repo, now does, so both spellings of one text deliver the same `payload.title` and `payload.body`, and a bare string renders exactly what it rendered before. -- **Still refused, with a new sentence.** A value that is neither a string nor a template envelope (a number, an array, a `cel` envelope) was refused before (`invalid_type`). It is refused now as `invalid_union`, with the `TYPED_EXPRESSION_DIALECT_ONLY.template` sentence. -- **New, notify-only.** A template envelope on either key must carry a non-blank `source`. The executor renders `source` and has nothing to render from `ast` alone, so such an envelope is refused at the key instead of failing every run (`title`) or sending an empty body (`message`). An envelope never parsed at these keys before, so this refuses nothing that used to parse. -- **Placeholder spelling.** These two slots render `{{ }}` holes (`{{ record.name }}`), for a bare string and an envelope alike, and a single-brace `{token}` in them is refused. The `.describe()` on both keys now says so, and no longer says the text is "sent verbatim". -- `@objectstack/service-automation`: the `notify` executor reads `source` from the two template slots, and the descriptor's `title` / `message` descriptions state the `{{ }}` holes in place of "sent verbatim". diff --git a/.changeset/pre/22057-bound-row-not-under-shared-bare-name.md b/.changeset/pre/22057-bound-row-not-under-shared-bare-name.md deleted file mode 100644 index 881697e0a7b..00000000000 --- a/.changeset/pre/22057-bound-row-not-under-shared-bare-name.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -A by-name view read naming a package now serves that package's view when another package's stored row has the same name - -Clause-②: no - -- **What was wrong.** Two installed packages can ship a view of one name, and a stored `sys_metadata` view row can be bound to one of them. The registry hydration registered that row under the view's bare name. The registry answers the bare name ahead of every package's own entry, whichever package the read names. So `getMetaItem` naming the OTHER package found no row of its own and served this row's body at its registry step, under the other package's envelope. That is the read behind `GET /api/v1/meta/view/NAME?package=…`. Meanwhile the list's slot for that package served its own view. This was measured on an unscoped kernel after a save, and on either kernel after a cold boot, because `loadMetaFromDb` hydrates through the same door on every kernel. -- **What it does now.** A view row bound to one package is not registered under a name another package ships. This is the shape the view-container expansion registration already takes. The reads answer the row from the row itself: the read naming its own package, the read naming no package, and the list's slot for that package. The read naming the other package serves that package's own view and envelope. The delete's registry heal also stops re-registering a metadata-service view baseline bound to one package under such a name. -- **Scope: `view` only.** Every other type registers as before: a row bound to one package, of a name two packages ship, keeps the bare entry with its own body and its own package's envelope. A package-less view row, and a view row of a name that only its own package ships or that no package ships, also register as before. The environment-scoped kernel's answer after a save is unchanged, because it registers nothing on a save. -- ⛔ No public export, signature, schema or accept-set change. Nothing is accepted or refused differently. The built entry declarations gain one `private` member name on `ObjectStackProtocolImplementation`. diff --git a/.changeset/pre/22062-email-template-boot-sweep.md b/.changeset/pre/22062-email-template-boot-sweep.md deleted file mode 100644 index 2a094c70c53..00000000000 --- a/.changeset/pre/22062-email-template-boot-sweep.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/plugin-email': patch ---- - -The declared-email-template boot sweep reads the stored rows in bulk and no longer rewrites a template that has not changed - -Clause-②: no - -Every boot used to look each effective email template up on its own and rewrite its -`sys_email_template` row unconditionally: one lookup, one UPDATE and the engine's two read-backs -per template, whether or not anything had changed. Measured on `ObjectQL` over `SqlDriver` -(better-sqlite3), a steady boot over 450 unchanged templates sent 1,800 statements; it now sends 3 -reads and no write (79 templates: 316 statements, now 1). - -- The sweep reads the stored rows for the declared names in `$in` pages of 200 names. Each - `(name, locale)` takes the first row the read returns, in the order the driver gives the - per-template lookup, so a slot several organizations hold resolves to the same row as before. -- A template the bulk read did not answer (new since the last boot, a read cut short at its row - bound, or a failed read) is looked up on its own before anything is inserted, as before. -- A `managed_by: 'package'` row that already holds every column the template projects is not - rewritten. `upsertDeclaredEmailTemplate` now returns `false` for it, and - `bootstrapDeclaredEmailTemplates` counts it under `skipped`, the value both already document for - a row deliberately not written. A row with any other provenance is handled exactly as before: - admin-owned and customized rows are never written, and a legacy row with no `managed_by` is - rewritten and adopted. - -An org-scoped template edit still survives the next boot. Signatures and accepted input are unchanged. diff --git a/.changeset/pre/22067-seed-claim-skip-automations.md b/.changeset/pre/22067-seed-claim-skip-automations.md deleted file mode 100644 index 468418855a7..00000000000 --- a/.changeset/pre/22067-seed-claim-skip-automations.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -The seed ownership claim no longer fires app hooks or record-change flows - -Clause-②: no - -On a freshly seeded database, the first sign-up promotes the first user to platform admin, and `claimSeedOwnership` hands every seeded row to that admin inside the same request. That write ran under a bare system context, so every claimed row went through the full write pipeline: hooks bound from app metadata fired, record-change flows ran, approvals opened on seeded records and notifications went to the new admin. Measured on hotcrm `56d98f7e` (17.7.0, a 354-row seed), the first sign-up took about 45 s, and in that time the claim fired 1,254 app hooks, ran 8 flows, opened 2 approvals and handed 8 emails to the transport. - -The claim's write now runs with `{ isSystem: true, skipAutomations: true }`. The seed itself is end-state data written without automation, and the claim keeps that rule for the write that completes it. - -- **App hooks no longer fire for the seed ownership claim.** No hook bound from metadata (an app's `hooks`, sandboxed bodies included) runs on the claim's owner change, and no record-change flow is dispatched for it. So the claim opens no approval and sends no notification. -- **Which claims.** Every pass of the claim: the promotion pass inside the first sign-up, and the pass that runs on `app:seeded` on every boot, the first and every later one. -- **Still runs.** Hooks that plugins register in code still run, so the claim still writes one audit row per claimed record (plugin-audit) and plugin-sharing still recomputes the grants the owner change earns. Every claimed row's `owner_id` is the admin and its `updated_at` still advances, exactly as before. The per-row hook ceiling and the paged fallback for very large objects are unchanged. -- **If an app relied on it.** An app hook or flow that reacted to the claim's owner change no longer sees it, just as it never saw the seed's own writes. None of the hooks or flows in ObjectStack's own example apps reads it. diff --git a/.changeset/pre/22070-audit-stamp-under-skip-automations.md b/.changeset/pre/22070-audit-stamp-under-skip-automations.md deleted file mode 100644 index a87d6390a66..00000000000 --- a/.changeset/pre/22070-audit-stamp-under-skip-automations.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -`skipAutomations` no longer skips ObjectQL's own audit stamps: a data import with "run automations & triggers" unchecked stamps `created_by` / `updated_by` from the session user again - -Clause-②: no - -`ExecutionContext.skipAutomations` suppresses the lifecycle hooks bound from metadata and, by its own -description, never bypasses audit. ObjectQL's builtin audit stamps (`sys_stamp_audit_insert` on -`beforeInsert`, `sys_stamp_audit_update` on `beforeUpdate`) were registered through the hook binder, -so they carried the metadata binding the opt-out keys on and were skipped together with the app's -hooks. Rows that a data import wrote with automations off landed with no `created_by` / `updated_by` -and no value in a declared plain `tenant_id` field. On a driver that does not stamp its own -timestamps, they also landed with no `created_at` / `updated_at`. The organization column was not -affected: the driver stamps it from the engine's driver options either way. - -The builtins are now registered in code (`registerHook`, no metadata binding), which is how the -contract describes audit. They run under `skipAutomations` exactly as they run without it, and they -keep the same wrapper, priority and `sys:audit` package id. Every hook bound from metadata is still -skipped under the flag. ⛔ Nothing you author changes: no key, option, export or type is added or -removed. diff --git a/.changeset/pre/22071-runtime-assets-boot-warning.md b/.changeset/pre/22071-runtime-assets-boot-warning.md deleted file mode 100644 index 0091966d5dd..00000000000 --- a/.changeset/pre/22071-runtime-assets-boot-warning.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os serve` (and `objectstack start`, which runs it) now says at boot when an app's branding logo or favicon will not be served. Before, the runtime assets route was skipped without a word when its directory was absent, so an artifact booted outside its project directory drew a broken logo and favicon and nothing in the boot output said why. - -Clause-②: no - -- Once the boot settles, every loaded app whose `branding.logo` or `branding.favicon` is a root path under `/runtime/assets/` that the route will not serve gets one warning line per file. The line names the apps and keys that use the file, the directory searched, and whether that directory came from `OS_RUNTIME_ASSETS_DIR` or the `assets/` default under the working directory. When the directory does not exist, the line says so and says nothing under `/runtime/assets/` is mounted for this run. -- The apps read are the ones the console is served, through the same metadata protocol read that `GET /api/v1/meta/app` answers from. Config boots and artifact boots are both covered. -- Whether a file is servable is decided by the route's own filename resolution, so the warning and the route cannot disagree. Absolute URLs, protocol-relative URLs, data URIs, relative paths and other root paths are not checked. -- The line goes through the kernel logger at `warn`. It shows in the banner's *Boot diagnostics* block, streams live at `--log-level debug` or `info`, and is hidden at `error` or `silent` like every other boot warning. -- ⛔ What `/runtime/assets/*` serves does not change. No route is added or removed, and the artifact still carries no asset files: ship the `assets/` directory beside it, or point `OS_RUNTIME_ASSETS_DIR` at the files. diff --git a/.changeset/pre/22072-migrate-meta-relevance-predicate.md b/.changeset/pre/22072-migrate-meta-relevance-predicate.md deleted file mode 100644 index e7b53ff9432..00000000000 --- a/.changeset/pre/22072-migrate-meta-relevance-predicate.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/cli": minor ---- - -`os migrate meta` stops listing semantic notices whose surface the stack provably does not declare. It counts them instead, and `--all` lists them in full. - -Clause-②: yes - -- **`SemanticMigration.relevantWhen`** (`@objectstack/spec/migrations`) is a new optional field. It holds a structured question over the loaded stack, `{ kind: 'stack-declares', keys: [...] }`: does the stack declare anything under one of these top-level keys? A key's value in a `packages[].manifest` body counts the same as a top-level value. The question is closed and named. It is never free text, and it never matches against the prose of `surface`. The new types `SemanticRelevance`, `StackDeclaresRelevance` and `SemanticRelevanceKey` are exported beside `SemanticMigration`. -- **`applyMetaMigrations`** asks each entry's question of the stack it is given and of every hop checkpoint. - - **`todos` is unchanged.** `MigrationChainResult.todos` and `MigrationHopResult.todos` still hold every semantic entry of every hop crossed, whatever the stack holds, as before. - - **`absentTodos` is a new required member** of `MigrationChainResult` and `MigrationHopResult`. It names the subset of `todos` whose question answered `absent` in all of them: the same objects, in chain order. Code that only reads a chain result needs no change. Code that builds one of these two interfaces itself must now supply `absentTodos` (an empty array when nothing is proven absent). - - **Only a positive proof names an entry.** These cases answer `unknown` and leave it off `absentTodos`: - - a value the question cannot read (a function, a promise, a scalar, a getter that throws); - - a stack that is not a plain object; - - any `plugins`, `devPlugins` or `tiers` entry, since a plugin, or the platform plugins a tier preset loads, can register metadata the stack does not show. - - An entry that judges a conversion which applied an edit in the same run is never named either. -- **The first batch is 25 entries** (5 from protocol 17, 20 from protocol 18). Each one's surface lives only under named top-level stack keys: `analyticsCubes`, `apis`, `jobs`, `mappings`, `hooks`, `agents`, `tools`, `dashboards` (with `reports` and `pages` for the chart-config entry), `datasets`, `permissions` and `sharingRules`. None of them names a code door. Each entry was also checked to confirm that its acceptance criteria send the author to no stored row and no runtime door. Every other entry is never named absent, so it is listed exactly as before. -- **`os migrate meta`** lists `todos` minus `absentTodos`. After the listed notices it prints one line that counts the entries proven absent and names `--all`. A second line says that the proof covers the stack this run loaded, and not metadata a deployment stores. - - `--all` prints each of those entries in full, with the keys it was proven absent under. - - `--json` keeps `todos` whole and adds `absentTodos`, plus `hops[].absentTodos` with `--step`. - - `--step` reports each hop's listed count, and adds a `not listed` count to the hop line when that count is not zero. - - A run whose only notices are proven absent still writes `--out`. diff --git a/.changeset/pre/22073-boot-warning-one-line-per-class.md b/.changeset/pre/22073-boot-warning-one-line-per-class.md deleted file mode 100644 index d4451624563..00000000000 --- a/.changeset/pre/22073-boot-warning-one-line-per-class.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": patch -"@objectstack/plugin-auth": patch ---- - -The startup banner prints one line per warning class, each warning appears once, and a localhost boot no longer warns that OAuth is unencrypted. - -Clause-②: no - -- **One line per class.** Flows that declare a trigger but are not bound are grouped by trigger type and reason, with the flows listed: `⚠ 8 flows declare a 'schedule' trigger but are NOT bound — disabled by deployment policy — … (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy), so no time trigger arms …: flow_a, flow_b, …`. Before, the full reason (up to ~650 characters) printed once per flow. The banner now shows the reason's first sentence; `--log-level debug` still streams each flow's full reason. A real binding failure, or a missing trigger, keeps its own line, worded as before. -- **Printed once.** *Boot diagnostics* no longer repeats the automation plugin's per-flow `… is NOT bound` and shadowed-flow warnings, which the banner's `Flows:` list already shows. Its header counts them instead: `(8 more already listed above)`. Every other boot warning replays exactly as before. A boot that fails before the banner still replays all of them. -- **OAuth on loopback.** `OAuth is served UNENCRYPTED: …` is logged at `info` when the issuer's host is loopback (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`), so it is not shown at the default `warn` level. It stays `warn` on a private or link-local issuer. The sentence and the transport rule are unchanged. -- ⛔ Nothing you author changes. Which flows bind, the scheduled-work switch, the transport rule, the service-automation warning an embedded host reads, and every public key, export and parameter are unchanged. diff --git a/.changeset/pre/22074-auth-identity-objects-plugin.md b/.changeset/pre/22074-auth-identity-objects-plugin.md deleted file mode 100644 index 54fa57ee5a0..00000000000 --- a/.changeset/pre/22074-auth-identity-objects-plugin.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -`createIdentityObjectsPlugin()` registers plugin-auth's identity objects (`sys_user`, `sys_member`, `sys_organization` and the rest of the list `AuthPlugin` registers) on a kernel that does not mount `AuthPlugin`, such as an app's or a plugin's own test kit. - -Clause-②: yes (widening) - -- **What is new.** `createIdentityObjectsPlugin(options?)`, the `IdentityObjectsPlugin` class it returns, `IdentityObjectsPluginOptions` (`manifestDatasource`, with the meaning `AuthPluginOptions.manifestDatasource` has) and `IDENTITY_OBJECTS_PLUGIN_NAME` (`com.objectstack.auth.identity-objects`, its kernel plugin name). -- **One list.** It registers the identity half of plugin-auth's manifest: the header, the objects and the field plugin-auth adds to `sys_sso_provider`. `AuthPlugin` spreads the same builder into the one manifest it registers, so a reduced kernel and a full one register the same objects, and a kit no longer copies plugin-auth's object list or manifest id. -- **Same owner.** The objects register under plugin-auth's package id, `com.objectstack.plugin-auth`, as `AuthPlugin` registers them. The registry records one owning package per object, so a different id would make the owner of `sys_user` depend on which plugin a kernel mounts. -- **No authentication.** It registers objects only: no sessions, no routes, no `auth` service. -- **Not beside `AuthPlugin`.** `AuthPlugin` registers the same objects itself. A kernel that mounts both is refused at boot, by the identity plugin's `init()`, with an error naming both. -- **Usage.** `await kernel.use(createIdentityObjectsPlugin())` after `ObjectQLPlugin`. A suite that boots through `@objectstack/verify` already gets these objects: its `bootStack` mounts `AuthPlugin`. -- **Unchanged.** `AuthPlugin` registers the same manifest it registered before, under the same id. diff --git a/.changeset/pre/22074-security-identity-objects-boot-refusal.md b/.changeset/pre/22074-security-identity-objects-boot-refusal.md deleted file mode 100644 index 6d221554ce2..00000000000 --- a/.changeset/pre/22074-security-identity-objects-boot-refusal.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -`SecurityPlugin` declares the identity objects its authorization store reads, `sys_user` and `sys_member`, and refuses to boot a kernel that does not register them. The refusal names the missing objects and the plugin that registers them, where the kernel used to boot and then fail every authenticated request's permission read as an outage. - -Clause-②: yes (narrowing) - - - -**BREAKING**: a kernel that booted before is refused at boot, shipped as `minor` under the launch-window convention. - -**Why.** Permission resolution, `resolveUserAuthzGrants` in `@objectstack/core`, reads `sys_user` and `sys_member`, which `@objectstack/plugin-auth` registers. The engine refuses a read of an object its registry does not hold, so a kernel with `SecurityPlugin` and without those objects booted cleanly, and then every authenticated request's permission read failed with `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503), which reads as an outage and names no missing dependency. - -**What is refused.** A kernel where `SecurityPlugin.start()` ran and the engine registry does not hold `sys_user` or `sys_member` at `kernel:ready`: `bootstrap()` rejects with an error named `AuthzIdentityObjectsMissingError`, whose `missingObjects` lists the absent names and whose message names `@objectstack/plugin-auth`, `AuthPlugin` and `createIdentityObjectsPlugin()`. Measured in this repository: three test harnesses that boot `SecurityPlugin` without `AuthPlugin` (one in `@objectstack/runtime`, two in `@objectstack/service-automation`), each now mounting `createIdentityObjectsPlugin()`. By reading the code, not by a run: `objectstack dev` through `DevPlugin` with `services: { auth: false }` and security left on is refused too. - -**What still boots, unchanged.** A kernel that mounts `AuthPlugin` (`os serve` pairs the two; `@objectstack/verify`'s `bootStack` mounts both); a kernel that registers the two objects any other way; and a boot that composes `SecurityPlugin` for its declarations only (`os migrate`, which suppresses `start()`). - -**The remedy.** on a kernel that mounts `SecurityPlugin` without `AuthPlugin`, such as a test kit, add `await kernel.use(createIdentityObjectsPlugin())` from `@objectstack/plugin-auth`. It registers plugin-auth's own identity list, so a hand-written plugin that registers `SysUser` and `SysMember` is no longer needed, though it still satisfies the check. A `DevPlugin` stack with `services: { auth: false }` either turns security off as well or passes `createIdentityObjectsPlugin()` in `extraPlugins`. Nothing in an app's metadata changes. diff --git a/.changeset/pre/22079-forms-slug-redirect.md b/.changeset/pre/22079-forms-slug-redirect.md deleted file mode 100644 index 64b8dbae3bc..00000000000 --- a/.changeset/pre/22079-forms-slug-redirect.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -The path an author writes as a public form's `sharing.publicLink` now answers: `GET /forms/SLUG` redirects to the Console's public form page, `/_console/f/SLUG`, when the anonymous form door serves that slug - -Clause-②: yes (widening) - -- **What was wrong.** An app declares a public form as `sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' }`, the spelling the platform's own examples use. The Console serves that form to an anonymous visitor at `/_console/f/contact-us`, but the path as written answered `404 ENDPOINT_NOT_FOUND`, so the link an author put on a website reached nothing. -- **What it does now.** Wherever the Console is mounted (`os serve` / `os dev` with the Console, or any host that mounts `createConsoleStaticPlugin` from `@objectstack/cli/console`), `GET /forms/SLUG` answers `302` with `Location: /_console/f/SLUG`, followed by the request's query string. It does so only when the anonymous form door, `GET /api/v1/forms/SLUG`, serves the form to the same request: the form's `sharing` has `enabled` and `allowAnonymous` set and names the slug, no other metadata layer withdraws it, and the deployment's tenancy posture lets it take an anonymous submission. The redirect asks that door in-process with the visitor's own request, so it decides nothing the door does not, and it reveals nothing the door does not. -- **Unchanged.** Every other request under `/forms/` answers exactly as before: a disabled form, a form not open to anonymous visitors, an unknown slug, a form the posture withholds, any method other than `GET` / `HEAD`, a trailing slash or a deeper path all get the same `404 ENDPOINT_NOT_FOUND`. Nothing is mounted at the root for a bare slug. The signed-in Console route `/_console/forms/NAME` is untouched. A deployment without the Console (`--no-ui`, `--no-console`, `OS_DISABLE_CONSOLE=1`, or no built Console) mounts no redirect. -- **Status and target.** The redirect is a `302`, never a permanent one, because whether a form is served can change with its sharing. The `Location` path is built from the Console path and the slug, encoded as one path segment, so no request can point it anywhere else. The request's query string is carried verbatim after that path, because the public form page seeds its fields from `?prefill_FIELD=` parameters: `/forms/contact-us?prefill_source=website` lands on `/_console/f/contact-us?prefill_source=website`. A query string can change only the query of the page the visitor lands on, never its origin or path. -- **Nothing to migrate.** No key, export or signature changes. `sharing.publicLink` keeps its meaning and its accepted spellings (`/forms/x`, `forms/x` and `x` name one slug). diff --git a/.changeset/pre/22079-publiclink-describe.md b/.changeset/pre/22079-publiclink-describe.md deleted file mode 100644 index 3cabe9d8e39..00000000000 --- a/.changeset/pre/22079-publiclink-describe.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The `sharing.publicLink` describe says the value is a slug and names where it is served - -Clause-②: no - -`FormView.sharing.publicLink` described itself as a "Generated public share URL". It is -neither: the author chooses it, nothing generates it, and the server reads it as a slug, where -`/forms/x`, `forms/x` and `x` name one slug, `x` (`publicFormSlug`). The describe now says so, -and names where an anonymous visitor reaches the form while `enabled` and `allowAnonymous` are -both true: the REST form door `GET /api/v1/forms/:slug` (on the default API base), and, on a -host that serves the console, the console page `/_console/f/:slug`, with `/forms/:slug` -redirected there and its query string carried. The generated `ui/sharing` and `ui/view` -reference pages carry the new text. Describe text only: the accepted values, keys, types and -exports do not change. diff --git a/.changeset/pre/22080-v18-line-opens.md b/.changeset/pre/22080-v18-line-opens.md deleted file mode 100644 index 91b1263e9b6..00000000000 --- a/.changeset/pre/22080-v18-line-opens.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": major ---- - -The v18 line opens (ADR-0131; #22050). Every release from here is an `18.0.0-next.N` prerelease on the npm dist-tag `next`, until 18.0 GA. - -Clause-②: no - -- **What this marks.** Changesets is in pre mode with the tag `next`. Every `@objectstack/*` package versions together, so this one `major` takes all of them to 18, and the first version cut is `18.0.0-next.0`. `PROTOCOL_VERSION` follows `@objectstack/spec`'s major at version time, so it reads `18.0.0` from that prerelease on. The marker itself changes no code, key, export or stored shape. -- **What the line carries.** ADR-0131 (organization ownership is total: no NULL `organization_id`) lands on this line in stages. Each stage that breaks something says so in its own changeset, with its own migration. -- **Who receives it.** A plain install still resolves the dist-tag `latest`, which stays on 17.x until 18.0 GA. A prerelease is installed only on request: `@objectstack/spec@next`, or an exact `18.0.0-next.N`. -- **What ends it.** 18.0 GA exits pre mode and publishes to `latest`. - - diff --git a/.changeset/pre/22081-notify-refusal-single-brace.md b/.changeset/pre/22081-notify-refusal-single-brace.md deleted file mode 100644 index b2f11738582..00000000000 --- a/.changeset/pre/22081-notify-refusal-single-brace.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -A notify flow node's `title` / `message` refusal now names the key and prescribes `'{{ record.name }}'`, the `{{ }}` hole the notify executor renders. It used to answer with the shared template sentence, which names no key. - -Clause-②: no - -- Both slots take the same input as before: a bare, non-blank string or a `{ dialect: 'template', source }` envelope. Every value that parsed still parses, every value that was refused is still refused, with the same `invalid_union` code at the same path. Only the sentence changes. -- A blank bare string, a number, a non-template envelope and the like at `title` or `message` are refused with a sentence that names the key, prescribes `'{{ record.name }}'` or `{ dialect: 'template', source: '{{ record.name }}' }`, and says why: the notify executor renders `{{ }}` template holes, and a single-brace token is not one. The branch issue that `formatZodIssue` and the API error mapper print beneath it carries the same sentence. The build's flow judge (`FlowSchema`, flow registration, `os validate`) quotes the new sentence. -- Every other template slot keeps its sentence, which still prescribes `'{{record.name}}'`. That covers `titleFormat`, the prompt template's `system` / `user`, and any slot typed `TemplateExpressionInputSchema`. `TYPED_EXPRESSION_SOURCE_REQUIRED.template` and `TYPED_EXPRESSION_DIALECT_ONLY.template` are unchanged. -- The `tmpl` docblock no longer calls the envelope "Mustache" or shows only `{{record.x}}`. It now says which renderers read which braces: `{{record.x}}` for the formula template engine, the messaging, email and i18n renderers and a notify node's `title` / `message`, and either for `titleFormat`. The `TemplateExpressionInputSchema` docblock lists the notify slots the same way, and the generated expression reference page says a template slot's fix is written in the spelling its renderer reads. -- No export is added, removed or renamed, and no type changes. The notify slots take the same input as `TemplateExpressionInputSchema` from a constructor that stays internal to the package. diff --git a/.changeset/pre/22088-flow-edge-unresolved-or-repeated-refused.md b/.changeset/pre/22088-flow-edge-unresolved-or-repeated-refused.md deleted file mode 100644 index bc528b3ba54..00000000000 --- a/.changeset/pre/22088-flow-edge-unresolved-or-repeated-refused.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: `FlowSchema` refuses an edge whose `source` or `target` names no node of its graph, and an edge that repeats an earlier one - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. - -**Why.** `FlowSchema` held node ids and edge ids unique, and checked nothing else about an edge. A flow whose edge named a node it no longer held (`start → node_1` over nodes `[start, end]`), or that held `start → node_1` three times, passed `FlowSchema.parse`, `objectstack validate` and the metadata save door, and publish answered 200 with `_diagnostics.valid: true`. Then it ran. The dangling edge carried the run nowhere, silently, and the repeated edge ran its target once per copy: one record update created three identical records. The Studio flow designer produced both shapes after a node was removed. - -**What is refused.** Both rules run in the region walk the node-id rule already uses, at every depth it reaches. The issue's `code` is `custom`, and each issue is anchored on the edge to fix: - -- **An endpoint that names no node of the edge's own graph**, at `edges.N.source` / `edges.N.target`. For an edge inside a `loop` / `parallel` / `try_catch` region the path is the region path, such as `nodes.N.config.body.edges.M.target`. The graph is the flow's own `nodes` for a top-level edge, and the region body's `nodes` for a region edge, because the engine resolves an endpoint there alone. So a top-level edge into a region node is refused too, and the message names the graph the node does live in. The node-id space is still one across the flow for uniqueness. -- **A repeated edge**, at `edges.N`, naming the earlier copy. Repeated means the key the engine selects on: the same `source`, `target`, `type`, `condition` (its dialect and source, so a bare CEL string and its envelope are one condition) and branch `label`. An `isDefault` copy of an unconditional edge is a repeat. A repeat is judged only between edges whose endpoints both resolve. - -That covers `FlowSchema`, `defineFlow`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `os validate`, `os compile`, an artifact's parse, `AutomationEngine.registerFlow` (which parses first) and the metadata save door (`422 INVALID_METADATA`, in draft and in publish mode). - -**What is still accepted, byte for byte.** Two nodes joined by edges the engine tells apart: different conditions, a `fault` edge beside a default one, or `approve` and `reject` branch labels into one node. Every flow whose edges all resolve in their own graph and repeat nothing. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| an edge into a node that is not in the same `nodes` list (`target: 'node_1'`, no `node_1`) | point it at the node it was meant to reach, or delete the edge | -| a top-level edge into a node inside a region body | an edge into the region's container node; the region's own edges reach the nodes inside it | -| the same `source` → `target` edge twice, with the same `type`, `condition` and `label` | one edge. Delete the later copy; an edge meant to take its own route needs its own `condition` or branch `label` | - -**The one-line fix: delete the edge the refusal names, or re-point its endpoint.** Deleting a dangling edge changes nothing a run did, with one exception. A conditioned edge into a missing node still counted as the branch taken when its condition held, so where a flow relied on that, point the edge at a node that ends the branch. Deleting a repeated copy runs its target once per traversal instead of once per copy, which is the defect being removed. - -**Who is affected, measured.** At `aa71c4d9d`, every flow the examples ship (`app-showcase`, `app-crm`, `app-todo`: 35 flows, 55 graphs counting region bodies, 131 edges) has no dangling endpoint and no edge pair sharing a `source` and `target` at all. The flows the packages ship (the `os generate` and `os explain` templates, the new-flow seed, the `@objectstack/verify` fixture) and the platform test checklist's flow bodies are clean by reading. The CLI's golden eval corpus carries no flow. Deployed metadata and other repositories were not measured. Where such an edge already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. - -### The kit - -- **The refusal.** Two blocks in `FlowSchema`'s `superRefine`, after the edge-id rule, over `collectFlowGraphs`. No new error code: the issue is the same `custom` issue the id rules raise. -- **The ledger.** The D3 semantic entry `flow-edge-unresolved-or-repeated-refused` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: a dangling endpoint carries no intent a rewrite could recover, and dropping a copy changes how often its target runs. diff --git a/.changeset/pre/22090-metadata-protocol-revert-stored-package.md b/.changeset/pre/22090-metadata-protocol-revert-stored-package.md deleted file mode 100644 index 58bdb4e7dc3..00000000000 --- a/.changeset/pre/22090-metadata-protocol-revert-stored-package.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor ---- - -`ObjectStackProtocolImplementation.revertStoredPackage({ packageId, organizationId?, actor? })` reverts a package's stored members to their published version - -Clause-②: yes (widening) - -- A Studio-authored package's members are `sys_metadata` rows bound by `package_id`. The method reads them through the same predicate `listDrafts` uses, which now has one statement shared by both reads. -- No stored row bound to the package: it answers `{ stored: false, discarded: [] }` and touches nothing. The package's members, if it has any, belong to the metadata service. An unprovisioned `sys_metadata` answers the same way. -- Stored rows, none of them published: it refuses with `RESOURCE_CONFLICT` / 409, "Package '…' has never been published, so there is no published version to revert to." Nothing is touched. -- Otherwise it removes every draft of the package, each in the scope it lives in, through the same per-draft step `discardPackageDrafts` takes. Each item then serves its published (active) row again. An item created after the last publish has no published row and is removed. A refused draft fails the call with that refusal's own code and status. -- The only change to the public surface is this one added method. `discardPackageDrafts` and `listDrafts` answer as before. diff --git a/.changeset/pre/22090-runtime-revert-studio-package.md b/.changeset/pre/22090-runtime-revert-studio-package.md deleted file mode 100644 index ba8455c541d..00000000000 --- a/.changeset/pre/22090-runtime-revert-studio-package.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -`POST /packages/:id/revert` reverts a Studio-authored package instead of answering 404 "No metadata items found" - -Clause-②: no - -- The door asked only the metadata service, whose in-memory registry never holds a Studio package's stored rows. So a package with published items and a pending draft answered `404 RESOURCE_NOT_FOUND`, and the draft stayed. -- The door now asks the protocol's `revertStoredPackage` first, with the caller's active organization. A package with stored rows answers `200 { success: true }` with its drafts removed, or `409 RESOURCE_CONFLICT` when it has never been published. -- A package with no stored row is answered by the metadata service's `revertPackage`, exactly as before. That covers a code-shipped package and an unknown id (`404`). It is also what happens when the protocol does not provide `revertStoredPackage`. -- The request and the response shape are unchanged. diff --git a/.changeset/pre/22093-author-strings-internal-refs.md b/.changeset/pre/22093-author-strings-internal-refs.md deleted file mode 100644 index 567d54fce11..00000000000 --- a/.changeset/pre/22093-author-strings-internal-refs.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/service-automation': patch -'@objectstack/platform-objects': patch ---- - -Form help and refusals an author reads no longer carry service-interface names, ruling dates or another product's ids - -Clause-②: no - -Wording only: no schema, key, type, export or error-code change. - -- The notify node's Template help (the `NotifyConfigSchema.template` describe and the Studio - inspector's copy in `@objectstack/service-automation`) names the deployment's default locale in - product words instead of `II18nService.getDefaultLocale()`, and drops its ruling date. -- `MANIFEST_ID_EXAMPLES` is now `com.acme.crm` and `org.example.help-desk` (was `com.steedos.crm` - and `org.apache.superset`). The package-id refusal opens with the headline - "Invalid package id 'VALUE'." and names the key in the sentence after it, so the headline alone - carries no JSON path; the rule, the examples and the suggestion follow unchanged in substance. A - caller that matched the old "on KEY. Expected reverse-domain notation" wording matches the - headline, or compares against `manifestIdRefusal()` by reference, instead. -- Ruling dates leave the describes Studio renders as form help: field `required` and `multiple`, - form-view field and section `visibleWhen`, section `collapsible` / `collapsed`, the redirect - arm's `submitBehavior.url`, and page `kind` / `source` (the ADR citations stay). They also - leave the refusals for padded grouping field names, `submitBehavior.url`, `features.*` in a - form-view predicate, and the four filter comparand refusals (null ordering comparand, - `{ $field }` in a list position, null list member, blank `$between` bound). Each sentence still - states the rule, why it exists and the repair. -- The email-template form's Identity section help says how senders address a template instead of - naming `IEmailService.sendTemplate`, in all four shipped locales. -- The action `description` help no longer ends in a dangling dash left behind by an earlier - strip: "(one dialog, not two —)" now reads "(one dialog, not two)". diff --git a/.changeset/pre/22093-submit-behavior-help-no-ruling-date.md b/.changeset/pre/22093-submit-behavior-help-no-ruling-date.md deleted file mode 100644 index 9a20d348c9a..00000000000 --- a/.changeset/pre/22093-submit-behavior-help-no-ruling-date.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The form view `submitBehavior` property help no longer ends in a ruling date (#22093) - -Clause-②: no - -Studio, the reference docs and the published `objectstack-ui` skill show this describe as the property help. It still says that on the `redirect` arm `url` is relative-only, interpolates only declared record fields as `{{record.field_name}}` and is URL-escaped; the "(ruled 2026-08-11)" parenthetical is gone, and the ruling's date and tracker reference live in the code comment above the describe. Wording only: no key, type, accept/reject or export change. The `react-blocks.md` row and the `view` / `protocol` reference pages are regenerated from it. diff --git a/.changeset/pre/22093-sys-email-field-help-product-words.md b/.changeset/pre/22093-sys-email-field-help-product-words.md deleted file mode 100644 index 02744263b6f..00000000000 --- a/.changeset/pre/22093-sys-email-field-help-product-words.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -The `sys_email` field help for Headers (JSON) and Status no longer names the `IEmailService.send` service interface (#22093) - -Clause-②: no - -Studio shows these descriptions as field help in the object forms. They now say what the field holds and when it is set, in product words: Headers (JSON) holds the custom headers supplied with the message when it was sent, and Status is `queued` when the message is submitted for sending, before the first delivery attempt. Every other fact the help states is unchanged. The `en` bundle is regenerated from the source, and the `zh-CN`, `ja-JP` and `es-ES` translations of the two leaves are re-translated in place. Wording only: no field, option, key or type changes. diff --git a/.changeset/pre/22099-owner-bind-in-flight.md b/.changeset/pre/22099-owner-bind-in-flight.md deleted file mode 100644 index 687bd799259..00000000000 --- a/.changeset/pre/22099-owner-bind-in-flight.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -A first boot no longer logs a refused `sys_migration` insert, and the owner-bind ledger row records the outcome that happened - -Clause-②: no - -On every first boot (`os serve` and `os dev`, on `:memory:`, file SQLite and PostgreSQL), *Boot diagnostics* showed `WARN Insert operation failed {"object":"sys_migration", … UNIQUE constraint failed: sys_migration.id}` with a full stack. The one-time default-organization owner bind (`createEnsureDefaultOrganizationOnce`, ADR-0093 D7) re-entered itself: the owner write makes the security plugin grant `organization_admin`, that grant insert is a bootstrap trigger, and the inner call recorded the decision first. It recorded `admin-already-member` with no organization. The outer call's accurate record (`promoted` or `bound`, with the organization id) was then refused. Nothing was bound twice. The ledger row named the wrong outcome. - -The gate now marks the decision in flight before its first `await`. A call that finds it in flight still creates a missing Default Organization, but binds nobody and records nothing. The deciding call records its own outcome once. A call that did not act (no platform admin yet, or a refused write) clears the mark, so the next trigger still decides. - -- **Nothing you author changes.** No key, export or option is added or removed. The walled `@objectstack/organizations` wiring calls the same gate and gets the same rule. -- **A real ledger write failure is still reported.** Only the gate's own second write is gone; a refused `sys_migration` insert for any other reason still logs at `error`, with the remedy. -- **Rows already written are not rewritten.** A deployment whose first boot recorded `admin-already-member` keeps that row; nothing reads its `details`. diff --git a/.changeset/pre/22110-flow-text-slot-double-brace.md b/.changeset/pre/22110-flow-text-slot-double-brace.md deleted file mode 100644 index 9c8e07802a1..00000000000 --- a/.changeset/pre/22110-flow-text-slot-double-brace.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': major -'@objectstack/service-automation': major -'@objectstack/lint': major -'@objectstack/formula': minor ---- - -A flow TEXT slot — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message` — reads ADR-0032 §3's `{{ }}` template holes, rendered by the formula template engine over the flow's variables. A single-brace `{…}` token in one is refused at `objectstack validate`, at `registerFlow` and by the node's contract, with the `{{ }}` spelling of each token. - -Clause-②: yes (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** ADR-0032 Decision 3 fixes one template delimiter: "One delimiter, `{{ }}`; single `{ }` deleted." The notify pair was already typed as `template` slots, while the executor read the single brace, so a flow carried two template dialects and an author who met both mixed them. - -**What changes.** - -- **The renderer.** The text slots render through one renderer inside `@objectstack/service-automation` (package-internal; the package's public exports do not change): `{{ path }}` and `{{ path | formatter[:arg] }}` holes, every other character literal. A hole reads a flow variable by name (`{{ record.name }}`, a node output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or `{{ rows[0].subject }}`, and a `$`-named one: `{{ $error.message }}`). `null` and an absent path render nothing, an object or array renders as JSON, a `Date` as its ISO text; the ADR-0032 formatters make the rest explicit (`{{ amount | currency }}`, `{{ due | date:long }}`). A hole holds no logic. -- **The refusal.** One judge in `@objectstack/spec/automation` (`textSlotTemplateRefusal`, `flowNodeTextSlotSources`, `FLOW_NODE_TEXT_SLOTS`, `TEXT_SLOT_TEMPLATE_REFUSAL`): `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` refuse a single-brace token at the slot's key; `registerFlow` and `objectstack validate` (`expression-invalid`, `error`) refuse it with the same words and also compile every slot's holes, so `{{ a + b }}` or an unknown formatter is refused before a run. A stored flow carrying one is skipped at boot with a warn naming it. Past the doors, a hole that does not compile fails the node with a guard refusal, which a `fault` edge does not route. -- **`@objectstack/formula`.** A template hole's path may contain `$` (`{{ $error.message }}`): a widening of the hole grammar, so a host scope's `$`-named variable has a template spelling. An unbound one renders nothing, like any unknown path. -- **`flow-double-brace-interpolation`** no longer flags `{{ }}` on the text slots, and its hint names them as the only `{{ }}` positions; a bare `$ref.x` outside the holes of a text slot is flagged with the hole spelling. -- **Unchanged:** the value slots are CEL's. - -**Not converted.** No ADR-0087 D2 conversion rewrites `{x}` to `{{ x }}`. The two renderers were run over the same variables: a path renders the same text for a string, a number, a boolean, `null`, an absent key or variable, an ISO date string, an object or an array, but a `Date` value rendered JSON-quoted under the 17.x interpolator (`"2026-10-08T09:30:00.000Z"`, quotes included) and as its ISO text now, and a screen or `end` text that was one token holding an object, an array or a `Date` rendered `String(value)` (`[object Object]`). So the rewrite is the author's to check; the D3 record is the semantic entry `flow-text-slot-single-brace-refused`. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `title: 'Deal won: {record.name}'` | `title: 'Deal won: {{ record.name }}'` | -| `message: 'Failed: {$error.message}'` | `message: 'Failed: {{ $error.message }}'` | -| `message: 'Total {amount * 2}'`, `'{round(x)}'` | compute it first — `assignments: { v: { dialect: 'cel', source: 'amount * 2' } }` — then `'Total {{ v }}'` (a number format is a formatter: `{{ v \| number:2 }}`) | -| `message: 'Due {TODAY() + 7}'`, `'By {$User.Id}'` | compute it first with an `assignment` node's CEL value envelope — `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' }, by: { dialect: 'cel', source: 'current_user.id' } }` — then `'Due {{ due }}'`, `'By {{ by }}'` | - -**The one-line fix: double the braces of every path token in a text slot (`{x}` → `{{ x }}`), and compute anything else into a variable first.** - -**Who is affected, measured.** This repository's in-tree text-slot sites — `examples/app-showcase` (24 strings, 30 tokens) and `examples/app-todo` (5 strings, 7 tokens), all of them paths — are rewritten in this change, with the docs pages that taught the single brace (`content/docs/automation/flows.mdx`, `content/docs/getting-started/common-patterns.mdx`). Other repositories and deployed metadata were not measured here. diff --git a/.changeset/pre/22113-revert-package-code-shipped-members.md b/.changeset/pre/22113-revert-package-code-shipped-members.md deleted file mode 100644 index 98b211fec03..00000000000 --- a/.changeset/pre/22113-revert-package-code-shipped-members.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/metadata': patch -'@objectstack/runtime': patch ---- - -fix(runtime,metadata): publishing or reverting a read-only package is refused with `422 WRITABLE_PACKAGE_REQUIRED`, and package membership reads the `_packageId` stamp - -Clause-②: no - -ADR-0070 D2 makes a code or installed package read-only. `POST /api/v1/packages/:id/publish` and `POST /api/v1/packages/:id/revert` did not check this. These two answers change: - -- **Publish of a code or installed package: 200 → 422.** Before, the publish door answered 200. For a package whose items carry an authored `packageId`, for example `com.example.showcase`'s two capabilities, it answered `success: true` and wrote `publishedDefinition`, `state` and `version` onto those read-only items. For every other code package it answered `success: false`, "No metadata items found". It now answers `422 WRITABLE_PACKAGE_REQUIRED` before anything is written, the same refusal that `PATCH /packages/:id/disable` and `DELETE /packages/:id` already give. -- **Revert of a code or installed package: 404 or 409 → 422.** Before, the revert door answered `404 RESOURCE_NOT_FOUND` "No metadata items found" (for example `com.objectstack.setup` and the platform packages that ship objects) or `409 RESOURCE_CONFLICT` "Package '…' has never been published" (for example `com.example.showcase`). It now answers `422 WRITABLE_PACKAGE_REQUIRED`. The check runs after the protocol's stored-row answer, so a revert of a code package that has a stored row bound to it, such as an organization overlay draft, keeps its answer. - -To customise what a code package provides, use an ADR-0005 organization overlay. Overlay drafts publish through `POST /api/v1/packages/:id/publish-drafts` and the per-item publish door, and this change leaves both alone. - -**Unchanged:** a writable package's publish and revert, and an id that nothing carries (revert 404; publish 200 with `success: false`). - -`MetadataManager.publishPackage` and `revertPackage` now find a package's members by `packageId`, `package` or the private `_packageId` stamp. The artifact loader writes that stamp through `applyProtection`, and the ObjectQL object bridge copies it onto every object it registers. Before, an item that carried only the stamp was not a member. `MetadataManager` has no notion of package kind; the refusal of read-only packages lives at the two doors above. diff --git a/.changeset/pre/22114-meta-read-version-token.md b/.changeset/pre/22114-meta-read-version-token.md deleted file mode 100644 index db32da828e9..00000000000 --- a/.changeset/pre/22114-meta-read-version-token.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-protocol": minor -"@objectstack/rest": minor -"@objectstack/types": minor ---- - -feat(meta): the `/meta` item read serves the ADR-0008 version token, `If-None-Match: *` pins a save that expects no row, and the 409 `METADATA_CONFLICT` body carries the current version as data - -Clause-②: yes (narrowing) - -**BREAKING** for two request shapes on `PUT /api/v1/meta/:type/:name` that were answered `200` before this release and are now refused `400 VALIDATION_ERROR`, with nothing written: an `If-None-Match` header whose value is anything but `*` (an entity-tag, a list, a weak `W/"*"`, an empty value), and an `If-None-Match` header sent beside `If-Match`. Before, the door did not read `If-None-Match` at all and wrote the body, unguarded or under the `If-Match` alone. The remedy: send `If-None-Match: *` alone to save only where no row exists, or `If-Match` with the version you read alone to save only over that version. No first-party client sends `If-None-Match` on a `PUT`: the SDK sends it only on its cached `GET`, and objectui's ETag hook has no caller. - -ADR-0008's optimistic lock on `PUT /api/v1/meta/:type/:name` takes the version a client read as `If-Match`, but a client can only send a token it was served, and only a save receipt served one. So the first save after a load could not be pinned: two editors who each loaded an item and saved once overwrote each other. Three additions close that. - -- **The read serves the token.** `GET /meta/:type/:name` now carries `version` (declared on `GetMetaItemResponseSchema`): the keyed token of the stored row a save to this item would compare against, at the read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` for the draft row, the plain read for the active row). It is the same producer and the same row as the save receipt's `version`, so a read after a save serves the receipt's token byte for byte. Send it back as `If-Match`. `null` means no stored row is there (an item served from code or a package artifact, or from a row in a scope the save does not write), so the next save is a create. The cached published-value branch (the default plain read) and `?preview=draft` publish no `version`, as they publish no `lock`; their `ETag` stays the cache validator and is not the token. -- **"Expect no row" can be said.** `If-None-Match: *` on `PUT /meta/:type/:name` (with `?mode=draft` for a draft) saves only where no row of that lifecycle exists, and is refused `409 METADATA_CONFLICT` once one does. This is the `parentVersion: null` pin `SaveMetaItemRequestSchema` already declared, now reachable over HTTP. The header takes `*` alone: any other value, or `If-None-Match` beside `If-Match`, is refused `400 VALIDATION_ERROR` and nothing is written. No first-party client sends `If-None-Match` on a `PUT`. A save with neither header is last-writer-wins, as before. -- **The conflict body names the current version as data.** The 409 of the `/meta` item write doors (save, publish, rollback, reset) now carries `currentVersion` beside the unchanged `error` sentence: the token the sentence names, or `null` when no row of the target lifecycle exists (declared as `MetadataConflictErrorSchema`). On the save door it equals the token the next read at the same address serves, so a client offering "reload" or "overwrite" re-pins without parsing prose. The SDK already exposes it as `err.details.currentVersion`. -- **The receipts' `version` describes are corrected.** `SaveMetaItemResponseSchema.version`, `PublishMetaItemResponseSchema.version` and the package publish door's `published[].version` said "Content hash … currently emitted as `sha256:`" and "409 `metadata_conflict`". The token has been the keyed `hmac-sha256:` digest of the stored content hash since the doors stopped serving the raw hash, and the conflict code on the wire is `METADATA_CONFLICT`. Only the description text moves. - - diff --git a/.changeset/pre/22116-migrate-meta-out-snapshot.md b/.changeset/pre/22116-migrate-meta-out-snapshot.md deleted file mode 100644 index 6e197546ac2..00000000000 --- a/.changeset/pre/22116-migrate-meta-out-snapshot.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os migrate meta --out FILE` writes its snapshot on a range that crosses no step (#22116) - -Clause-②: no - -`os migrate meta --from 18 --to 18 --out FILE` exited 0, printed no snapshot line and wrote no `FILE`. The same command with `--json` wrote `FILE`. The human report returned early on a run with nothing to migrate, and that return came before the `--out` write. An operator or a CI step that keeps `FILE` as the record of the run then found no file, or read an earlier run's file as this one's. - -- The human mode now writes `FILE` and prints the line that names it on every run. On a run with nothing to migrate, the line comes after the range answer, before `--write`'s outcome and the data migrations, the same order as on every other run. -- The bytes are the ones `--json` writes for the same run: the stack the chain returned, which for a range with no step is the stack as loaded. -- The fix sits in the branch both "nothing to migrate" answers share. A range with steps that applies and lists nothing takes the same branch, but no range reaches it on this build, because every major carries semantic notices and the chain lists them all. -- Unchanged: the `--json` mode, `--write`, `--stored` and the chain. A range with steps writes `FILE` exactly as before. diff --git a/.changeset/pre/22118-gate-baseline-stored-self.md b/.changeset/pre/22118-gate-baseline-stored-self.md deleted file mode 100644 index 74c1ce3a88f..00000000000 --- a/.changeset/pre/22118-gate-baseline-stored-self.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -The runtime publish gate no longer charges a write with a stored sibling's finding that only shows when the written item is present. Re-saving a master object with only its label changed answered `422 INVALID_METADATA` for a stored detail the author never touched: a detail's `lookupColumns` entry naming no field of the master, or a detail's `readonlyWhen` read through `parent`. - -Clause-②: no - -- On an update into a context collection (`object`, `permission`, `book`, `dataset`) the gate also judges the stored universe: the live collections with the written item's stored self in place. A finding located on another entry that the stored universe already holds is not this write's. -- A finding located on the written item itself is judged as before, even when the stored row carries the same finding. Re-saving a row is writing it. -- A write that newly breaks a sibling is still refused, for example removing the master field a detail's `lookupColumns` names. -- A create is judged as before. -- On a permission write the same change drops a stored detail's `security-master-detail-ungranted` advisory from a label-only re-save of the tenant's only permission set. -- A finding whose path names no entry the gate can locate keeps the previous verdict. -- ⛔ Nothing you author changes, and no export or signature changes. diff --git a/.changeset/pre/22128-draft-head-resolution.md b/.changeset/pre/22128-draft-head-resolution.md deleted file mode 100644 index 50f79e68959..00000000000 --- a/.changeset/pre/22128-draft-head-resolution.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor -"@objectstack/rest": patch -"@objectstack/spec": patch ---- - -fix(meta): a second unpinned draft save of a package-owned item is no longer refused 409, the item read serves that draft's `version`, and a package-less draft save keeps a package-owned datasource's stored credential - -Clause-②: yes (widening) - -- **The second draft save is accepted.** Save an item into a package (`PUT /api/v1/meta/:type/:name?package=`), then save a draft of it without `?package=` twice. The repository stores that draft in the item's package (the draft inheritance that keeps a pending change in the package it edits). The save door took its expected parent from the package-unbound draft row instead. It found none, so the second save, which sent no `If-Match`, was refused `409 METADATA_CONFLICT` ("Expected parent null but current is hmac-sha256:…"). ADR-0008 makes an unpinned save last-writer-wins, and it is accepted again. -- **One head row per write address.** `SysMetadataRepository.headAt(ref, { state, packageId })` answers `{ head, packageId }`: the row a `put` at that address upserts (`null` for a create), and the package binding that put writes under. Both come from the same resolution `put`'s optimistic lock runs. That resolution includes the draft inheritance and the adoption of a draft stored without a package. The save door's expected parent and the item read's `version` both come from it, so the token `GET /meta/:type/:name?state=draft` serves is the token a draft save at the same address accepts. Before this, that read served `version: null` while the inherited draft existed. -- **`?package=all` on the item read.** `GET /meta/:type/:name?package=all` now reads `all` as the save and publish doors read it: as naming no package. Before, the read forwarded the literal `all`. Its `version` was resolved at a package no save writes, so it served `null` beside a row the save then judged. Its `?state=draft` read answered `404 NO_DRAFT` for a draft stored in the item's package. The read now serves the item as a read with no `?package=` serves it, and `?package=all` still takes the uncached arm, so it keeps serving `version`. -- **The credential carry-forward reads the same row.** A save of a type whose read withholds a credential (`datasource`; `flow` with the automation plugin) carries the stored credential back into a body that returns as it was served. It compared that body against the row at the key the caller named. A package-less draft save of a package-owned item therefore found neither the inherited draft nor the package's active row, and compared against the code layer. The stored credential was persisted away on the first such save, measured on the real route with a datasource whose `config.url` holds one. Both reads now go through `headAt`: the draft the save overwrites, or with no draft yet the active row in the package the draft is stamped into. An item with no package binding is unchanged. -- **Descriptions only, in `@objectstack/spec`.** `SaveMetaItemRequestSchema.packageId` now states the draft inheritance, where it said "absent = env-local overlay". The `POST /meta/:type/:name/publish` route description spells the conflict code `METADATA_CONFLICT`, as the wire sends it. -- A stale `If-Match` is still refused `409`, and `If-None-Match: *` is still refused while a row exists. An item with no package binding is unchanged. No spec key, request field or response field is added or removed. -- The only change to the public surface is one added method on the exported `SysMetadataRepository`: `headAt`. No existing signature, schema or accept set changes. diff --git a/.changeset/pre/22130-protocol-18.md b/.changeset/pre/22130-protocol-18.md deleted file mode 100644 index 1d2542e7114..00000000000 --- a/.changeset/pre/22130-protocol-18.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/spec': major -'@objectstack/mcp': patch ---- - -feat(spec)!: `PROTOCOL_VERSION` is `18.0.0`; the runtime handshake accepts `engines.protocol: '^18'` and refuses `'^17'` - -Clause-②: yes (narrowing: the handshake refuses a manifest pinned to protocol 17 (`'^17'`, `'^17.x.y'`, `'17.x'`), while the exported `PROTOCOL_VERSION` moves to `'18.0.0'` and a `'^18'` manifest is admitted) - - - -**BREAKING**, graded `major`: Changesets is in pre mode (`next`) for the v18 line, so this ships in an `18.0.0-next.N` prerelease. The protocol major moves in this reviewed change, not in the version pass (ruling record 6049734955, Q1 → B). Until that line's first version pass, `PROTOCOL_VERSION` is one major ahead of the package version; `check-changeset-no-major.mjs` admits that only in pre mode with a pending `major` for `@objectstack/spec` (ruling record 6056808625). - -- **`PROTOCOL_VERSION` reads `'18.0.0'`** and `PROTOCOL_MAJOR` reads `18`. The ADR-0087 D1 handshake on every load and install door (the app load seam, `POST /api/v1/packages`, `POST /api/v1/marketplace/install-local`) now admits a package whose `engines.protocol` admits major 18, and refuses one pinned to 17 (`'^17'`, `'^17.x.y'`, `'17.x'`) with `OS_PROTOCOL_INCOMPATIBLE` (422), naming `objectstack migrate meta --from 17`. -- **What you change.** Run `objectstack migrate meta --from 17` to list the edits for existing sources; `--write` applies the ones it can prove, including the declared `engines.protocol` range, rewritten in place from `'^17'` to `'^18'`, and you apply the rest by hand. -- **`spec-changes.json` and the protocol upgrade guide** now carry the protocol 17 → 18 record, generated from the registries: 64 mechanical conversions and 329 semantic entries. The aggregate record spans 16 → 18. -- **`os init`** stamps `'^18'` in the manifests it writes, and `os lint`'s `protocol/missing-engines-range` fix names `'^18'`. The `create-objectstack` template keeps `'^17'` until the version pass stamps it together with its `@objectstack/*` dependency ranges. -- **`@objectstack/mcp`**: the README's `defineStack` examples declare `'^18'`. diff --git a/.changeset/pre/22135-security-catalog-one-holder.md b/.changeset/pre/22135-security-catalog-one-holder.md deleted file mode 100644 index fddd17ded72..00000000000 --- a/.changeset/pre/22135-security-catalog-one-holder.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/objectql': major ---- - -feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a package registering a name that an installed package, the environment catalog or a built-in already holds is refused, naming both holders - -Clause-②: no - - - -**BREAKING** — an accept-set narrowing at the package registration door, shipped as `major` on the v18 pre-release line (`.changeset/pre.json` is in `next` pre mode on `main`). A deployment whose packages share a position, permission set or capability name booted before this release and is refused at boot after it. - -**Why.** An assignment names a position or a permission set by its bare name, with no package to tell two definitions apart (ADR-0131 D4). Before this release two installed packages could ship one name, and which definition granted depended on registration order. Measured on a booted kernel with two packages sharing one name per type: the by-name catalog read resolved the permission set and the capability to the first-registered package, and the position to the last-registered one. An app declaring the platform's own `admin_full_access` registered beside it, and the by-name read answered the app's set. The maintainer ruled the security catalog out of ADR-0048 §3.4's cross-package coexistence: each of the three types holds one namespace per deployment. Every other metadata type keeps §3.4's coexistence unchanged. - -**What is refused, and where.** `SchemaRegistry.installPackage` refuses a package whose declared `positions`, `permissions` (permission sets) or `capabilities` — top level, or on a nested `plugins[]` entry — name something another holder already holds. It refuses ahead of every mutation, so a refused package leaves no record behind. The holders are: - -- another installed package; -- the environment catalog: an item authored in this environment, in the registry's bare slot with no package; -- a built-in: the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) and the curated platform capabilities (`manage_users`, `setup.access`, `studio.access` and the rest of `PLATFORM_CAPABILITIES`). - -The platform's own permission sets (`admin_full_access`, `member_default`, …) are declared by `@objectstack/plugin-security` on its manifest, so they are held by that package like any other package's. `registerItem` with a package id refuses the same second holder for a registration that reaches the registry directly. The platform's own declaration of a built-in name there (`@objectstack/plugin-security` declaring the six built-in positions) is the built-in holder's, so it is never refused. An environment item already stored under that name keeps answering first. Every package registration reaches this door first: `AppPlugin.init` at boot (each package of a multi-package artifact included), a hot install through `install-local`, and a post-start `manifest.register`. On an artifact boot the refusal fires in Phase 1, before the artifact door registers anything. `install-local`'s offline (inline-manifest) import answers `422` under that route's own `PLUGIN_REGISTER_FAILED` code, with this refusal's message in `error.message`, and records nothing. `POST /api/v1/packages` carries no catalog collection at all; its strict body refuses them with `400`. - -**What an author sees.** The boot, or the install, fails with an ADR-0112 envelope: `code: 'NAMESPACE_CONFLICT'` (the code the namespace gate already carries; `NAMESPACE_CONFLICT_CODE` is exported) and `status: 422`. The message names the incoming package and the existing holder of each conflicting name, all conflicts in one message. The thrown error carries `conflicts[]` with `{ catalogType, name, incomingPackageId, existingHolder }`, where `existingHolder` is `{ kind: 'package', packageId }`, `{ kind: 'environment' }` or `{ kind: 'built-in' }`. **The one-line fix: rename the item in one of the two packages, or uninstall one of them.** A built-in name is never available to a package. An assignment that named the old name must name the new one; nothing rewrites stored assignments. - -**What is NOT refused.** The same package registering its own name again (an idempotent reload, a re-install, a hot reload). An item of any other metadata type shared by two packages. An environment save over a package-held name: a registration with no package (every `sys_metadata` hydration and metadata write-through) is never judged here, and a packaged permission set is already locked against an in-place edit (`403`). `OS_METADATA_COLLISION=warn` downgrades the namespace gate only. It does not downgrade this refusal. At cold boot, packages register before the environment catalog loads from `sys_metadata`, so this door cannot see an environment-held name then; the engine checks every package-held position and permission-set name against the environment catalog right after it loads, and refuses the boot with this envelope (the cold-boot entry in this release). - -**Measured producers.** On objectstack `1604e094f5`, the four examples (`app-crm`, `app-showcase`, `app-todo`, `app-multi-package`) and the platform built-ins carry 50 catalog declarations, and no name has more than one holder. Deployed and marketplace packages NOT MEASURED. diff --git a/.changeset/pre/22136-settings-timezone-help-plain.md b/.changeset/pre/22136-settings-timezone-help-plain.md deleted file mode 100644 index 0dbc81ea79a..00000000000 --- a/.changeset/pre/22136-settings-timezone-help-plain.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/service-settings": patch ---- - -fix(service-settings): the Default timezone help speaks to administrators, not in formula-function names - -Clause-②: no - -The help under Settings → Localization → Default timezone (also shown in the first-run "set the workspace timezone" prompt) read "IANA zone for today()/daysFromNow, analytics date buckets, and rendered datetimes." It now says what the setting does: "Decides what "today" means in formulas, how reports group dates, and how dates and times are shown. Accepts any IANA time zone name (e.g. Europe/Paris)." The same meaning ships in every built-in locale (en, zh-CN, ja-JP, es-ES) and in the manifest's own `description`, which `GET /api/settings/localization` serves and the console falls back to. - -Wording only: the `timezone` key, its default (`UTC`), its accepted values (any IANA zone) and what it drives are unchanged. diff --git a/.changeset/pre/22141-rest-meta-save-request-options.md b/.changeset/pre/22141-rest-meta-save-request-options.md deleted file mode 100644 index 96c70708257..00000000000 --- a/.changeset/pre/22141-rest-meta-save-request-options.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -`metaSaveRequestOptions` is exported: the one mapping from a `PUT /meta/:type/:name` request's `If-Match`, `If-None-Match` and `?mode=draft` to `saveMetaItem`'s `parentVersion` and `mode` - -Clause-②: yes (widening) - -- `RestServer`'s `PUT /api/v1/meta/:type/:name` door now reads its precondition and its lifecycle through this function. The runtime dispatcher's `/meta` door, the one behind the `@objectstack/hono` catch-all, reads them through it too, so the two doors cannot answer the same save differently. It accepts a `Headers`-like object (`get`) or a plain header record, plus the query. It answers `{ ok: true, request }`, where `request` holds only the members the caller asked for, or `{ ok: false, message }`, the sentence of a `400`. -- Unchanged: `RestServer`'s answers. The token still has ETag quotes stripped, `If-None-Match: *` still pins "no row of this lifecycle", and the two `400` refusals keep their wording. `?mode=draft`, matched case-insensitively, still stages a draft. Any other `mode` value is still an active save. diff --git a/.changeset/pre/22141-runtime-meta-save-preconditions.md b/.changeset/pre/22141-runtime-meta-save-preconditions.md deleted file mode 100644 index 6d249361c74..00000000000 --- a/.changeset/pre/22141-runtime-meta-save-preconditions.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -The runtime dispatcher's `PUT /meta/:type/:name` honours `If-Match`, `If-None-Match: *` and `?mode=draft`, so a host that mounts the `@objectstack/hono` catch-all no longer overwrites a pinned save or publishes a draft save - -Clause-②: no - -- Behind `createHonoApp`'s `${prefix}/*` catch-all, this door handed `saveMetaItem` no `parentVersion` and no `mode`. A stale `If-Match` wrote (`200`, not `409`), `If-None-Match: *` over an existing row wrote, and a `?mode=draft` save landed on the ACTIVE row with a receipt saying `state: 'active'`, so every draft edit went live without a publish. The door now reads the request through `metaSaveRequestOptions` from `@objectstack/rest`, the same mapping `RestServer`'s `PUT` door reads it through. It answers as that door does: `409 METADATA_CONFLICT` for a stale token or for `*` over an existing row, a draft row that leaves the active row alone, and `400 VALIDATION_ERROR` for `If-Match` sent beside `If-None-Match` or for an `If-None-Match` other than `*`. Each refusal writes nothing. -- A host whose protocol has no `saveMetaItem` falls back to the metadata service's `saveItem(type, name, item)`, which cannot carry a precondition or a lifecycle. A save that asks for one there is now refused `501 NOT_IMPLEMENTED` and is not written. -- Unchanged: a save that sends neither header and no `?mode=draft` writes the active row, last writer wins, as before. The refusal envelope is this transport's own (`{ success: false, error: { code, message, httpStatus } }`). diff --git a/.changeset/pre/22149-define-seed-record-keys.md b/.changeset/pre/22149-define-seed-record-keys.md deleted file mode 100644 index 18bc56600ac..00000000000 --- a/.changeset/pre/22149-define-seed-record-keys.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`defineSeed()` refuses a seed record key that names no column of the target object, whatever shape the records arrive in, and its record type now admits the system columns the platform injects (`created_at`, `owner_id` and the rest), which it used to refuse in a record literal. - -Clause-②: yes (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. It is a call-time refusal, not a type-only change: the check runs when `defineSeed` is called, so it is reached by `os validate`, `os build` and boot through the config module's evaluation. - -**Why.** The docblock promised that "typos in record field names are caught at compile time", and nothing else checked them. The promise held only for a record written as an object literal directly in `records`. TypeScript's excess-property check is the only thing the record type enforced, and it does not run for records from a variable or a `.map()`, for an object typed `ServiceObject`, or for any inline record in an array that also spreads a `Record[]`. A seed with a misspelled key passed `tsc` and `objectstack validate`, and the mistake surfaced, if at all, only when the seed loaded. Measured with the published 17.7.0 types on a real app: an `ObjectSchema.create()` object keeps its literal field keys, and the spread is what silenced the check. The same type refused `created_at` in a record literal, a key the seed loader keeps on insert, so the one legitimate way to seed it was the shape that also hid typos. - -**What is refused.** `defineSeed(obj, config)` throws when any record carries a key that is neither one of `obj.fields` nor a system column the platform injects on `obj`. The injected set is `resolveInjectedSystemColumns(obj).names`, the same per-object answer the registry's injection reads. It holds the driver's `id` always, plus `organization_id`, the audit columns (`created_at`, `created_by`, `updated_at`, `updated_by`), `owner_id` and `owning_business_unit_id` as the object's `systemFields`, `tenancy`, `ownership` and `managedBy` select them. All unknown keys are reported in one error, one line per key, naming the object, the record index and the key, with a near-miss suggestion: - -```text -defineSeed('crm_case'): unknown field(s) in records — created_atx. - • records[0]: `created_atx` is not a field of `crm_case`. Did you mean 'created_at'? -``` - -**What is admitted that was not.** The record type adds every injectable system column name (the new exported type `InjectedSystemColumnName`) to the keys a record literal may carry, typed `unknown`. A literal writing `created_at` or `owner_id` now passes `tsc`. The type cannot evaluate an object's opt-outs, so the call narrows it: `created_at` on a `systemFields: false` object, or `owner_id` on an `ownership: 'org'` object, is refused when the call runs. A declared field of the same name keeps its declared value type. - -**Remedy.** Correct, declare or remove the key the refusal names. A misspelled key takes the spelling the refusal suggests (the declared field, or the system column such as `created_at`). A key for a field the object does not declare needs that field declared on the object, or the key removed. A system column the object opts out of (`created_at` on `systemFields: false`, `owner_id` on `ownership: 'org'`) does not exist on that object, so the key is removed. The key named no column of the object, so no value it carried could be stored under it, and no working seed depends on it. - -**Who is affected, measured.** At `0767335c`, every `defineSeed` call in this repository passes: `examples/app-crm` (5 seeds, 28 records), `examples/app-showcase` (19 seeds, 132 records) and `examples/app-todo` (1 seed, 8 records), evaluated against the built package. A misspelled key in the same context is refused, which is the control. Every seed module in hotcrm at `99d290a` passes too (8 modules, 354 records, including the `created_at` its case seeds author), and its `crm_case` with the misspelled `created_atx` is refused. Other repositories and deployed packages were not measured. diff --git a/.changeset/pre/22150-scaffold-dockerfile-lockfile.md b/.changeset/pre/22150-scaffold-dockerfile-lockfile.md deleted file mode 100644 index eb87e04edb3..00000000000 --- a/.changeset/pre/22150-scaffold-dockerfile-lockfile.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"create-objectstack": patch ---- - -fix(create-objectstack): the scaffolded Dockerfile installs from the lockfile the project carries - -Clause-②: no - -Where pnpm is on PATH, `create-objectstack` installs with pnpm and the project gets `pnpm-lock.yaml` and no `package-lock.json`. The `Dockerfile` it wrote copied `package*.json` and ran `npm ci`, so `docker build` stopped with `EUSAGE` before `os build` ran. - -- The build stage now copies `package.json` and whichever of `pnpm-lock.yaml`, `pnpm-workspace.yaml` and `package-lock.json` exist, then installs from the lockfile: `corepack pnpm@10 install --frozen-lockfile` for `pnpm-lock.yaml`, `npm ci` for `package-lock.json`. With neither, the build stops and says to install once and commit the lockfile. -- pnpm is pinned to major 10, the major the template's `.github/workflows/ci.yml` installs with. An unpinned Corepack takes the registry's newest pnpm, which the Corepack bundled with Node 22 could not run when this was measured. -- Unchanged: `os build` in the image, the runtime stage and its pinned image tag. -- A project scaffolded before this release keeps its own `Dockerfile`. To fix one, replace its `COPY package*.json ./` and `RUN npm ci` lines with the build stage shown in the self-hosting guide. diff --git a/.changeset/pre/22154-analytics-security-absence-info.md b/.changeset/pre/22154-analytics-security-absence-info.md deleted file mode 100644 index b39bd0dfef8..00000000000 --- a/.changeset/pre/22154-analytics-security-absence-info.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/service-analytics": patch ---- - -fix(service-analytics): a default boot no longer warns that no "security" service is registered; the warning moves to the first query that finds none - -Clause-②: no - -- **What changed.** `AnalyticsServicePlugin` used to log a WARN at init, `[Analytics] No admitObjectRead configured and no "security" service registered at init …`, on every default boot. `@objectstack/plugin-security` registers the `security` service in its `start()`, after every plugin's `init()`, and the object-level read bridge resolves that service per query. So the warning described a consequence that did not apply to the running app. Init now logs the same situation at `info`, as the sibling row-scope bridge already did: `[Analytics] admitObjectRead bridged to the "security" service; that service is not registered yet at init and will be resolved per query`. -- **Where the warning went.** When an analytics query needs the object-level gate and the plugin context returns no `security` service, the bridge logs one WARN: `[Analytics] No admitObjectRead configured and no "security" service registered when an analytics query needed one (first: "OBJECT") …`. It is logged once per plugin instance, not once per query. -- **Unchanged.** What the bridge enforces. When the plugin context answers a lookup for `security` with nothing, the query is admitted, as before. A lookup that throws, or a service that answers neither `canReadObject` nor `explain`, still denies the query fail-closed and logs an `error`, as before. The in-repo kernels (`ObjectKernel`, `LiteKernel`) throw on a lookup for a service nobody registered, so on those kernels a query with no security service ever registered is still denied, with an `error` per query, as before. A deployment that supplies `admitObjectRead` sees no change. diff --git a/.changeset/pre/22155-console-dist-remedy.md b/.changeset/pre/22155-console-dist-remedy.md deleted file mode 100644 index d485d63ba69..00000000000 --- a/.changeset/pre/22155-console-dist-remedy.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): the "Console dist not found" boot warning names the remedy that works where the server runs (#22155) - -When the Console package the server resolves has no built `dist/`, the boot warning used to say `install @object-ui/console (already built) or run pnpm --filter @object-ui/console build in the objectui workspace`, wherever it ran. The first remedy works nowhere, because the resolver never looks for an installed `@object-ui/console`. The second needs an objectui checkout, which a fresh clone of the framework repository does not have; there the Console is built by a root script. - -The warning now names one remedy. It picks it by asking whether the nearest `package.json` above the resolved console package declares an `objectui:build` script: - -- **In the framework repository** (the root manifest declares it), the warning gives the dist path, says that `pnpm build` does not produce it, and names `pnpm objectui:build` with the directory to run it in. -- **Anywhere else**, such as a project that installed `@objectstack/cli`, the warning gives the dist path and says the Console ships prebuilt in `@objectstack/console`, a dependency of `@objectstack/cli`. The remedy is to reinstall that package at the CLI's version. - -Only the text changes. The Console mounts, or is skipped, exactly as before. diff --git a/.changeset/pre/22156-cli-port-help-and-init-next-steps.md b/.changeset/pre/22156-cli-port-help-and-init-next-steps.md deleted file mode 100644 index 096eb4aecae..00000000000 --- a/.changeset/pre/22156-cli-port-help-and-init-next-steps.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os dev --help` and `os start --help` name `OS_PORT` for `--port`, and `os init --no-install` names the package manager it resolved in its Next steps. - -Clause-②: no - -- **`os dev --port`.** The help text read `Server port (overrides $PORT)`, though `os dev` reads `OS_PORT` first and `PORT` only as its legacy alias. It now reads `Server port (overrides $OS_PORT; $PORT is the legacy alias)`. Which variables are read, and in what order, is unchanged. -- **`os start --port`.** The same drift: the help text read `Port to listen on (overrides $PORT, default 3000)`, though `os start` reads `OS_PORT` first as well. It now reads `Port to listen on, default 3000 (overrides $OS_PORT; $PORT is the legacy alias)`. The port behaviour is unchanged. -- **`os init --no-install`.** The package manager was resolved only when an install ran, so a `--no-install` run always printed `npm install` and `npx objectstack …`, even with `--package-manager pnpm` or when invoked through pnpm. It is now resolved before the install step, the same way as before (the `--package-manager` flag, then the invoking package manager, then npm), so `os init my-app --no-install --package-manager pnpm` prints `pnpm install` and `pnpm exec objectstack …`. A run with no flag, invoked through npm or `npx`, still prints `npm install`: the scaffold supports npm, yarn and bun as well as pnpm. diff --git a/.changeset/pre/22157-option-visible-when-parent.md b/.changeset/pre/22157-option-visible-when-parent.md deleted file mode 100644 index 6daeb618a45..00000000000 --- a/.changeset/pre/22157-option-visible-when-parent.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor ---- - -fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, or any other root the server's option check does not bind (#22157) - -Clause-②: no (narrowing: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, which the runtime's option check cannot bind) - -A select option's `visibleWhen` is a gate the server enforces on write: the rule validator evaluates the predicate for the value a caller picks, and refuses the value when the predicate is false. That option check binds `record`, `previous` and the acting user (`current_user`, and its ADR-0068 aliases `user`, `ctx` and `os`), and nothing else. An option predicate that read `parent`, such as `parent.status == 'closed'`, still passed `os build` and the object save door. `parent` is a root the platform declares, and a field's own `readonlyWhen` and `requiredWhen` bind it on an object with exactly one `master_detail`. The option check does not bind it. So the predicate faulted on every write that picked the option, the server logged "the option's gate was NOT enforced on this write", and the value was admitted. - -The build's expression rule (`validateStackExpressions`) now gives a select option's `visibleWhen` a root verdict over the roots the option check binds. A predicate that reads any other root the platform declares, `parent` above all, is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the option, the field and the root. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. - -**BREAKING — what moves for consumers.** - -- `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a root other than `record`, `previous`, `current_user`, `user`, `ctx` or `os`. Besides `parent`, that covers the roots the platform declares for other evaluation sites, such as `input`, `vars`, `trigger`, `data` and `features`. Each of them faulted at the option check in the same way. -- An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). - -**Remedy.** Rewrite the predicate against what the option check binds: `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`. To gate a choice on a master-detail header's state, read a column the detail object declares, and denormalise the header value onto the detail; `parent` is bound only for a field's own `readonlyWhen` and `requiredWhen`. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. - -**Unchanged.** - -- The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind `parent` for an option, this refusal is lifted in that same change. -- The acting user is still accepted in an option's `visibleWhen` under all four ADR-0068 spellings, and so is a grant check such as `current_user.can('OBJECT', 'edit')`. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen`, the acting user is still refused and `parent` is still accepted, as before. -- A root the platform does not declare at all, such as `app`, was already refused in an option's `visibleWhen` by the bare-reference check, and it still is, with the same message. -- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. -- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. -- Measured before crossing: every object this repository ships carries 5 option predicates, all on `showcase_cascade`: four `record.country` cascades and one `current_user.positions` role gate. That is over the 119 objects from its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them (33 objects). They have 0 refusals and 0 advisories, at the build and at the door, before this change and after it. -- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. - - diff --git a/.changeset/pre/22158-print-page-spec.md b/.changeset/pre/22158-print-page-spec.md deleted file mode 100644 index 80101156100..00000000000 --- a/.changeset/pre/22158-print-page-spec.md +++ /dev/null @@ -1,64 +0,0 @@ ---- -'@objectstack/spec': major -'@objectstack/lint': minor ---- - -feat(spec)!: a `page` gains an optional `print` declaration and a linted printable block subset; the list-export `'pdf'` prescription points at the print page; the zero-reader document schemas retire whole (#22158) - -Clause-②: yes (narrowing) - - - -**BREAKING** — the document family's exports leave `@objectstack/spec/data` (an export removal on a published entry), graded `major` on `@objectstack/spec`: Changesets is in pre mode on `main` (tag `next`), where the launch-window `major` guard stands aside for the line's breaking changes, so the level says what the change is. The v18 opening marker already takes the fixed group to `18.0.0-next.N`, so this grade moves no version on its own. `@objectstack/lint` is `minor`: its new rule refuses blocks only inside a page that declares `print`, which no page could carry before this release, and it adds exports. The `print` declaration itself is additive. - -Card ① of the ruling on PDF and print documents (letter B′): **a document is a page with a print declaration; there is no separate template type.** - -### The print page (additive) - -`PageSchema` gains an optional, closed `print` block. Its presence makes the page a print page — a document (an invoice, a delivery order, a letter, a report) authored in the page's own blocks: - -```ts -definePage({ - name: 'invoice_print', - label: 'Invoice', - type: 'record', - object: 'invoice', - regions: [ - { name: 'header', components: [{ type: 'element:image', properties: { src: '/logo.png', alt: 'ACME' } }] }, - { name: 'main', components: [ - { type: 'record:details', properties: { fields: ['customer', 'invoice_date'] } }, - { type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'description' }, { name: 'amount' }] } }, - ] }, - { name: 'footer', components: [{ type: 'element:text', properties: { content: 'Payment due within 30 days.' } }] }, - ], - print: { paperSize: 'A4', orientation: 'portrait', margins: { top: 15, bottom: 15 }, repeatHeader: true, repeatFooter: true, pageNumbers: true }, -}); -``` - -- **Keys** (`PagePrintSchema`): `paperSize` (`A4` | `A5` | `Letter` | `Legal`), `orientation` (`portrait` | `landscape`), `margins` (`{ top, right, bottom, left }` in millimetres), `repeatHeader` / `repeatFooter` (repeat the page's own `header` / `footer` region on every sheet), `pageNumbers`, and the page-break hints `repeatTableHeaders` and `avoidBreakInside`. Each maps to print CSS (`@page`, `thead { display: table-header-group }`, `break-inside`). -- **Not yet rendered.** Nothing applies these keys until the console's print rendering lands; the liveness ledger carries `print` as `planned` with an author warning, so `os validate` tells an author who writes it that the layout is validated but not yet applied. -- **Refused at the parse** (`checkPagePrintComposition`, exported for `.shape` mirrors): `print` on a `slotted` page, on an `html` / `jsx` / `react` page, on a `list` or `utility` page (a print page is a `record`, `home` or `app` page), on a `full` page with no `regions`, and `repeatHeader` / `repeatFooter` on a page with no region of that name. -- **The printable block subset** (`PRINTABLE_PAGE_COMPONENT_TYPES`, with the reason for every other vocabulary type in `PRINT_REFUSED_PAGE_COMPONENT_TYPES`): `page:section`, `page:card`, `page:footer`, `record:details`, `record:highlights`, `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`. Inside a print page any other block — one that pages or windows its rows (`object-grid`, `record:related_list`), one that lays itself out to the screen (`page:sidebar`, `object-kanban`, `object-calendar`), or one with nothing printable (controls, inputs, shell chrome, `page:tabs`) — is refused by `@objectstack/lint`'s new gating rule `print-page-block-unprintable` (`validatePrintPageBlocks`), on `os validate`, `os build`, `os lint` and the page save door. - -### The list-export prescription - -`'pdf'` stays refused in `view.exportOptions` formats. The refusal no longer says "PDF export itself was declined as NOT PLANNED" — no longer true — and instead names the view's `allowPrinting` for printing a list and a page that declares `print` for a document. - -### FROM → TO (the retirement) - -`DocumentTemplateSchema`, `DocumentSchema`, `ESignatureConfigSchema` and the orphaned `DocumentVersionSchema` (`data/document.zod.ts`) are removed from `@objectstack/spec/data`, with their type aliases (`DocumentTemplate`, `DocumentTemplateParsed`, `Document`, `DocumentParsed`, `ESignatureConfig`, `ESignatureConfigParsed`, `DocumentVersion`, `DocumentVersionParsed`). No code in this repository, objectui or hotcrm read them. - -| before | what to write instead | -| --- | --- | -| `DocumentTemplateSchema` (a docx template with placeholders) | a `page` that declares `print`, its body drawn from the printable block subset | -| `DocumentSchema` / `DocumentVersionSchema` | nothing — no document store ever kept them; a document record is ordinary object data, its files `sys_file` attachments | -| `ESignatureConfigSchema` | nothing — no e-signature integration exists | - -**The one-line fix: delete the import; author a printable document as a page with a `print` block.** Every such import is TS2305 after upgrade. `data/DocumentSchemaValidation` (the NoSQL driver's block) is unaffected. `os migrate meta --from 17` lists the delegated step (D3 `document-schemas-retired`); there is no mechanical edit, since no stack collection ever carried these shapes. - -### The retirement kit - -- `RETIRED_DEFS_BY_MAJOR[18]`: `data/DocumentTemplate`, `data/Document`, `data/ESignatureConfig`, `data/DocumentVersion`; D3 semantic entry `document-schemas-retired`, with a step-18 rationale fragment. The `ESignatureConfig` deadline-key entries in `RETIRED_KEYS_BY_MAJOR[18]` stay as history. -- No tombstone and no D2 conversion: none of the schemas is a stack collection member or a metadata type, so a conversion would have no seam that runs. -- Generated: `json-schema.manifest/data.json` −4 defs, `authorable-surface/data.json` −30 rows, `authorable-defaults/data.json` −2, the api-surface / declaration-map / export-origins shards, the `data/document` reference page removed. -- Pin: `src/data/document-schemas-retirement.test.ts`, a tree-scoped absence walk over the declared radius. diff --git a/.changeset/pre/22160-boot-diagnostics-noise-budget.md b/.changeset/pre/22160-boot-diagnostics-noise-budget.md deleted file mode 100644 index 4b53ab9f021..00000000000 --- a/.changeset/pre/22160-boot-diagnostics-noise-budget.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -*Boot diagnostics* puts the one record you have to act on first, with its fix, counts it apart from the informational ones, and no longer prints stack traces at the default log level. - -Clause-②: no - -- **The record that needs you comes first, with its fix.** A script action declared with no handler (a button wired to nothing) prints first in *Boot diagnostics*, highlighted, naming each action, with its fix on the next line. A blank project with one such action shows: - - ```text - ⚠ Boot diagnostics — 1 needs your attention · 2 informational: - ⚠ [action-governance] declared script actions with NO handler — a button wired to nothing (ADR-0078): support_desk_ticket:resolve_ticket - fix: add a `body`, or register a handler under the declared `target` - ``` - - Every other record prints once, dim, as before, and the header counts it as informational. When nothing needs your attention the header reads `ℹ Boot diagnostics — 4 informational:`. This record class is the only one highlighted so far. -- **No stack traces at the default level.** A record that carries an error's stack trace prints with its message and without the trace. The block's last line says how many traces were withheld. At `--log-level debug` the boot streams live, and the same record prints there with its stack. -- ⛔ Nothing you author changes, and no warning's log level changes. The package that logs a warning sets its level. diff --git a/.changeset/pre/22161-functional-completeness-one-line.md b/.changeset/pre/22161-functional-completeness-one-line.md deleted file mode 100644 index 6addb30406e..00000000000 --- a/.changeset/pre/22161-functional-completeness-one-line.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": patch -"@objectstack/lint": patch ---- - -fix(spec, lint): the functional-completeness findings print one verdict line, and `os explain ` carries their reasoning - -Clause-②: no - -- **Shorter verdicts.** The nine rule ids the ADR-0078 completeness predicate emits (`field/summary-without-operations`, `field/formula-without-expression`, `field/relationship-without-reference`, `field/choice-without-options`, `view/layout-without-binding`, `view/tree-without-parent-field`, `view/row-color-without-colors`, `view/row-color-unresolvable-value`, `webhook/without-triggers`) printed a `message` of 239 to 793 characters. Each `message` is now one verdict sentence of at most 200 characters (for `view/row-color-unresolvable-value`, plus the offending `key` = "value" entries it lists). It still names the runtime site that makes the verdict true, for example `` A `summary` field with no `summaryOperations` reads 0/null everywhere: the engine's summary index skips it (`engine.ts` — `if (!d.summaryOperations) continue`) ``. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`) is unchanged, and so are every rule id, severity, `path`, and what each rule accepts or refuses. For `view/layout-without-binding`, the keys the old body told the author to declare are on that `fix:` line. A tool that matched the old message text should match on `rule` and `path` instead. -- **`os explain ` takes these nine ids**, for example `os explain view/layout-without-binding`. It prints the reasoning the verdicts no longer carry: each view type's measured renderer path, refusal screen and required keys, the exemptions (`user` relationships, `multiselect`, `gallery`), the hex trap behind the two `rowColor` rules, and why an empty `triggers` is not an off switch. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the nine ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the nine entries. -- **Where the new text prints.** On the CLI, `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face. `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. At the runtime publish gate, the rule runs on `object` writes only (Studio, REST `/meta`, MCP), so only the `field/*` ids reach it. A `summary` field with no `summaryOperations` or a `formula` field with no `expression` is an `error`: the 422 issue's `message` changes, and so does the server's refusal log line under the unlinted-writes hatch. A `checkboxes` field with no `options` is a `warning`: the `message` in the 2xx response's `advisories` changes, and so does the deduped `[Protocol] authoring advisory` server log line. Each issue's `hint` is unchanged. A `lookup`/`master_detail` with no `reference` and a `select`/`radio` with no `options` never reach this gate: the object schema's own parse refuses them first, with its own message, which this change does not touch. -- **Unchanged doors.** The registry's boot-time line (`[Registry] Object "…" registered with N functionally-incomplete field(s) …`) prints each finding's rule id and `fix`, never its `message`, so it reads as before. The webhook auto-enqueuer's `webhook/without-triggers` boot warning has its own text, also unchanged. The `view/*` and `webhook/*` ids do not run at the runtime publish gate. diff --git a/.changeset/pre/22161-lint-slice-10-one-line.md b/.changeset/pre/22161-lint-slice-10-one-line.md deleted file mode 100644 index c3f0ff02e53..00000000000 --- a/.changeset/pre/22161-lint-slice-10-one-line.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): the flow-credential, AI skill, managed-object API method, organization-axis business-unit grant, retired permission residue, seed and semantic-role provenance findings print one verdict line, `os explain ` carries their reasoning, and `field-no-consumers` stops listing every object that shares a field name - -Clause-②: no - -- **Shorter verdicts.** Each finding of these 9 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 194 characters, and the runtime publish gate's suite at most 181; before, the longest of each ran from 222 to 441 characters. The ids: - - flows: `flow-credential-literal` (a literal credential in an `http` node's headers or url query, or in a `connectorConfig.input`); - - AI agents and skills: `ai-skill-surface-mismatch` and `ai-skill-tool-unresolved`; - - managed objects: `object/managed-api-method-unaffordable`; - - sharing rules: `org-axis-cross-org-bu-grant` (its sibling `org-axis-permission-inheritance` already printed one sentence of at most 174 characters and is unchanged); - - permission sets: `permission-retired-lifecycle-residue`; - - seed datasets: `seed-insert-mode-duplicates-on-replay` and `seed-value-outside-state-machine`; - - semantic roles: `semantic-role-field-unprovisioned`. - - A tenth id this change shortened, `flow-filter-token-unknown`, is retired in this same release with its rule: a flow filter operand is a value slot, and a token there is refused as `expression-invalid` by `validateStackExpressions` (its `ExprIssue`), with the CEL envelope that replaces it. - - A verdict no longer repeats what the finding's `where` already names: the skill (`ai-skill-tool-unresolved` now opens on the tool), the managed object, the sharing rule's object and the semantic-role object. `flow-credential-literal` keeps a short route (`move it to a connector's \`auth.credentialRef\``, or `drop it` for a connector input) because `os lint` prints the fix only under `--fix`; the route by auth variant stays the fix line's. `seed-value-outside-state-machine` names the first three declared states, then `(and N more)`. `object/managed-api-method-unaffordable` no longer appends the spec's `describeManagedApiMethodConflicts` sentence; it names the refused verbs, the `managedBy` bucket and the affordances they need. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. -- **`field-no-consumers`** names the first other object that declares a field of the same name and counts the rest (`a consumer of the same name on "showcase_account" (and 9 more) does not count`), where it listed every one. On `os validate` of the showcase app its longest finding drops from 341 characters to 199, and that 341-character one, which named ten objects, now prints 143. Its `inert` and `carrier-only` verdicts and its explanation are otherwise unchanged. -- **`os explain ` takes these 9 ids**, for example `os explain flow-credential-literal`. It prints the reasoning the verdicts no longer carry: which flow positions are served to every flow reader and where a credential belongs, by auth variant; how an agent's and a skill's surfaces must match, and why an unresolved skill name is skipped; how a skill's tool name resolves and when an action materialises as a tool; which affordance each write verb needs and what the registry does with a refused one; why business-unit trees stay inside one organization and which recipients may share a platform-global object; why the retired `allowRestore` / `allowPurge` default is accepted in silence and which channels miss it; why seeds must be safe to replay and which states a seeded value may name; and why an injected column on an external object is blank. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 9 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 9 entries. -- **Where the new text prints.** On the CLI, all 10 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs when it compiles at startup, and on each watch recompile under `--verbose`), `os lint` (as `where: message`) and the scaffold check `os init` runs print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. `os verify` prints the error-severity ids' new text in its refusal list (`ai-skill-surface-mismatch`, `object/managed-api-method-unaffordable`, `org-axis-cross-org-bu-grant`) and counts the warnings without printing them. At the runtime publish gate (Studio, REST `/meta`, MCP): an `object` write's `object/managed-api-method-unaffordable` changes the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`; a `flow` write's `flow-credential-literal` and a `permission` write's `permission-retired-lifecycle-residue` change the 2xx `advisories` entry and the `[Protocol] authoring advisory` log line. Each issue's `hint` is unchanged. -- **Never at the runtime gate:** `org-axis-cross-org-bu-grant`, the two seed ids, `semantic-role-field-unprovisioned` and `field-no-consumers` (CLI-only rules), and the two AI ids, whose rules run at that door only for a `flow` write, whose snapshot carries no agents or skills of their own. diff --git a/.changeset/pre/22161-lint-slice-11-one-line-fix.md b/.changeset/pre/22161-lint-slice-11-one-line-fix.md deleted file mode 100644 index 529a3187b6d..00000000000 --- a/.changeset/pre/22161-lint-slice-11-one-line-fix.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): 15 author-time rule ids print a `fix:` line of one remedy sentence, and `os explain ` carries what those lines used to enumerate - -Clause-②: no - -- **Shorter fix lines.** Each finding of these 15 rule ids now carries a `hint` (the CLI's `fix:` line, the runtime issue's `hint`) of one remedy sentence of at most 200 characters (the lengths here are the `hint` alone; the CLI prints it after `fix: `). Before, the longest of each ran from 128 to 2,280 characters. The ids: - - sharing rules: `sharing-rule-runtime-variable-condition` (2,280 → 195) and `sharing-rule-unlowerable-condition` (968 → 199); - - approvals: `approval-approvers-may-resolve-empty` (its `manager` arm 2,243 → 198, its group arm 440 → 200); - - RLS predicates: `rls-predicate-unenforceable` (1,056 → 191), `rls-predicate-over-budget` (1,044 → 197) and `rls-predicate-unknown-field` (766 → 154); - - filters: `filter-empty-combinator` (764 → 154); - - permission sets: `permission-retired-lifecycle-residue` (681 → 14: `Delete the key`); - - view visibility: `visibility-predicate-over-budget` (651 → 179); - - hook bodies: `hook-api-update-readonly-field` (647 → 182); - - flows: `flow-time-relative-descriptor-invalid` (342 → 176); - - pages: `component-type-unknown` (229 → 158), `react-chart-aggregate-invalid` (363 → 191) and `react-chart-drilldown-invalid` (128 → 181: longer, because it now carries the remedy instead of a pointer); - - fields: `field-no-consumers` (on `os validate` of the showcase app, 624 → 199). -- **Four fix lines pointed at text the verdict no longer carries.** `component-type-unknown` said "Apply the prescription above", `react-chart-drilldown-invalid` and `react-chart-aggregate-invalid` said "the rejection above carries the fix", and `flow-time-relative-descriptor-invalid` said "satisfies each message above", after earlier releases cut those verdicts to one sentence. Each now names the remedy itself: the retired component type's "Delete the …" sentence cut from `RETIRED_PAGE_COMPONENT_TYPES` (for example `Delete the \`element:form\` component and use the object-bound \`object-form\` block instead`), the schema refusal's rename or the layer a misplaced key belongs to, and the descriptor key the verdict names. -- **Enumerations are cut to the first item and a count.** `field-no-consumers` names the first carrier site and counts the rest (`… together with its 16 carrier site(s): translations[0].en.objects.showcase_account.fields.website (and 15 more)`), where it listed every one. `rls-predicate-unenforceable` gives the first faulty site's rewrite and counts the rest; for a bare `current_user` it names the key slot (`current_user.`) instead of listing every kernel key. `rls-predicate-unknown-field` no longer lists every field of the object: the verdict's "Did you mean" already names the nearest one. `permission-retired-lifecycle-residue` carries the retirement's own "Delete the key" sentence, cut from the tombstone's prescription, not the whole prescription. -- **`os explain ` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`, which `os explain ` prints: the lowerable predicate subset and its traps, the comparison classes with the declared types each holds, the rule each `current_user` holding breaks, which mechanism replaces a `current_user` read on which sharing model (an RLS policy narrows only `public_read` / `public_read_write`; it grants nothing on `private`), the graded routes that populate `sys_user.manager_id`, the four ways to shrink an oversized RLS predicate (and why a top-level `&&` must never be split), and the declared "match nothing" filter spelling. The entries already existed; they gain paragraphs. -- **Where the new fix lines print.** On the CLI: the `fix:` line under each finding of `os validate`, `os build` (and `os compile`, which `os dev` runs at startup and, under `--verbose`, on each watch recompile), the scaffold check `os init` runs, and `os verify`'s refusal list; `os lint` prints it as `→ fix:` under `--fix` and as `fix` in `--json`; `os validate --json` (`errors`) and `os build --json` (author-time `issues`) carry it on an error-severity finding; their `warnings` carry an advisory as one `where: message` string, without it. At the runtime publish gate (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), the `hint` FROM → TO, measured at the door: - - a `flow` write: `flow-time-relative-descriptor-invalid` (422 `issues[].hint`, 342 → 176), `filter-empty-combinator` (422, 647 / 764 → 134 / 154) and `approval-approvers-may-resolve-empty` (2xx `advisories[].hint` and the `[Protocol] authoring advisory` log line, 2,243 / 440 → 198 / 200); - - a `permission` write: `rls-predicate-unenforceable` (422, 912 / 312 → 186 / 121), `rls-predicate-unknown-field` (422, 708 → 154), `rls-predicate-over-budget` (422, once the platform CEL bounds refuse) and `permission-retired-lifecycle-residue` (2xx advisory, 573 / 681 → 14); - - a `view` write: `visibility-predicate-over-budget` (422, 651 → 179). - - Studio's save-advisory toast prints an advisory's `hint` after its message, so the approval and residue toasts shorten; 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. -- **Never at the runtime gate:** the two sharing-rule ids, `field-no-consumers`, `component-type-unknown`, the two `react-chart-*` ids and `hook-api-update-readonly-field` run on the CLI only. -- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. diff --git a/.changeset/pre/22161-lint-slice-12-one-line-fix.md b/.changeset/pre/22161-lint-slice-12-one-line-fix.md deleted file mode 100644 index f96331ec587..00000000000 --- a/.changeset/pre/22161-lint-slice-12-one-line-fix.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): 16 more author-time rule ids print a `fix:` line of one remedy sentence, and `os explain ` carries what those lines used to enumerate - -Clause-②: no - -- **Shorter fix lines.** Each finding of these 16 rule ids 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: - - validation rules: `validation-rule-json-schema-unknown-format` (646 → 183); - - datasets and cubes: `measure-aggregate-field-type-refused` (645 → 178) and `dimension-json-stored-field-refused` (262 → 177); - - form predicates: `predicate-rhs-path-shaped` (643 → 184), `predicate-path-unrooted` (214 → 122) and `predicate-path-unresolved` (201 → 153); - - RLS predicates: `rls-predicate-unknown-user-variable` (618 → 193) and `rls-predicate-unparseable` (523 → 171); - - hook bodies: `hook-api-update-readonly-when-field` (607 → 173); - - action names: `action-name-undefined` (560 → 195); - - AI skills: `ai-skill-tool-unresolved` (548 → 189); - - organization axis: `org-axis-permission-inheritance` (542 → 145) and `org-axis-cross-org-bu-grant` (315 → 141); - - object names: `object-reference-unknown` (522 → 134) and `object-reference-unregistered-platform` (500 → 187); - - filters: `filter-empty-node` (515 → 113). -- **Lists are no longer echoed.** `object-reference-unknown` no longer lists every object the stack and its artifact define, and `action-name-undefined` no longer lists every action of the stack or of the related object: the verdict's "Did you mean" already names the nearest one. `validation-rule-json-schema-unknown-format` names the nearest registered format and no longer lists all of them, and `ai-skill-tool-unresolved` no longer lists the tool family prefixes. `rls-predicate-unknown-user-variable` names the kernel keys and the nearest one, and so holds no author text. -- **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: why a missing object costs what it costs at each site (the record picker, the view container's index, the dashboard filter, the dataset's reports, the nav entry), the doors that refuse a refused aggregate pair later (a dataset's compile, a cube's query), how an app stages an RLS membership set, why no system context waives a `readonlyWhen` lock, and the declared "match nothing" filter spelling. Where the namespace-prefixed object exists, a view container's fix line is that spelling alone (`write "my_app_order_line", not "order_line"`). -- **`os explain ` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`, which `os explain ` prints. Two ids had no entry and gain one, so their `rule:` line now ends with the `os explain` pointer: `object-reference-unknown` ("how an object name resolves, and what a miss costs") and `org-axis-permission-inheritance` ("why access never walks the organization tree"). The other 14 entries gain paragraphs. The registered format names and the tool family prefixes are written out in their entries and held to the live sets by the rules' tests. -- **Where the new fix lines print.** On the CLI: the `fix:` line under each finding of `os validate`, `os build` (and `os compile`, which `os dev` runs at startup and, under `--verbose`, on each watch recompile), the scaffold check `os init` runs, and `os verify`'s refusal list; `os lint` prints it as `→ fix:` under `--fix` and as `fix` in `--json`; `os validate --json` (`errors`) and `os build --json` (author-time `issues`) carry it on an error-severity finding; their `warnings` carry an advisory as one `where: message` string, without it. At the runtime publish gate (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), the `hint` FROM → TO, measured at the door: - - an `object` write: `validation-rule-json-schema-unknown-format` (422 `issues[].hint`, 638 / 606 → 175 / 143, with and without a nearest name); - - a `dataset` write: `measure-aggregate-field-type-refused` (422, 606 / 581 → 147 / 122), `dimension-json-stored-field-refused` (422, multi-value 259 → 166, structured JSON 177 unchanged), `object-reference-unknown` (422 on the dataset's `object`, 331 → 134) and `object-reference-unregistered-platform` (2xx `advisories[].hint` and the `[Protocol] authoring advisory` log line, 449 → 187); - - a `view` write of a schema-bound metadata form: `predicate-rhs-path-shaped` (422 on a dotted right-hand side, 384 → 156; 2xx advisory on a bare word, 631 → 180), `predicate-path-unrooted` (422, 214 → 122) and `predicate-path-unresolved` (422, 100 → 36 with a nearest key); - - a `permission` write: `rls-predicate-unparseable` (422, 523 → 171) and `rls-predicate-unknown-user-variable` (422, 566 / 634 → 158 / 190, without and with a nearest key); - - a `flow` or `report` write: `filter-empty-node` (422, 515 / 278 / 170 → 113 / 107 / 69 for the whole filter, a `$or` branch and a `$and` branch). - - Studio's save-advisory toast prints an advisory's `hint` after its message, so the platform-object and bare-word toasts shorten; 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. -- **Never at the runtime gate:** `hook-api-update-readonly-when-field`, `action-name-undefined`, `ai-skill-tool-unresolved` and the two `org-axis-*` ids run on the CLI only, as do the object-reference ids on every site but a dataset's `object`. -- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. diff --git a/.changeset/pre/22161-lint-slice-13-one-line-fix.md b/.changeset/pre/22161-lint-slice-13-one-line-fix.md deleted file mode 100644 index 6e29409ba5d..00000000000 --- a/.changeset/pre/22161-lint-slice-13-one-line-fix.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): 18 more author-time 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 18 rule ids 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: - - flow credentials: `flow-credential-literal` (646 → 177); - - flow writes: `flow-update-readonly-when-field` (549 → 174) and `flow-update-readonly-field` (400 → 143); - - list-view bulk wiring: `action-dispatch-contract-mismatch` (514 → 157); - - security posture: `security-controlled-by-parent-ambiguous-relation` (496 → 174), `security-master-detail-ungranted` (314 → 153), `security-controlled-by-parent-no-relation` (294 → 194), `security-fls-unknown-field` (293 → 132) and `security-wildcard-vama` (218 → 103); - - sharing rules: `sharing-rule-object-not-shareable` (482 → 144) and `sharing-rule-object-controlled-by-parent` (395 → 169); - - interface pages: `page/visualization-without-binding` (480 → 183); - - action bodies: `action-api-update-readonly-when-field` (470 → 188); - - flow triggers: `flow-api-trigger-secret-missing` (461 → 171), `flow-time-relative-descriptor-unroutable` (441 → 166), `flow-trigger-unroutable` (295 → 163), `flow-trigger-unknown-event` (268 → 101) and `flow-trigger-unknown-object` (210 → 156). -- **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: why `runAs: 'system'` is the channel for a static readonly field and why a `beforeInsert` stamp survives the strip; why a hook-derived value lands on a `readonlyWhen`-locked record, the drop-the-field alternative and that the warning never blocks a build; how to pick between the two bulk contracts; the controlled-by-parent tier precedence and what each sharing baseline means; how to tell which half of a sharing rule on a public object is wrong; why `sourceView` is an interface page's one binding channel; the descriptor's optional keys and the schedule's default; the api trigger's signature format; the connector's `provider`, the Basic auth variant, where `credentialRef` resolves and that a `{variable}` template draws nothing. -- **A list becomes a nearest name.** `security-fls-unknown-field` no longer lists up to twelve of the object's fields; it names the nearest declared or registry-injected column when one is close (`'crm_account.description' is the nearest`), and none otherwise. `page/visualization-without-binding` names the field TYPE the derivation looks for and no longer repeats the name pattern it also tries. -- **`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 had no entry and gain one, so their `rule:` line now ends with the `os explain` pointer: `security-wildcard-vama` ("why the '*' entry cannot carry View All / Modify All") and `flow-trigger-unknown-object` ("why an unknown trigger object only warns"). The other 16 entries gain paragraphs. The auth variants and the descriptor keys the lines and entries spell out are held to the spec's `ConnectorInstanceAuthSchema` and `TimeRelativeTriggerSchema` by the rules' tests. -- **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 13 of the 18 fire; the `hint` FROM → TO, measured at the door: - - a `flow` write: `flow-credential-literal` (2xx `advisories[].hint`, header / query / connector input 646 / 528 / 488 → 173 / 177 / 141), `flow-update-readonly-field` (422 `issues[].hint`, update / create 239 / 396 → 116 / 139), `flow-update-readonly-when-field` (2xx advisory, 527 → 161, under `runAs: 'user'` and `'system'` alike), `flow-trigger-unknown-object` (2xx advisory, record / time-relative 210 / 179 → 156 / 125), `flow-trigger-unknown-event` (422, token / array 191 / 268 → 101 / 96), `flow-time-relative-descriptor-unroutable` (422, 441 → 166), `flow-trigger-unroutable` (422, 295 → 163) and `flow-api-trigger-secret-missing` (422, 461 → 171); - - an `object` write: `security-controlled-by-parent-no-relation` (422, 294 → 194), `security-controlled-by-parent-ambiguous-relation` (422, 496 → 167) and `security-master-detail-ungranted` (2xx advisory, 304 → 147); - - a `permission` write: `security-fls-unknown-field` (422, 324 → 132), `security-wildcard-vama` (422, 218 → 103) and `security-master-detail-ungranted` (2xx advisory, 304 → 147). - - Studio's save-advisory toast prints an advisory's `hint` after its message, so the credential, readonlyWhen, unknown-object and ungranted-detail toasts shorten, and Studio's draft-changes panel shows the `hint` in place of the message for the two controlled-by-parent errors its in-browser security check finds on object drafts. 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. -- **Never at the runtime gate:** `action-dispatch-contract-mismatch`, `page/visualization-without-binding`, `action-api-update-readonly-when-field` and the two `sharing-rule-object-*` ids run on the CLI only. -- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. diff --git a/.changeset/pre/22161-lint-slice-14-one-line-fix.md b/.changeset/pre/22161-lint-slice-14-one-line-fix.md deleted file mode 100644 index 962cabebf37..00000000000 --- a/.changeset/pre/22161-lint-slice-14-one-line-fix.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): 16 more author-time 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 16 rule ids 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: - - list-view search: `searchable-field-unknown` (448 → 129), `searchable-field-unprovisioned` (362 → 175) and `searchable-field-unsearchable` (328 → 139); - - list-view sort: `sort-field-unprovisioned` (378 → 191), `sort-field-unsortable` (332 → 154) and `sort-field-unknown` (301 → 142); - - visibility predicates: `visibility-predicate-unknown-function` (375 → 165), `visibility-bare-identifier` (221 → 102) and `visibility-predicate-syntax` (214 → 113); - - actions: `action-no-placement` (424 → 171); - - import mappings: `mapping-target-field-unknown` (405 → 189); - - dashboards: `unconsumed-widget-option` (396 → 135); - - app navigation: `nav-object-ungranted` (370 → 118); - - hook bodies: `hook-body-write-unprovisioned-anchor` (365 → 178), `hook-body-write-unknown-field` (275 → 110) and `hook-body-source-unparseable` (254 → 105). - - A seventeenth id this change shortened, `flow-filter-token-unknown`, is retired in this same release with its rule: a flow filter operand is a value slot, and a token there is refused as `expression-invalid` by `validateStackExpressions` (its `ExprIssue`), with the CEL envelope that replaces it. -- **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: why a stale or inadmissible `searchableFields` entry is a `400 INVALID_FIELD` on list search, that a view narrows the object's searchable set and never widens it, and that a lookup column stores only an id; that both runtime doors refuse a formula sort with `400 INVALID_SORT`; how to vouch for an unprovisioned anchor (`columnMap`) or opt the object out of the injection (`ownership: 'none'`, `systemFields: { audit: false }`); the other-language operator spellings CEL does not parse, why re-spelling cannot fix an unregistered function, and which namespaces each surface binds; why gating a nav entry does not clear a missing grant; which hook-body write shapes are checked; what a partially parsed body leaves unchecked. -- **A list the verdict already covers is not echoed.** `searchable-field-unknown`, `sort-field-unknown` and `mapping-target-field-unknown` no longer list every field of the object: the verdict's "Did you mean" names the near miss. `mapping-target-field-unknown` still lists the object's compound fields and their parts, as every import-mapping refusal does. -- **`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 had no entry and gain one, so their `rule:` line now ends with the `os explain` pointer: `action-no-placement` ("where an action can be placed") and `unconsumed-widget-option` ("which widget options a renderer reads"). Five entries gain text. The action locations the new entry spells out are held to the spec's `ACTION_LOCATIONS` by the rule's test. -- **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 9 of the 16 fire; the `hint` FROM → TO, measured at the door: - - a `view` write: `sort-field-unknown` (422 `issues[].hint`, a name / a dotted path 114 / 261 → 47 / 135), `sort-field-unsortable` (422, 318 → 140), `sort-field-unprovisioned` (2xx `advisories[].hint`, 363 → 176), `searchable-field-unknown` (422, a name / a dotted path 281 / 430 → 48 / 129), `searchable-field-unsearchable` (422, a formula / outside the declared set / a lookup / a json column 222 / 142 / 323 / 157 → 110 / 68 / 135 / 64), `searchable-field-unprovisioned` (2xx advisory, 361 → 174), `visibility-predicate-syntax` (422, a recognised spelling / none 106 / 214 → 106 / 107), `visibility-predicate-unknown-function` (422, 371 → 165) and `visibility-bare-identifier` (422, a bare name / an unbound namespace 160 / 215 → 41 / 96); - - an `object` write: `searchable-field-unknown` (422, the object's own set / a built-in list view 266 / 261 → 55 / 50), `searchable-field-unsearchable` (422, 222 → 110) and `searchable-field-unprovisioned` (2xx advisory, 361 → 174). - - Studio's save-advisory toast prints an advisory's `hint` after its message, so the two `*-unprovisioned` toasts shorten; 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. -- **Never at the runtime gate:** `action-no-placement`, `mapping-target-field-unknown`, `unconsumed-widget-option`, `nav-object-ungranted` and the three `hook-body-*` ids run on the CLI only. -- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. diff --git a/.changeset/pre/22161-lint-slice-15-one-line-fix.md b/.changeset/pre/22161-lint-slice-15-one-line-fix.md deleted file mode 100644 index c5e024afe21..00000000000 --- a/.changeset/pre/22161-lint-slice-15-one-line-fix.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): 17 more author-time 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 17 rule ids 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: - - react pages: `react-chart-field-unprovisioned` (362 → 175), `react-prop-missing-required` (277 → 131), `react-page-source-unparseable` (254 → 105) and `react-block-needs-record-context` (228 → 195); - - action bodies: `action-body-write-unprovisioned-anchor` (358 → 171), `action-body-write-unknown-field` (323 → 91), `action-record-write-discarded` (309 → 104) and `action-body-source-unparseable` (254 → 105); - - agents: `agent-authoring-withdrawn` (363 → 158) and `default-agent-outside-roster` (237 → 118); - - flow nodes: `flow-node-write-unprovisioned-anchor` (358 → 171) and `flow-node-write-unknown-field` (289 → 110); - - object validation rules: `validation-rule-json-schema-uncompilable` (341 → 124) and `validation-rule-regex-uncompilable` (300 → 135); - - translations: `translation-section-name-missing` (359 → 172); - - managed objects: `object/managed-api-method-unaffordable` (357 → 169); - - app navigation: `nav-object-unservable` (348 → 156). -- **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: how to vouch for an unprovisioned anchor (`columnMap`) or opt the object out of the injection (`ownership: 'none'`, `systemFields: { audit: false }`), now on the react-chart, action-body and flow-node anchor ids too; what a partially parsed source leaves unchecked; which action-body write shapes are checked and that the warning never blocks a build; why the flow-node unknown-field finding gates where the body rules advise; that skills attach to the platform agents by `surface` affinity, so dropping a custom agent loses nothing; the escaped regex spellings and the named `format`s; the shapes the common JSON Schema keywords take; that a suggested section name is never a key derived from the label; and that a record block's react-tier replacement binds the record itself. -- **Shared one-line forms.** The anchor and parse ids of the react-page, action-body and flow-node rules print the same one-line forms the hook-body ids print (`If the remote table carries '…', declare it in …'s own fields (mapped through columnMap); otherwise drop the reference or opt the object out of the injection`, and `Fix the syntax error and re-run: until the source parses, the checks cover only what the parser recovered`). -- **Quoted from the contract, cut to one sentence.** A missing required react-tier binding still prints the contract's own description of it, now its first sentence, which names the binding and its spelling (`Pass data={…}: The object this block binds to, written as the metadata-tier data source: data={{ provider: 'object', object: '…' }}`); the rest of the description is in the published react-blocks reference. A record block's line is the spec's named replacement behind the lead-in `Instead:`. -- **`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; eleven entries gain text. The named formats the regex entry spells out are held to the spec's `FormatValidationSchema` by the rule's test. `react-prop-missing-required` has no entry, as before, so its `rule:` line carries no pointer. -- **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 5 of the 17 fire; the `hint` FROM → TO, measured at the door: - - an `object` write: `object/managed-api-method-unaffordable` (422 `issues[].hint`, two refused verbs / every write verb 335 / 372 → 132 / 169), `validation-rule-regex-uncompilable` (422, the pattern `([` / a 28-character pattern 288 / 314 → 123 / 149) and `validation-rule-json-schema-uncompilable` (422, 341 → 124); - - a `flow` write: `flow-node-write-unknown-field` (422, with / without a near miss 271 / 250 → 92 / 71) and `flow-node-write-unprovisioned-anchor` (2xx `advisories[].hint`, 361 → 174). - - Studio's save-advisory toast prints an advisory's `hint` after its message, so the flow-node anchor toast shortens; 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. -- **Never at the runtime gate:** the four react-page ids, the four action-body ids, the two agent ids, `translation-section-name-missing` and `nav-object-unservable` run on the CLI only. -- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. diff --git a/.changeset/pre/22161-lint-slice-16-one-line-fix.md b/.changeset/pre/22161-lint-slice-16-one-line-fix.md deleted file mode 100644 index 7c1c3c57de9..00000000000 --- a/.changeset/pre/22161-lint-slice-16-one-line-fix.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): 17 more author-time 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 17 rule ids 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: - - approval nodes: `approval-approver-not-membership-tier` (313 → 168), `approval-expression-no-empty-policy` (294 → 121) and `approval-expression-invalid` (292 → 144); - - datasets: `dataset-include-unknown` (336 → 97) and `dataset-field-unknown` (206 → 139); - - record titles: `title-format-retired` (267 → 148) and `title-unresolvable` (237 → 122); - - list views: `list-view-field-unknown` (258 → 62) and `list-view-field-dotted` (233 → 158); - - translations: `translation-target-unknown` (317 → 164); - - seeds: `seed-insert-mode-duplicates-on-replay` (309 → 131) and `seed-value-outside-state-machine` (251 → 139); - - charts: `chart-measure-unknown` (285 → 115); - - object field-name lists: `object-field-ref-unknown` (284 → 186); - - stack views: `view-container-shape` (256 → 187); - - agents: `ai-skill-surface-mismatch` (235 → 171); - - source-tier pages: `page-source-className-tailwind` (231 → 167). -- **Lists are no longer echoed.** `dataset-include-unknown`, `dataset-field-unknown`, `list-view-field-unknown` and `object-field-ref-unknown` no longer append the object's whole field list, `chart-measure-unknown` no longer lists the dataset's measures, and `translation-target-unknown` no longer lists the declared fields, views, sections, tabs, rules, actions, apps, navigation ids, dashboards, widgets, header actions, flows, screens, screen fields, refusing end nodes, params, outcomes or result paths: the verdict's "Did you mean" (on `chart-measure-unknown`, the fix line's own) names the nearest one. Two facts a list used to carry stay on the line as one bounded clause: a stale `_views` key is told that a nameless default `list` is keyed `default`, and a `_tabs` key over an object with no preset bar is told to declare the tab in a page's `interfaceConfig.userFilters.tabs`, because a list view's own `tabs` has no renderer. `dataset-filter-field-unknown`, outside this change, keeps its field list. -- **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: what an expression approver's three roots hold, why `record` and `previous` are unbound at an approval node, and what `resolveAs` does; what each `onEmptyApprovers` policy does; that a business role is always a position; that a dataset join is derived from the object graph, up to three hops, with `a.b` including `a`; why `titleFormat` is retired and how a record title resolves, with its fallback; what a denormalised field is; which presentation positions a chart measure lands on, and that none carries a `suppressWarnings` key; the composite `externalId` of a junction table; that the seed exemption never licenses an undeclared state; when a skill may declare `surface: 'both'`; and why a Tailwind class in page source produces no CSS, with the theme-token and overlay advice. -- **`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. Five ids gain an entry, so their `rule:` line now carries the `os explain` pointer: `approval-expression-invalid`, `approval-expression-no-empty-policy`, `title-format-retired`, `title-unresolvable` and `page-source-className-tailwind`. Ten existing entries gain text. The enumerations an entry spells out are held to their sources by the rules' tests: the empty-slate policies to the spec's `onEmptyApprovers` enum, the expression roots to `APPROVER_EXPRESSION_ROOTS`, and the title-eligible types to `TITLE_ELIGIBLE_TYPES`. -- **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 9 of the 17 fire; the `hint` FROM → TO, measured at the door: - - a `flow` write with an approval node: `approval-approver-not-membership-tier` (2xx `advisories[].hint`, 313 → 138), `approval-expression-invalid` (422 `issues[].hint`: an empty expression 216 → 90, a `record` root 292 → 144; a parse failure 129, a bare root 110 and the `resolveAs` advisory 102 unchanged) and `approval-expression-no-empty-policy` (2xx advisory, 294 → 121); - - a `dataset` write: `dataset-include-unknown` (422, an entry naming nothing / an ordinary field 239 / 187 → 93 / 93) and `dataset-field-unknown` (422, a dimension / a measure 184 / 182 → 132 / 130); - - an `object` write: `object-field-ref-unknown` (422, 180 → 128; the same text is the `fix` of the publish receipt's `object_field_ref_unknown` runtime probe issue), and on its `listViews`, `list-view-field-unknown` (422, 110 → 57) and `list-view-field-dotted` (422, 231 → 157); - - a `view` write: `list-view-field-unknown` (422, 110 → 57) and `list-view-field-dotted` (422, a projection / a filter 231 / 143 → 157 / 95); - - a `report` write: `chart-measure-unknown` (422 at the query position, 117 → 106; 2xx advisory at a presentation position, 301 → 113). - - Studio's save-advisory toast prints an advisory's `hint` after its message, so the three approval advisories and the chart presentation advisory 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. -- **Never at the runtime gate:** `title-format-retired`, `title-unresolvable`, `translation-target-unknown`, both seed ids, `view-container-shape`, `ai-skill-surface-mismatch` and `page-source-className-tailwind` run on the CLI only. -- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. diff --git a/.changeset/pre/22161-lint-slice-2-one-line.md b/.changeset/pre/22161-lint-slice-2-one-line.md deleted file mode 100644 index 182e135a0a1..00000000000 --- a/.changeset/pre/22161-lint-slice-2-one-line.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): 26 more author-time findings print one verdict line, and `os explain ` carries their reasoning - -Clause-②: no - -- **Shorter verdicts.** Each finding of these 26 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 200 characters; before, the longest of each ran from 205 to 697 characters. The ids: - - dashboard widgets: `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`, `dashboard-filter-field-not-included`, `dashboard-filter-field-unprovisioned`, `widget-filter-field-unknown`, `widget-filter-field-not-included`, `widget-sortby-unselected`, `chart-field-unknown`, `chart-measures-missing`, `widget-measures-missing`, `chart-dimensions-missing`; - - datasets: `dataset-include-unknown`, `dataset-field-unknown`, `dataset-field-not-included`, `dataset-filter-field-unknown`; - - security posture: `security-controlled-by-parent-ambiguous-relation`, `security-fls-unknown-field`, `security-controlled-by-parent-no-relation`, `security-master-detail-ungranted`, `security-owd-alias`, `security-delegation-missing-reason`; - - visibility predicates: `visibility-root-mislayered`, `visibility-predicate-over-budget`, `visibility-predicate-syntax`, `visibility-predicate-unknown-function`, `visibility-bare-identifier`. - - The values the author wrote still close each verdict — the field path, the candidate roster, the selection list, the predicate excerpt (now at most 72 characters; `visibility-predicate-over-budget` no longer echoes the predicate at all, its `path` locates it) — so a verdict over a long authored value grows with it. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. -- **`os explain ` takes these 26 ids**, for example `os explain chart-field-unknown`. It prints the reasoning the verdicts no longer carry: how a dashboard filter reaches every widget, why the renderer ignores `chartConfig` binding keys, what an unresolved dataset path does to the analytics query, how the master relation of a `controlled_by_parent` object is chosen, how the runtime resolves a field-permission key, why a visibility predicate that cannot evaluate renders its element anyway. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 26 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 26 entries. -- **Where the new text prints.** On the CLI, `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. `os doctor`'s dashboard widget check prints the 11 widget ids as `where: message`, with no `rule:` line. At the runtime publish gate (Studio, REST `/meta`, MCP), by write type: a `dashboard` write carries the widget ids, a `dataset` write the dataset ids, a `view` write the visibility ids, an `object` write the two `controlled_by_parent` ids and `security-master-detail-ungranted`, a `permission` write `security-fls-unknown-field` and `security-master-detail-ungranted`, a `seed` write `security-delegation-missing-reason`. An `error` changes the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`; a `warning` (`dashboard-filter-field-unprovisioned`, `chart-field-unknown`, `chart-measures-missing`, `widget-measures-missing`, `chart-dimensions-missing`, `security-master-detail-ungranted`, `visibility-root-mislayered`) changes the `message` in the 2xx response's `advisories` and the deduped `[Protocol] authoring advisory` server log line. Each issue's `hint` is unchanged. -- **Never at the runtime gate:** `security-owd-alias`. The object schema's closed `sharingModel` / `externalSharingModel` enums refuse those values at parse, with their own message, before the gate runs; the rule speaks only on the unparsed doors (`os lint` on a raw config, a direct call). diff --git a/.changeset/pre/22161-lint-slice-3-one-line.md b/.changeset/pre/22161-lint-slice-3-one-line.md deleted file mode 100644 index dab08ca60cd..00000000000 --- a/.changeset/pre/22161-lint-slice-3-one-line.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): 14 more author-time findings print one verdict line, and `os explain ` carries their reasoning - -Clause-②: no - -- **Shorter verdicts.** Each finding of these 14 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire, and the CLI suite's handler-hook variants, is at most 200 characters; before, the longest of each ran from 212 to 792 characters. The ids: - - hook bodies: `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`; - - action bodies: `action-body-write-unknown-field`, `action-body-write-unprovisioned-anchor`, `action-record-write-discarded`, `action-body-source-unparseable`; - - flow nodes: `flow-node-write-unknown-field`, `flow-node-write-unprovisioned-anchor`; - - readonly writes: `flow-update-readonly-field`, `flow-update-readonly-when-field`, `hook-api-update-readonly-field`, `hook-api-update-readonly-when-field`, `action-api-update-readonly-when-field`. - - The three write surfaces now share one wording per question: an undeclared field ends on "so the write is refused (INVALID_FIELD / 400)", an unprovisioned anchor reads "'FIELD' is an injected column with no storage on external object 'OBJECT', so … can never land", an unparseable hook or action body reads "L2 body did not parse (…), so writes in its unread part go unchecked", and a readonly write says it is "silently stripped" (a `readonlyWhen` field "where its predicate is TRUE"). The values the author wrote still close each verdict — the field, the object, the `ctx.api` call, the run identity — so a verdict over a long name grows with it, and a hook lowered from an inline `handler` keeps its " (judged on the metadata body lowered from the inline handler)" suffix. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. -- **`os explain ` takes these 14 ids**, for example `os explain hook-body-write-unknown-field`. It prints the reasoning the verdicts no longer carry: how each write channel reaches the engine's declared-field door and what the refusal takes down with it, why a write to an unprovisioned anchor on an external object passes the validator and never lands, what a partially parsed body leaves unchecked, why an action's `ctx.record` is never written back, and which readonly strip a system context waives and which it does not. The paragraphs the three surfaces share are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 14 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 14 entries. -- **Where the new text prints.** On the CLI, `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP), a `flow` write carries the four flow ids: `flow-node-write-unknown-field` and `flow-update-readonly-field` are errors, so the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change; `flow-node-write-unprovisioned-anchor` and `flow-update-readonly-when-field` are warnings, so the `message` in the 2xx response's `advisories` and the deduped `[Protocol] authoring advisory` server log line change. Each issue's `hint` is unchanged. -- **Never at the runtime gate:** the ten hook and action ids. A `hook` or `action` write does not dispatch these rules (they parse authored JavaScript, which the publish path never loads), and a `flow` write's snapshot carries no hook or action body for them to read; they speak only on the CLI doors above. diff --git a/.changeset/pre/22161-lint-slice-4-one-line.md b/.changeset/pre/22161-lint-slice-4-one-line.md deleted file mode 100644 index a46595e25d5..00000000000 --- a/.changeset/pre/22161-lint-slice-4-one-line.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): the RLS-predicate and sharing-rule findings print one verdict line, and `os explain ` carries their reasoning - -Clause-②: no - -- **Shorter verdicts.** Each finding of these 9 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites, the CLI's RLS admission suite and the runtime publish gate's suite fire is at most 195 characters; before, the longest of each ran from 492 to 2,056 characters. The ids: - - RLS policies (`permissions[].rowLevelSecurity[].using` / `.check`): `rls-predicate-unparseable`, `rls-predicate-unenforceable`, `rls-predicate-unknown-user-variable`, `rls-predicate-unknown-field`, `rls-predicate-over-budget`; - - sharing rules (`sharingRules[]`): `sharing-rule-unlowerable-condition`, `sharing-rule-object-not-shareable`, `sharing-rule-object-controlled-by-parent`, `sharing-rule-runtime-variable-condition`. - - A finding no longer echoes the predicate or the condition: its `path` names the clause, and the predicate is author text of any length (the old message quoted up to 200 characters of it, or all of it). Each verdict names what is wrong and closes on what it costs: an RLS policy the runtime drops ends "so the policy is dropped and grants no access" (a `using`) or "… and admits no write" (a `check`); a field compared with a field that holds a list or an object, or across comparison classes, quotes the first such comparison, names the column or the two classes, counts the rest, and ends on the `INVALID_FILTER / 400` refusal; a sharing condition that does not lower ends "so the rule is never seeded and grants nothing"; an anchor object a grant cannot widen names its `sharingModel` and the `SHARING_NOT_ENABLED` refusal. A quoted refusal — the compiler's, the platform's shared filter check's, or the engine's (`RLS using (CODE / STATUS): …`) — is quoted up to its own suggested rewrite, or to its first sentence. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. -- **`os explain ` takes these 9 ids**, for example `os explain rls-predicate-unenforceable`. It prints the reasoning the verdicts no longer carry: what `RLSCompiler` does with a policy it cannot compile (the deny sentinel and its ZERO rows, the OR with other policies, the INSERT check a `using` doubles as, the `check` set), the one WARN line each drop leaves, which `current_user` keys the kernel resolves and with which type, why a column that holds a list or an object — or two columns of different comparison classes — cannot be compared, what the engine's own filter admission refuses and what that costs analytics, and, for sharing rules, why a condition is compiled once at boot, what an unlowerable or refused condition leaves behind, and why a grant on a public or `controlled_by_parent` object is refused. The paragraphs the RLS and sharing rules share are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 9 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 9 entries. -- **Where the new text prints.** On the CLI, all 9 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP), a `permission` write carries the five `rls-predicate-*` ids, all errors: the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change. Each issue's `hint` is unchanged. -- **Never at the runtime gate:** the four `sharing-rule-*` ids. The rule is registered for the CLI doors only (no write type dispatches it at the publish gate), so they speak only on the CLI doors above. diff --git a/.changeset/pre/22161-lint-slice-5-one-line.md b/.changeset/pre/22161-lint-slice-5-one-line.md deleted file mode 100644 index 07fe53b435a..00000000000 --- a/.changeset/pre/22161-lint-slice-5-one-line.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): the flow-trigger, react-page, dataset-measure and validation-rule findings print one verdict line, and `os explain ` carries their reasoning - -Clause-②: no - -- **Shorter verdicts.** Each finding of these 15 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites and the runtime publish gate's suites fire is at most 194 characters; before, the longest of each ran from 211 to 1,049 characters. The ids: - - flow triggers (`flows[].nodes[]` start node): `flow-time-relative-descriptor-invalid`, `flow-time-relative-descriptor-unroutable`, `flow-trigger-unroutable`, `flow-api-trigger-secret-missing`, `flow-trigger-unknown-event`; - - react pages (`pages[].source`): `react-chart-drilldown-invalid`, `react-chart-aggregate-invalid`, `react-chart-field-unprovisioned`, `react-block-needs-record-context`, `react-page-source-unparseable`; - - dataset and cube members: `measure-aggregate-field-type-refused`, `dimension-json-stored-field-refused`; - - object validation rules: `validation-rule-json-schema-uncompilable`, `validation-rule-regex-uncompilable`, `validation-rule-json-schema-unknown-format`. - - A verdict no longer repeats what the finding's `where` and `path` already name: the validation rule and its object, the JSON Pointer of a `format`, the measure or dimension name, the source of an uncompilable `regex` (the `fix:` line still quotes it). A refusal the verdict quotes is quoted to its verdict. `TimeRelativeTriggerSchema`'s refusal of a descriptor is quoted for ONE issue (an unrecognized key first) and the rest are counted, `(and N more)`; ajv's metaschema refusal is quoted to its first violation, with the rest counted; a `@objectstack/spec` unknown-key refusal is quoted to the key and the schema's rename (`Did you mean …?`), without the wrong-layer prescription and the history sentence that follow it; and when no form of an `` `groupBy` union matches, the verdict keeps the forms whose complaint is about the value's content. A measure or dimension over a column its join chain reaches names a `joined object` (it read "(reached through this dataset's join chain)"). The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. -- **`os explain ` takes these 15 ids**, for example `os explain flow-trigger-unroutable`. It prints the reasoning the verdicts no longer carry: the engine's trigger routing chain, the record-trigger token grammar and the time-relative routing predicate, what nothing reports at run time, and where the publish gate refuses the flow; the inbound api secret (ADR-0041) and why the engine refuses to register a flow without one; the write path's fail-open skip of a rule whose `regex` or `schema` does not compile, the runtime's ajv environment, and what ajv does with an unregistered `format` name; each aggregate's accepted field types (the `AGGREGATE_FIELD_TYPE_COMPATIBILITY` rows, written out), what a JSON-stored column is, and why analytics refuses to group by one; how a forwarded `` or `aggregate` refusal is quoted and which keys the schema answers with a prescription instead of a rename (`schedule` and `runAs` on a time-relative descriptor, `drilldown`, `mode`, `report`, `view` and `sort` on a drill block, five aggregate and two `groupBy` keys); what an unprovisioned anchor is; why a `record:*` block renders empty on a react page; and why an unparseable react source is a warning. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 15 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 15 entries. -- **Where the new text prints.** On the CLI, all 15 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify`, and the scaffold check `os init` runs print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP): a `flow` write carries the five flow ids (all errors, refusing an active flow's publish), a `dataset` write the two dataset ids, and an `object` write the three validation-rule ids — the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change. Each issue's `hint` is unchanged. -- **Never at the runtime gate:** the five `react-*` ids. Their rule runs at that door only for a flow write, whose snapshot carries no pages, so they speak only on the CLI doors above. diff --git a/.changeset/pre/22161-lint-slice-6-one-line.md b/.changeset/pre/22161-lint-slice-6-one-line.md deleted file mode 100644 index aa30a38f60c..00000000000 --- a/.changeset/pre/22161-lint-slice-6-one-line.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): the list-view sort and search, form-predicate path, bulk-dispatch, component-type and preset-comparand findings print one verdict line, and `os explain ` carries their reasoning - -Clause-②: no - -- **Shorter verdicts.** Each finding of these 12 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 193 characters, and the runtime publish gate's suites at most 137; before, the longest of each ran from 287 to 927 characters. The ids: - - list-view `sort` (`objects[].listViews`, `views[]` lists, list overlays and ViewItem records): `sort-field-unknown`, `sort-field-unsortable`, `sort-field-unprovisioned`; - - `searchableFields` (the object's own set, list views, and a react page's ``): `searchable-field-unknown`, `searchable-field-unsearchable`, `searchable-field-unprovisioned`; - - metadata-form `visibleWhen` predicates (`views[]` forms bound to a schema): `predicate-path-unresolved`, `predicate-path-unrooted`, `predicate-rhs-path-shaped`; - - list-view bulk wiring: `action-dispatch-contract-mismatch`; - - page component types (`pages[]`): `component-type-unknown`; - - filter comparands: `filter-preset-comparand`. - - A verdict no longer repeats what the finding's `where` already names: the view that wires an action (`action-dispatch-contract-mismatch`). The unprovisioned-anchor ids print the same one-clause cause the other converted anchor rules print (`'owner_id' is an injected column with no storage on external object 'x'`), and the two virtual-entry ids (`sort-field-unsortable`, `searchable-field-unsearchable`) state the storage fact in one shared wording. `searchable-field-unsearchable` quotes at most three names of an object's declared set, then `(and N more)`. A retired component type (`user:profile`, `element:filter`, `element:form`, `ai:chat_window`) is quoted to the head of its prescription in `RETIRED_PAGE_COMPONENT_TYPES` (`` `element:filter` was removed in @objectstack/spec 17 (ADR-0049) ``), and the verdict says the parse refuses the node by name; the whole prescription is the parse door's refusal of the same name, which `os validate` and `os build` print. `filter-preset-comparand` opens with the first sentence of the refusal the schema door shares (`"last_30_days" is a dashboard date-range PRESET name, not a filter value`), then names the operator and the preset's `{date-macro}` window. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. -- **`os explain ` takes these 12 ids**, for example `os explain sort-field-unknown`. It prints the reasoning the verdicts no longer carry: why an unknown sort field breaks a view's first fetch and every load after it, and which list-view surfaces the sort and search rules walk and skip; what a `formula` field's lack of storage does to an ORDER BY and to a search; what an unprovisioned anchor is and what sorting or searching one measured; how a stale `searchableFields` entry narrows a search or falls through to the auto-default set, and how a list view's narrowing reaches the runtime as the `$searchFields` override; how a metadata-form predicate path is resolved against the edited schema, why a dead predicate fails open, and why the right side of `==` / `!=` is a literal; how the two bulk wirings call an action and why nothing refuses a mismatch at run time; which component types the vocabulary closes and where a retired type's prescription lives; and where a date-range preset name is understood and what each layer does with a bare one. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 12 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 12 entries. -- **Where the new text prints.** On the CLI, all 12 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify`, and the scaffold check `os init` runs print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP), the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change for: the three `searchable-field-*` ids on a `view`, `object` or `flow` write; the three `sort-field-*` ids on a `view` or `flow` write; the three `predicate-*` ids on a `view` write; and `filter-preset-comparand` on a `dashboard`, `view`, `object`, `page`, `flow` or `report` write. Each issue's `hint` is unchanged. -- **Never at the runtime gate:** `component-type-unknown`, which runs on the CLI doors only, and `action-dispatch-contract-mismatch`, whose rule runs at that door only for a `flow` write, whose snapshot carries no actions to judge, so both speak only on the CLI doors above. diff --git a/.changeset/pre/22161-lint-slice-7-one-line.md b/.changeset/pre/22161-lint-slice-7-one-line.md deleted file mode 100644 index e4709e20d9a..00000000000 --- a/.changeset/pre/22161-lint-slice-7-one-line.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -fix(lint): the approval-approver, data-model, AI agent-authoring, view-reference and chart-binding findings print one verdict line, and `os explain ` carries their reasoning - -Clause-②: yes (widening: three rule id constants exported from the barrel) - -- **Shorter verdicts.** Each finding of these 15 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 197 characters, the `packages/cli` unit suite's at most 188, and `os validate` on the example apps' at most 191; before, the longest of each ran from 201 to 480 characters. The ids: - - approval nodes (`flows[]`): `approval-approvers-may-resolve-empty` (both the group arm and the `manager` arm), `approval-approver-not-membership-tier`; - - the ADR-0120 uniqueness rules (`objects[].indexes` and field-level `unique`): `unique/unscoped-declared-index`, `unique/double-declaration`, `unique/legacy-organization-composite`; - - `os lint`'s data-model sweep: `relationship/master-detail-required`, `relationship/delete-behavior`, `rollup/non-numeric-aggregand`; - - AI agents (`agents[]`, `apps[].defaultAgent`): `agent-authoring-withdrawn`, `default-agent-outside-roster`, `default-agent-legacy-alias`; - - view references: `view-ref-nav-view-missing` (app navigation), `view-key-collision` (view containers); - - chart bindings (reports, list-view charts, dataset-bound page charts): `chart-measure-unknown`, `chart-axis-not-selected`. - - The `manager` arm of `approval-approvers-may-resolve-empty` stays conditional — it says what happens where `sys_user.manager_id` is unset and asserts nothing about the column, which the rule cannot read. `view-ref-nav-view-missing` still lists the object's list views, at most three and then `(and N more)`, and `chart-axis-not-selected` quotes at most three names of the chart's selection. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. -- **Three rule id constants are published** for ids `os lint`'s data-model sweep already emitted without one: `RELATIONSHIP_MASTER_DETAIL_REQUIRED` (`relationship/master-detail-required`), `RELATIONSHIP_DELETE_BEHAVIOR` (`relationship/delete-behavior`) and `ROLLUP_NON_NUMERIC_AGGREGAND` (`rollup/non-numeric-aggregand`), exported from `@objectstack/lint` beside the `UNIQUE_*` constants. The id strings themselves are unchanged. -- **`os explain ` takes these 15 ids**, for example `os explain unique/double-declaration`. It prints the reasoning the verdicts no longer carry: why an all-group or all-`manager` approval slate can stall, what silences the `manager` arm and what does not, and what an `org_membership_level` approver resolves against; the ADR-0120 scope words, the two channels that refuse a bare `unique: true` on a declared index, how a field `unique` and an index `unique` contradict or repeat, and why a hand-written organization composite enforces nothing on NULL rows; why a `master_detail` reference must be required under `controlled_by_parent`, what deleting a master does to its details, and why a `min` / `max` roll-up needs a numeric child; the platform agent roster and its retired aliases, how the runtime enforces the agent withdrawal, and what `defaultAgent` resolves against; how a navigation `viewName` is matched and what a renamed view key does to its references; and which chart positions bind a measure on each surface and which selection each is measured against. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 15 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 15 entries. -- **Where the new text prints.** On the CLI: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify`, and the scaffold check `os init` runs print the new `message` on the text face for the approval, agent, view-reference and chart ids and for the three `unique/*` ids, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. `relationship/master-detail-required`, `relationship/delete-behavior` and `rollup/non-numeric-aggregand` print on `os lint` only (and in the metadata-generation rubric's lint half), as before. At the runtime publish gate (Studio, REST `/meta`, MCP): on a `flow` write, the two approval ids ride the 2xx `advisories` and the `[Protocol] authoring advisory` log line; on a `report` write, `chart-measure-unknown` at a report's `values` or chart `yAxis` changes the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`, and `chart-measure-unknown` at a report chart's `series[]` and `chart-axis-not-selected` ride the 2xx `advisories` and the advisory log line. Each issue's `hint` is unchanged. -- **Never at the runtime gate:** the three agent ids (the rule runs there only on a `flow` write, whose snapshot carries no agents or apps), the two view-reference ids and the three `unique/*` ids (CLI-only rules), the three data-model sweep ids (`os lint` only), and the chart ids at a list-view or page chart (a report write carries neither surface). diff --git a/.changeset/pre/22161-lint-slice-8-one-line.md b/.changeset/pre/22161-lint-slice-8-one-line.md deleted file mode 100644 index fa65fe9c28b..00000000000 --- a/.changeset/pre/22161-lint-slice-8-one-line.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): the dashboard-action, empty-filter, list-view-field and translation findings print one verdict line, and `os explain ` carries their reasoning - -Clause-②: no - -- **Shorter verdicts.** Each finding of these 9 rule ids now prints a `message` of one verdict sentence (followed by `Did you mean "…"?` where the rule offers the nearest declared name). Every finding the rules' own test suites fire is at most 199 characters, where the longest of each id ran from 226 to 553 before. The ids: - - dashboard header actions (`dashboards[].header.actions[]`): `dashboard-action-target-undefined` (the `script` and `modal` arms), `dashboard-action-route-unresolved` (one finding per unregistered `COLLECTION/NAME` segment of a `url` target); - - authored filters: `filter-empty-combinator` (`$and: []`, `$or: []`, `$not: {}`), `filter-empty-node` (`{}` as the whole filter, as a `$or` branch, as a `$and` branch); - - list-view field references: `list-view-field-unknown`, `list-view-field-dotted` (the projection door and the filter door's three refused head classes); - - translation bundles (`translations[]`): `translation-target-unknown` (every leg: objects and their fields, views, sections, tabs, validations and actions; global actions; apps and navigation; dashboards, widgets and header actions; flows, toasts, screens, screen descriptions, screen fields and refusals; action params, outcomes and result dialogs), `translation-option-key-unknown` (field, action-param and screen-field options), `translation-section-name-missing`. - - The empty-filter verdicts still take their row-set words ("matches EVERY row" / "matches NO row") from the shared filter reduction. `list-view-field-unknown` keeps the shared field-path sentence and, for a dotted reference, which written name it judged. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. -- **`os explain ` takes these 9 ids**, for example `os explain translation-target-unknown`. It prints the reasoning the verdicts no longer carry: what a `script` or `modal` header-action target must name and how a `url` route is resolved and skipped; the empty-filter identity table, what each shape does on a read scope or beside other branches, and which filters are judged; what a dangling list-view field costs at each position, why only the head of a dotted name is judged, and which positions reach which query door; how the translation resolver reads a key, why an orphan key is an error while a mis-keyed option is a warning, what every bundle leg may name, and why a section with no `name` can never be translated. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 9 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 9 entries. -- **Where the new text prints.** On the CLI: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and the scaffold check `os init` runs print the new `message` on the text face for all 9 ids, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP): on a `flow` or `report` write, `filter-empty-combinator` and `filter-empty-node` change the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`; on the write types the reference-integrity suite dispatches the list-view rule on (`view`, `object` and `flow`), `list-view-field-dotted` and the error-tier positions of `list-view-field-unknown` change the 422 issue's `message` and the refusal log line, and its warning-tier positions ride the 2xx `advisories` and the `[Protocol] authoring advisory` log line. Each issue's `hint` is unchanged. -- **Never at the runtime gate:** the two dashboard-action ids (a CLI-only rule: a single published item cannot see the actions and pages it resolves against) and the three translation ids (the rules run there only on a `flow` write, whose snapshot carries no translation bundles). diff --git a/.changeset/pre/22161-lint-slice-9-one-line.md b/.changeset/pre/22161-lint-slice-9-one-line.md deleted file mode 100644 index a759771fdc0..00000000000 --- a/.changeset/pre/22161-lint-slice-9-one-line.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): the navigation, object-reference, capability, action-name, import-mapping, view-container and interface-page findings print one verdict line, and `os explain ` carries their reasoning - -Clause-②: no - -- **Shorter verdicts.** Each finding of these 10 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 199 characters, and the runtime publish gate's suite at most 95; before, the longest of each ran from 227 to 629 characters. The ids: - - app navigation (`apps[].navigation`, `areas[]`): `nav-object-ungranted`, `nav-object-unservable`, `nav-target-unresolved`; - - object field-name lists (`highlightFields`, `publicSharing.redactFields`, `indexes[].fields` and the field-level `relatedListColumns`, `lookupColumns`, `lookupFilters`, `dependsOn`): `object-field-ref-unknown`; - - object-name references: `object-reference-unregistered-platform` (its sibling `object-reference-unknown` already printed one sentence of at most 175 characters and is unchanged); - - `requiredPermissions`: `capability-reference-unknown`; - - name-bound actions (list views, page components, navigation): `action-name-undefined`; - - import mappings (`mappings[].fieldMapping[].target`): `mapping-target-field-unknown`; - - the stack's `views:` collection: `view-container-shape`; - - interface list pages (`appearance.allowedVisualizations`): `page/visualization-without-binding`. - - A verdict no longer repeats what the finding's `where` already names: the import mapping and its object, and the interface page. `object-field-ref-unknown` prints the shared field-path sentence the list-view rules print (`highlightFields[1] "field_10" is not a field on object "proj_task".`); what a miss costs at each position is its explanation's. `action-name-undefined` names at most three places a related-list action is defined, then `(and N more)`. `nav-object-unservable` names the object's `enable` key and the status the list answers (404 or 405). The flat-list-view arm of `view-container-shape` no longer says `ViewSchema` strips the keys to an empty container — the schema is strict and refuses that shape, and so does the registration loop; it now says so. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. -- **`os explain ` takes these 10 ids**, for example `os explain nav-object-unservable`. It prints the reasoning the verdicts no longer carry: which grants count toward a navigation entry's read access, why an object's `enable` block makes an entry dead for every user and what the served `/meta` does with it, and why a grant and a list are two independent findings; which `defineStack` check `nav-target-unresolved` stands in for; which object each field-name list is judged against and what a miss costs at each position; the object-name resolution ladder and the platform object registry; where a capability may come from; which surfaces bind an action by name and how a related list resolves and places one; what an import mapping target may name, compound-field parts included; the container-only contract of `views:` and the doors that hold it; and the renderer's binding derivation for each interface-page visualization. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 10 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 10 entries. -- **Where the new text prints.** On the CLI, all 10 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify`, and the scaffold check `os init` runs print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP): an `object` write's `object-field-ref-unknown` changes the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`, and a `dataset` write's `object-reference-unregistered-platform` (its base `object`) changes the 2xx `advisories` entry and the `[Protocol] authoring advisory` log line. Each issue's `hint` is unchanged. -- **Never at the runtime gate:** `capability-reference-unknown` and `view-container-shape` (CLI-only rules), and the three navigation ids, `action-name-undefined`, `mapping-target-field-unknown` and `page/visualization-without-binding`, whose rules run at that door only for a `flow` write, whose snapshot carries no apps, pages, views or mappings of its own. diff --git a/.changeset/pre/22161-rule-message-one-line.md b/.changeset/pre/22161-rule-message-one-line.md deleted file mode 100644 index 0e9fbcf991e..00000000000 --- a/.changeset/pre/22161-rule-message-one-line.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/cli": minor ---- - -feat(lint, cli): one-line author-time rule verdicts, and `os explain ` for the reasoning - -Clause-②: yes (widening) - -- **Shorter warnings.** `field-no-consumers` and `security-owd-unset` now print one verdict sentence and one fix. `os validate`, `os build` and `os dev` used to print `field-no-consumers` as a single line of about 860 characters, and `os build` added a second paragraph of about 700; the warning now reads: - - ```text - ⚠ object "my_app_ticket" · field "description": declared, but nothing in this stack displays or reads it (inert) - fix: add it to a view column or a form section, or remove the declaration - rule: field-no-consumers at objects[1].fields.description — `os explain field-no-consumers` for what counts as a consumer - ``` - - The finding's `message` no longer restates its `where`, and no longer carries the list of consumer and carrier kinds, the exemptions or the scanned roots; `hint` is the fix alone (for a `carrier-only` verdict it still names each carrier site a removal must clean). The `verdict`, `carriers` and `rootsScanned` fields of a `field-no-consumers` finding are unchanged. A tool that matched the old message text should match on `rule`, `where` and `path` instead. - - `security-owd-unset` also changes on the runtime wire. It runs at the metadata write door's publish gate for `object` writes (Studio, REST `/meta`, MCP), and an active write of a custom object (neither `isSystem: true` nor `sys_`-named) with no `sharingModel` is refused with a 422 whose `security-owd-unset` issue now carries the message `custom object declares no sharingModel (OWD); the runtime falls back to 'private', but the baseline must be an authored decision` and the hint `declare sharingModel: 'private' (owner + shares; recommended), 'public_read', 'public_read_write', or 'controlled_by_parent' (master-detail children)`. The old message named the object, which the issue's `where` and `path` still carry, and told the leave_request incident, which `os explain security-owd-unset` now prints. -- **`os explain `.** `os explain` takes an author-time rule id as well as a schema name — `os explain field-no-consumers`, `os explain security-owd-unset` — and prints the reasoning the warning no longer carries; `--json` prints `{ rule, covers, paragraphs }`. A schema name resolves exactly as before. With no argument it also lists the rule ids that have an explanation (`--json` adds `rules: [{ id, covers }]`). The `rule:` line names the command only for those rule ids. An argument that is neither still exits 1, and its error string changes from `Unknown schema: "X"` to `Unknown schema or rule id: "X"`, followed by a second list, `Rules with an explanation: …`; the `--json` `error` field changes the same way, from `Unknown schema: X` to `Unknown schema or rule id: X`. -- **`os explain constructor` (or `__proto__`) is refused as an unknown id** instead of printing `Schema: Object … undefined` and crashing with `schema.required is not iterable`: both lookups read own keys only. -- **`os validate` prints the `fix:` and `rule:` lines** under each author-time warning, the way `os build` does, and the hint line under every author-time finding (`os build`, `os validate`, `os verify`, `os init`) is now labelled `fix:`. `os lint` adds the same `os explain` pointer to its rule line. -- **The `fix:` line is always a fix.** `expression-invalid` no longer puts the authored source in `hint`, where it printed as `fix: source: …`: the source now ends the finding's `message` as `` — source: `…` `` (the spelling the flow engine's runtime refusals use), so the CLI verdict line still carries it, and so does the issue `message` at the runtime publish gate for `flow`, `action`, `hook` and `object` writes (Studio, REST `/meta`, MCP): in the 422 for an `error`, in the 2xx `advisories` for a `warning`. Its `hint` is empty, so no `fix:` line prints and the runtime issue's `hint` is `''`. `component-props-invalid`, `flow-time-relative-descriptor-invalid`, `react-prop-missing-required` (where the component contract describes the binding) and `liveness-experimental-property` carried context in `hint`; each now leads with the instruction, and `component-props-invalid`'s message states its consequence (nothing refuses it today, so the renderer receives the props as written). Two of the four run at the runtime publish gate. `flow-time-relative-descriptor-invalid` is an `error` on `flow` writes, so its new hint reaches the 422 issue `hint`. `liveness-experimental-property` runs on `email_template`, `mapping` and `datasource` writes as a `warning`, so its hint would ride the 2xx `advisories`, but none of those three ledgers has an `experimental` row today, so it reaches no runtime response yet. `component-props-invalid` is CLI-only, and `react-prop-missing-required` judges no `page` write at the gate, so their new text reaches the CLI only. -- **New exports in `@objectstack/lint`:** `RULE_EXPLANATIONS`, `explainRule(ruleId)` and the type `RuleExplanation`, from the root entry and from the import-free `@objectstack/lint/rule-explanations` entry. diff --git a/.changeset/pre/22161-s17-widget-binding-fix-lines.md b/.changeset/pre/22161-s17-widget-binding-fix-lines.md deleted file mode 100644 index ac84041b500..00000000000 --- a/.changeset/pre/22161-s17-widget-binding-fix-lines.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -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. diff --git a/.changeset/pre/22161-s18-data-model-fix-lines.md b/.changeset/pre/22161-s18-data-model-fix-lines.md deleted file mode 100644 index 8ad20981170..00000000000 --- a/.changeset/pre/22161-s18-data-model-fix-lines.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): the six `data-model-rules.ts` 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 six rule ids now carries a `fix` of one remedy sentence of at most 200 characters on every variant measured, including long object and column names. The three ADR-0120 uniqueness ids carry it as the `hint` that `os validate` and `os build` print after `fix: `; all six print it as `os lint --fix`'s `→ fix:` line. The longest of each, FROM → TO (characters of the fix text alone): - - `unique/legacy-organization-composite` 592 → 161; - - `unique/unscoped-declared-index` 380 → 150; - - `object/missing-name-field` 327 → 188; - - `unique/double-declaration` 303 → 159 (the contradiction arm; the two redundancy arms were already short and now drop one word); - - `rollup/non-numeric-aggregand` 300 → 165 (the line no longer names the parent object, so its length no longer grows with that name); - - `field/select-missing-options` 207 → 180. -- **What moved.** The text the fix lines stopped saying is in each id's `RULE_EXPLANATIONS` entry, which `os explain RULE_ID` prints: - - `unique/unscoped-declared-index`: what `unique: 'organization'` makes the driver build, and the `os migrate meta --from 17` route for existing sources (one new paragraph); - - `unique/legacy-organization-composite`: that the respelling makes the listed organization column NULL-safe in place, what the constraint then means, and that duplicate NULL-organization rows block the `recreate_index` with a report rather than failing a boot (one paragraph extended, one new); - - `unique/double-declaration`: where each scope word may be written (one new paragraph). - - `object/missing-name-field` now points at `os explain title-format-retired`, whose entry already says why a `titleFormat` template does not count. `field/select-missing-options` and `rollup/non-numeric-aggregand` lost only wording; the rollup entry already carries the reasoning. -- **Runtime publish gate:** none of the six runs there. The three uniqueness entries are `cli`-only registry rules, and the other three run only in `os lint`'s `lintDataModel` sweep. So no 422 `issues[].hint` and no 2xx `advisories[].hint` changes. -- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. diff --git a/.changeset/pre/22161-s19-five-file-fix-lines.md b/.changeset/pre/22161-s19-five-file-fix-lines.md deleted file mode 100644 index 3498b131454..00000000000 --- a/.changeset/pre/22161-s19-five-file-fix-lines.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): seven more 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 7 rule ids now carries a `hint` (the CLI's `fix:` line, the runtime advisory's `hint`) of one remedy sentence of at most 200 characters on every variant the lint suite fires (lengths are the `hint` alone; the CLI prints it after `fix: `). The longest of each, before → after: - - flow reference syntax (`lint-flow-patterns.ts`): `flow-double-brace-interpolation` (1413 → 197) and `flow-bare-dollar-reference` (1234 → 198); - - `liveness-ledger-unreadable` (270 → 82), `capability-reference-unknown` (241 → 131), `page-section-count-not-multi-value` (228 → 167), `list-view-filters-in-views-mode` (219 → 135) and `page-field-unknown` (206 → 94). -- **The flow ids keep the judge's spelling, and only where it evaluates.** In a value slot the line names the spec value-slot judge's own CEL envelope for the reference, read out of the judge's remedy and never re-spelled: FROM `A value slot reads a CEL value envelope, … so a bare $ reference is stored as written. $source.id names $source, which is not one of the flow engine's own $ variables, … Write {source.id} as { dialect: 'cel', source: 'source.id' }. CEL refuses an absent variable or key … (the guarded form writes null).` (681) TO ``A value slot stores a bare `$` reference as written: write `{source.id}` as { dialect: 'cel', source: 'source.id' }.`` (116). Where an envelope at the string's place would not evaluate (a string inside an object or list literal, a legacy `assignments: [{ variable, value }]` shape, a `$User` path that never resolved), the line names no envelope and points at `os explain`. It never names the build's own refusal for a `{{ }}` string, whose spelling keeps the outer braces as literal text. In a text slot the line names the `{{ }}` hole for each reference the engine binds, and for any other `$` root says to compute the value into a variable with an `assignment` node and write `{{ v }}`. At a notify or http literal it says neither brace dialect resolves there: write the value itself. -- **Lists are no longer echoed.** `page-field-unknown` no longer appends the object's whole field list; it names the nearest declared field ("Did you mean") instead. -- **`os explain RULE_ID` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`: the `has()` guard for a value that may be absent, the run user's `current_user` null guard and the `sys_user` read for any other `$User` path, the whole-value literal and the canonical `assignments` map, what a filter operand's `null` does, which slots read `{{ }}`, and where a brace token in a literal is refused; that a capability another installed package declares needs no change; why page filter controls do nothing on an object list view (ADR-0047); that a missing liveness ledger is a platform packaging fault; which field types take `multiple: true`. Six ids gain an entry, so their `rule:` line now carries the `os explain` pointer: `flow-double-brace-interpolation`, `flow-bare-dollar-reference`, `liveness-ledger-unreadable`, `page-field-unknown`, `page-section-count-not-multi-value` and `list-view-filters-in-views-mode`. `capability-reference-unknown`'s entry gains one sentence. -- **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), the two flow ids fire on a `flow` write as 2xx `advisories[].hint`, FROM → TO measured at the door: a value-slot `$source.id` 681 → 116, `$User.Id` 681 → 121, `$User.Email` 742 → 198, a nested literal 1231 → 198, a legacy assignment 1030 → 198; a text-slot `$User.Id` 767 → 144 and `$error.message` 114 → 83; a value-slot `{{ }}` 746 → 116, a notify literal 297 → 137, a value no slot reads 362 → 158. Studio's save-advisory toast prints an advisory's `hint` after its message. `capability-reference-unknown` and `list-view-filters-in-views-mode` run at the CLI only; the two page ids do not fire on a `page` write; `liveness-ledger-unreadable` fires only on an install whose `@objectstack/spec` lost a ledger file. -- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. diff --git a/.changeset/pre/22161-s20-flow-patterns-fix-lines.md b/.changeset/pre/22161-s20-flow-patterns-fix-lines.md deleted file mode 100644 index d98188024b4..00000000000 --- a/.changeset/pre/22161-s20-flow-patterns-fix-lines.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): the twelve flow anti-pattern 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 12 rule ids in `lint-flow-patterns.ts` now carries a `hint` (the CLI's `fix:` line, the runtime finding's `hint`) of one remedy sentence of at most 200 characters on every variant the lint suite fires (lengths are the `hint` alone; the CLI prints it after `fix: `). The longest of each, before → after: `flow-loop-body-uncontained` (800 → 174), `flow-multi-write-unfiltered` (695 → 73), `flow-try-catch-without-catch` (594 → 137), `flow-decision-inclusive-overlap` (437 → 159), `flow-approval-revise-target-not-service-owned` (412 → 76), `flow-time-relative-antipattern` (397 → 163), `flow-date-equality-filter` (379 → 123), `flow-runas-unscoped` (372 → 105), `flow-decision-mode-invalid` (295 → 134), `flow-decision-unconditional-branch` (286 → 149; its other arm, 142, is unchanged), `flow-error-label-not-fault` (239 → 81) and `flow-branch-label-unmatched` (224 → 122). -- **Examples.** `flow-loop-body-uncontained` FROM ``Contain the failure per iteration: put a `try_catch` INSIDE the body and move this node into its `try` region — … A `catch` region cannot be empty … Measured: with it, all 5 items are processed … See content/docs/automation/flows.mdx §"Per-iteration containment".`` (800) TO ``Move this node into the `try` region of a `type: 'try_catch'` node inside the loop body, with `catch: { nodes: [ { id: 'handled', type: 'assignment', label: 'Handled' } ] }`.`` (174). `flow-multi-write-unfiltered` FROM ``Write the constraint you mean into `filter` (e.g. `{ status: 'closed' }` — see examples/app-showcase `showcase_inquiry_purge`, …). If emptying 'lead' really is the intent, keep it: … Distinct from the run-time erased-condition guard, …`` (684–695) TO ``Write the constraint you mean into `filter`, e.g. `{ status: 'closed' }`.`` (73). `flow-runas-unscoped` FROM ``Declare `runAs:'system'` to make the elevation explicit and intended (the run reads/writes every record). A schedule flow cannot scope to a user — … (ADR-0049, ADR-0073 D5)`` (362–372) TO ``Declare `runAs:'system'` to make the elevation explicit and intended (the run reads/writes every record).`` (105). `flow-time-relative-antipattern` used to spell its range query as ``BETWEEN {TODAY()} and {TODAY()+N}``, in the single-brace dialect flows no longer resolve; its line now names the declared `timeRelative` sweep, and its entry spells the range with `$gte` / `$lt`. -- **`os explain RULE_ID` carries the rest.** Each of the 12 ids gains a `RULE_EXPLANATIONS` entry in `@objectstack/lint`, so its `rule:` line now carries the `os explain` pointer. What the fix lines stopped saying is there: which node types count as able to fail and how containment is judged; the two empty `catch` spellings the parse refuses, what the minimal handler may omit, and the measured result with it; when failing fast or retry-then-fail is a legitimate reading; the worked `showcase_inquiry_purge` example, keeping a meant whole-object write, and how the rule differs from the run-time erased-condition guard; when to keep `mode: 'inclusive'` and where `os migrate meta` left it on a flow authored against protocol major 17; what ends the revise window and why ADR-0044's amendment moved it off `wait`; the declared `timeRelative` sweep and the hand-written range-query alternative; why abutting one-day windows match each row once; why a user-less run is refused rather than run unscoped, and the anonymous-endpoint exception (ADR-0138 D2b); that `registerFlow` refuses an invalid `mode` with the same sentence; dropping `type: 'decision'` for a meant fan-out; which failures a `fault` edge routes and which labels read as an error path; and one branching mechanism per decision. -- **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), all 12 ids fire on a `flow` write, FROM → TO measured at the door. As 422 `issues[].hint`: `flow-approval-revise-target-not-service-owned` 412 → 76, `flow-runas-unscoped` 367 → 105 (schedule), 372 → 105 (time-relative) and 362 → 105 (api), `flow-decision-mode-invalid` 295 → 134, `flow-branch-label-unmatched` 224 → 122. As 2xx `advisories[].hint`: `flow-loop-body-uncontained` 800 → 174, `flow-multi-write-unfiltered` 695 → 73 and 684 → 73, `flow-try-catch-without-catch` 594 → 137, `flow-decision-inclusive-overlap` 437 → 159, `flow-time-relative-antipattern` 397 → 163, `flow-date-equality-filter` 379 → 123, `flow-decision-unconditional-branch` 286 → 149, `flow-error-label-not-fault` 239 → 81. No write changes channel. -- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. diff --git a/.changeset/pre/22161-s21-validate-fix-lines.md b/.changeset/pre/22161-s21-validate-fix-lines.md deleted file mode 100644 index fe23327cb7d..00000000000 --- a/.changeset/pre/22161-s21-validate-fix-lines.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): the last eight fenced author-time 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 8 rule ids now carries a `hint` (the CLI's `fix:` line, the runtime finding's `hint`) of one remedy sentence of at most 200 characters on every variant the lint suite fires (lengths are the `hint` alone; the CLI prints it after `fix: `). The longest of each, before → after: - - flow template holes (`validate-flow-template-paths.ts`): `flow-template-field-unprovisioned` (362 → 175) and `flow-template-lookup-traversal` (316 → 139); - - page bindings (`validate-page-field-bindings.ts`): `page-field-unprovisioned` (362 → 175) and `page-section-group-unknown` (279 → 151); - - `print-page-block-unprintable` (362 → 170), `form-section-group-unknown` (275 → 147), `filter-token-unknown` (259 → 170) and `component-props-invalid` (258 → 188; its value-rejection line 248–258 → 157–167, while its retired-key line, 164–181, and its node-binding line, 188, were one remedy already and are unchanged). -- **Examples.** `print-page-block-unprintable` FROM ``Replace or remove the block. The printable blocks are `page:section`, `page:card`, … `object-metric`. If the page is not a document, delete its `print` declaration instead.`` (362) TO ``Replace the block with a printable one (`os explain print-page-block-unprintable` lists them) or remove it; if the page is not a document, delete its `print` declaration.`` (170). `flow-template-lookup-traversal` on a flow variable FROM ``A record read into a flow variable is not expanded either — config.expand is the START node's opt-in and covers the trigger record alone. Reference the foreign-key id directly ('{{ currentCase.owner_id }}'), project the value via a formula field on 'crm_case', or add a get_record node that reads the related record.`` (316) TO ``Write '{{ currentCase.owner_id }}' (the id) or a formula field on 'crm_case', or read the related record with a get_record node.`` (128). -- **Lists are no longer echoed.** The two section-group ids no longer append the object's declared field groups: the line names the nearest declared group ("Did you mean") instead. An object with no field groups gets one line: declare `fieldGroups: [{ key, label }]` and `group` on the member fields, or enumerate the section's `fields`. `print-page-block-unprintable` no longer lists the thirteen printable blocks; `os explain` does. `filter-token-unknown`'s line for a placeholder with no near miss names the two vocabularies briefly, and its `os explain` entry in full. -- **The anchor ids converge.** `page-field-unprovisioned` and `flow-template-field-unprovisioned` now print the shared one-line anchor fix the other unprovisioned-anchor ids print (`If the remote table carries 'owner_id', declare it in ext_customer's own fields (mapped through columnMap); otherwise drop the reference or opt the object out of the injection`, 175). -- **`os explain RULE_ID` carries the rest.** Each of the 8 ids gains a `RULE_EXPLANATIONS` entry in `@objectstack/lint`, so its `rule:` line now carries the `os explain` pointer. What the fix lines stopped saying is there: how to vouch for an unprovisioned anchor through `columnMap` or opt the object out of the injection (`ownership: 'none'`, `systemFields: { audit: false }`); what the start node's `config.expand` does (a one-hop re-read of the triggering record as the run's identity) and why it covers the trigger record alone; how a section inherits a field group's members, and that membership is declared on the field; the printable block list, held equal to the spec's `PRINTABLE_PAGE_COMPONENT_TYPES`, and the three reasons a block is refused; the context tokens and date macros a filter value resolves, where `{record_id}` resolves, and that a literal shaped like a placeholder must be renamed; and where a page component's props schema lives, why the page and not the schema is what to fix in almost every case, and the retired-key and node-binding cases. -- **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), FROM → TO measured at the door. On a `page` write, as 422 `issues[].hint`: `print-page-block-unprintable` 362 → 170, `component-props-invalid`'s value-rejection line 248 → 157 (`page:tabs`), 250 → 159 (`object-grid`), 253 → 162 (`record:details`) and 258 → 167 (`record:related_list`). On a `flow` write, as 2xx `advisories[].hint`: `flow-template-field-unprovisioned` 362 → 175, `flow-template-lookup-traversal` 249 → 131 (trigger root) and 314 → 126 (flow variable), on a probe hopping through `crm_case.account`. The two page-binding ids do not fire on a `page` write, and `filter-token-unknown` and `form-section-group-unknown` run at the CLI only. No write changes channel. -- **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. diff --git a/.changeset/pre/22161-spec-approver-type-describe.md b/.changeset/pre/22161-spec-approver-type-describe.md deleted file mode 100644 index 8cad97d7446..00000000000 --- a/.changeset/pre/22161-spec-approver-type-describe.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): `ApproverType`'s description points at `os explain approval-approvers-may-resolve-empty` for the graded `manager` routes - -Clause-②: no - -- **What changes:** the `.describe()` text of `ApproverType` (`@objectstack/spec/automation`), which the generated reference page renders for every approver entry (`content/docs/references/automation/approval.mdx`). It said `os lint` "carries the graded population routes and the full remedy". Since `@objectstack/lint` shortened `approval-approvers-may-resolve-empty`'s fix line to one sentence, the finding names two routes in one line, and `os explain approval-approvers-may-resolve-empty` prints the graded routes and the full remedy. The description now says so. Its "the request waits" clause now says the request falls to the node's `onEmptyApprovers` policy; under the default policy that opens the request on the organization's administrators, so it no longer waits. -- **Unchanged:** the `ApproverType` values, the schema's shape, and what it accepts or refuses. Only the description text moves. diff --git a/.changeset/pre/22162-scaffold-workspace-minimal.md b/.changeset/pre/22162-scaffold-workspace-minimal.md deleted file mode 100644 index 9c16fae3119..00000000000 --- a/.changeset/pre/22162-scaffold-workspace-minimal.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"create-objectstack": patch -"@objectstack/cli": patch ---- - -A new project's `pnpm-workspace.yaml` is now a short settings file: one comment line above each block instead of 57 to 66 lines of version history and measurements. `npm create objectstack`, `objectstack init` and a standalone `objectstack create` now write the same 21-line file. - -Clause-②: no - -- The settings are unchanged: `packages: []`, the same `onlyBuiltDependencies` and `allowBuilds` lists, and the same `peerDependencyRules.allowedVersions` entries, in the same order. A project installs exactly as before. -- Each block keeps one line that says why it is there: which pnpm versions read each build-approval key, and that the peer rules only silence a warning that was measured harmless. -- The measurements behind each setting now live in the `@objectstack/cli` source next to the values they explain, so they are kept up to date there instead of in every project. -- Already-scaffolded projects are not touched. Their longer comments can be deleted or kept; the settings are identical. diff --git a/.changeset/pre/22163-doctor-node-env-source-posture.md b/.changeset/pre/22163-doctor-node-env-source-posture.md deleted file mode 100644 index fb12844be36..00000000000 --- a/.changeset/pre/22163-doctor-node-env-source-posture.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os doctor` no longer tells a freshly scaffolded project that it is running in production (#22163) - -Clause-②: no - -- **What changed.** With `NODE_ENV` unset, in a directory whose `objectstack.config.ts` boots as itself, the `NODE_ENV` row now reads `✓ Not set — os dev runs this project as development; os start forces production`, with no fix text. That covers a config alone, and a config beside its own compiled `dist/objectstack.json`, which the first `os dev` writes. Before, the row read `⚠ Not set — this environment is being treated as production` and, under `--verbose`, told the operator to set `NODE_ENV=development`, which `os dev` already sets. -- **What did not change.** In every other directory, the `⚠ … treated as production` warning and its fix text are the same as before. That includes a config beside `OS_ARTIFACT_URL` or beside an `OS_ARTIFACT_PATH` naming another artifact, an artifact with no config, and a directory with neither. `doctor` reads the two variables the way `os dev` does: from the process environment over the `.env*` files loaded for development. -- **Nothing else moves.** The `Environment files` row still reports the `.env*` cascade for `node_env=production`. The `/discovery` `environment` default is unchanged, and so is the way `os serve`, `os start` and `os dev` resolve the mode. A `NODE_ENV` that is set still prints no row, and `doctor`'s exit code is unchanged. diff --git a/.changeset/pre/22168-settings-user-rung-owner.md b/.changeset/pre/22168-settings-user-rung-owner.md deleted file mode 100644 index 65a134db6bb..00000000000 --- a/.changeset/pre/22168-settings-user-rung-owner.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/service-settings': minor ---- - -fix(service-settings)!: the user rung answers only its owner, and a write of a user-scoped key must name its user - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -A settings key declared `scope: 'user'` belongs to one user. `SettingsService` now holds that on both its read and its write side. - -- **Reads.** The user rung answers only a caller whose `SettingsContext.userId` is present and equals the row's owner. A resolve whose context names no user (no `userId`, or an empty one) has no user rung: it answers from the tenant rung, then global, then the manifest default. This holds on every read path that reaches the cascade: `get`, `getMany`, `getNamespace`, `createClient`, `runAction` and `resetNamespace`. -- **Refused now.** `set` and `setMany` refuse a user-scoped key when the context names no user, a `null` reset of one included. The refusal is a `SettingsValidationError` (`code: 'SETTINGS_VALIDATION'`) with one `fields` entry per such key (`code: 'invalid_value'`, `constraint: { scope: 'user' }`). It refuses the whole batch, before anything is written. -- **Unchanged.** A caller with a user id reads and writes only their own rows, as before. Keys declared at `scope: 'tenant'` or `'global'` resolve and write the same with or without a user id. The settings HTTP routes resolve a signed-in user before they read or write any row, so they behave as before. - -What changes for you: when your code calls the settings service for a user-scoped key, pass the user id of the person the value belongs to. A value meant for everyone belongs on a key declared at `scope: 'tenant'` or `'global'`. None of the settings namespaces this repository ships declares a user-scoped key. diff --git a/.changeset/pre/22169-permission-set-heal-converges.md b/.changeset/pre/22169-permission-set-heal-converges.md deleted file mode 100644 index 4b8ab51073e..00000000000 --- a/.changeset/pre/22169-permission-set-heal-converges.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security): the boot heal of drifted `sys_permission_set` rows converges on duplicated names, stops paying a discarded metadata read per row, and a refused existence read no longer inserts a duplicate (#22169) - -Clause-②: no - -**The heal writes the row it read, by its id.** `reconcilePermissionSetProjection` heals every overlay-less, env-authored record that drifted from its metadata definition. It used to hand each drifted record to a by-name upsert, which re-read the first record of that name (lowest id). On a table holding more than one record per name, only that one was ever written: every other record was logged "re-projected" and left drifted, on every boot. Now each drifted record is updated by its own id, facets only (label, description and the permission columns; identity, `active` and provenance are never touched). The drift line is logged after the write and names the record's id; a refused write is logged as refused instead of as re-projected. On a real engine with 8 names × 3 records, the boot after the healing one went from 116 statements and 16 drift warnings to 4 statements and none. - -**The layered metadata read is lazy, once per name.** The heal awaited `getMetaItemLayered` (four statements on the protocol) for every candidate record before deciding whether it needed it, and it does not need it while a `SchemaRegistry` is readable. It is now read only when no registry answers, and once per name per pass. - -**A refused existence read declines the insert.** `bootstrapPlatformAdmin` reads each default permission set by name and inserts when it finds none. A refused read answered as "none", so on a table without the per-organization unique index the boot inserted a second record of a set that already existed. A refused read now inserts nothing, and the pass says so once, naming the sets. When `admin_full_access` is one of them, the promotion half reports `admin_permission_set_unreadable` instead of `admin_permission_set_missing`. - -**Unchanged.** Existing duplicate records are healed, never deleted: removing them is an operator repair. The live single-mutation projection keeps its per-item read. diff --git a/.changeset/pre/22175-upload-org-change-4xx.md b/.changeset/pre/22175-upload-org-change-4xx.md deleted file mode 100644 index 0303c0a3783..00000000000 --- a/.changeset/pre/22175-upload-org-change-4xx.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/service-storage': patch ---- - -fix(service-storage): finishing an upload after switching active organization answers `409 RESOURCE_CONFLICT` naming the change, not a `500` that reads as a data-engine outage - -Clause-②: no - -`POST /api/v1/storage/upload/complete` and `POST /api/v1/storage/upload/chunked/:uploadId/complete` write by id under the caller's active organization, and that write is scoped to it. When the uploader's active organization is no longer the one the upload was started in, the write cannot reach the upload's row. Both doors used to answer that with `500 INTERNAL` and a message diagnosing a data-engine outage ("Restore the data engine …"). - -- **Now:** before writing anything, each door answers `409 RESOURCE_CONFLICT` in the standard error envelope. The message says the upload was started in a different organization than the active one, and that switching back finishes it. The server log carries one warning naming the upload, the organization it was started in and the active one. -- **Unchanged:** which organization an upload belongs to (switching back finishes it there); a commit or completion from the starting organization; a real data-engine failure, which still answers `500` with its consequence and fix; the uploader-only rule, which runs first, so a caller who did not start the upload is still refused `403 PERMISSION_DENIED` and learns nothing about organizations; uploads stamped with no organization; routes with no session resolver; a store with no data engine wired; and the progress door. - -What changes for you: when either door answers `409 RESOURCE_CONFLICT`, switch the active organization back to the one the upload was started in and retry the same call. diff --git a/.changeset/pre/22178-analytics-picklist-dimension-labels.md b/.changeset/pre/22178-analytics-picklist-dimension-labels.md deleted file mode 100644 index 5d5340a38d4..00000000000 --- a/.changeset/pre/22178-analytics-picklist-dimension-labels.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -fix(service-analytics): a picklist-bound select dimension now renders its category labels in the request's locale - -Clause-②: no - -A select field that references a shared list (`picklist: ''`) is served with the list's options and with `picklist` kept, and its option labels translate under `picklists..options.`. The analytics label pass translated a dimension's options through a synthetic field that carried `options` only, so the translator never looked at the `picklists` address. Every chart and report category on such a dimension showed the authored (usually English) labels under every `Accept-Language`, while `GET /api/v1/meta/object/:name` showed the same field translated. - -The synthetic field now carries the dimension field's `picklist`. `POST /api/v1/analytics/dataset/query` answers the same labels the object-metadata endpoint serves for that field: a field-level entry (`objects..fields..options.`) wins when one exists, and otherwise the list's label applies. A dimension with inline `options` renders exactly as before. - -An `order` on a select dimension now sorts by the label the row renders in the request's locale, and a `limit` keeps the first rows in that order. The sort used the authored labels, so under `Accept-Language: zh-CN` or `ja-JP` the order followed the English text, for inline `options` and, once translated, for picklist-bound dimensions too. With no locale, or no translation for it, the order is unchanged. - -Nothing you author changes: no key, export or parameter is added or removed. diff --git a/.changeset/pre/22182-approval-approve-description.md b/.changeset/pre/22182-approval-approve-description.md deleted file mode 100644 index 11eac3fd1fe..00000000000 --- a/.changeset/pre/22182-approval-approve-description.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -fix(plugin-approvals): Approve asks its decision question in the dialog, as Reject does - -Clause-②: no - -`approval_approve` on `sys_approval_request` declares a `description`: "Approve this request? Your approval is recorded, and the request moves on once this step has the approvals it requires." The console shows it as the parameter dialog's subtitle, where a generic "Please provide the required information to continue." showed before; `approval_reject` beside it already declared its question this way. The zh-CN, ja-JP and es-ES bundles carry a translation of it. diff --git a/.changeset/pre/22182-invite-user-action-copy.md b/.changeset/pre/22182-invite-user-action-copy.md deleted file mode 100644 index 60b3e7472b4..00000000000 --- a/.changeset/pre/22182-invite-user-action-copy.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -fix(platform-objects): Invite User explains itself in its dialog and names the invitee in its success message - -Clause-②: no - -- **The parameter dialog.** `invite_user` on `sys_user`, `sys_invitation` and `sys_member` declares a `description`: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation." The console shows it as the dialog's subtitle, where a generic "Please provide the required information to continue." showed before. -- **The success toast.** `successMessage` is now `Invitation sent to ${result.email}`. `POST /api/v1/auth/organization/invite-member` answers the invitation row, and its top-level `email` is the invitee's address as stored (lowercased), so the toast reads "Invitation sent to ada@example.com" where it read "Invitation sent". -- **Translations.** The zh-CN, ja-JP and es-ES bundles carry the new description and a success message that keeps the `${result.email}` token, so every locale names the invitee. diff --git a/.changeset/pre/22183-import-kept-option-values.md b/.changeset/pre/22183-import-kept-option-values.md deleted file mode 100644 index 4f85d7cab63..00000000000 --- a/.changeset/pre/22183-import-kept-option-values.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": minor -"@objectstack/objectql": minor ---- - -fix(import): `createMissingOptions` now keeps an unmatched option value through the engine's option check - -Clause-②: yes (widening) - -- **What was wrong.** With `createMissingOptions: true` on an import (objectui's Import Wizard sends it for *Keep unknown option values*), the import coercion kept a `select` / `radio` / `multiselect` cell that matched none of the field's options. The engine's write-path option check then refused the same value ("Priority must be one of: high, low"), so on every writable field the row still failed. Only the stage that refused it changed. -- **What happens now.** The import stores the kept value: a single-value cell trimmed, and each unmatched item of a multi-value cell. The dry run admits the same rows the write does. The engine admits exactly the values the import kept, on that import's writes. Every other value outside the options is still refused, and a field bound to a shared picklist that did not resolve is still refused. -- **The field's options do not change.** No option is added to the field. A later write that sends the field is judged against the options again, so sending the kept value back is refused and picking an option is accepted. A write that leaves the field out, such as an edit form saving only the fields it changed, is not judged on that field. -- **`@objectstack/spec`.** `ExecutionContext` declares `keptOptionValues`: per field name, the option values an import kept. It is server-constructed only, like `skipStateMachine` and `preserveAudit`, and is never client-supplied. The `ImportRequest.createMissingOptions` description now states exactly the behaviour above. -- **`@objectstack/core`.** `coerceRow` returns `keptOptionValues` beside `data` and `errors`, and `coerceFieldValue` reports the values it kept. `runImport` puts exactly those values on each write's context. A per-row write carries its own row's values, and a batched create carries the union of its rows' values. A row that kept nothing is written with the import's context unchanged. -- **`@objectstack/objectql`.** `ValidateRecordOptions.keptOptionValues` lets the single-value and multi-value option arms admit a listed value. The engine fills it from the write's `ExecutionContext.keptOptionValues` on insert, update and `validate`. diff --git a/.changeset/pre/22188-rest-meta-package-all-fold.md b/.changeset/pre/22188-rest-meta-package-all-fold.md deleted file mode 100644 index 87bc23a20d6..00000000000 --- a/.changeset/pre/22188-rest-meta-package-all-fold.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -`metaItemPackageBinding` is exported, and every `/meta` door that reads `?package=` reads it through that function, so `?package=all` names no package on the layered read, the list, the book tree and the diagnostics sweep - -Clause-②: yes (widening) - -- `metaItemPackageBinding(raw)` answers the package a request's `?package=` names, or `undefined` for `all` (the metadata list's "show everything" scope), the empty value or a non-string. The item read, the save and the publish already read `?package=` through it. The runtime dispatcher's `/meta` domain now reads it through the same function. -- `GET /meta/:type/:name/layers?package=all` answered `404` for an item stored in a package, and now serves the layers that the read without `?package=` serves. The Studio editor sends this read when it is opened from the list's "show everything" scope. -- `GET /meta/:type?package=all` answered `[]`, and now lists what `GET /meta/:type` lists: the items of every package and the env-local ones. -- `GET /meta/book/:name/tree?package=all` served the empty implicit book of a package called `all`, and now resolves the declared book as the read without `?package=` does. -- `GET /meta/diagnostics?package=all` swept nothing, and now sweeps what the plain sweep sweeps. -- Unchanged: a real package id still scopes each of these reads. The item read's cache bypass still reads the raw parameter, so `?package=all` keeps the uncached read and its served `version`. diff --git a/.changeset/pre/22188-runtime-meta-package-all-fold.md b/.changeset/pre/22188-runtime-meta-package-all-fold.md deleted file mode 100644 index e9b58aa2bf4..00000000000 --- a/.changeset/pre/22188-runtime-meta-package-all-fold.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -The runtime dispatcher's `/meta` domain reads `?package=all` as naming no package, as `RestServer` does, so a `PUT ?package=all` through the `@objectstack/hono` catch-all no longer binds the row to a package called `all` - -Clause-②: no - -- The dispatcher's `PUT /meta/:type/:name?package=all` stored the row with `package_id: 'all'`. It now stores it env-local (`package_id` null), as `RestServer`'s `PUT` does. -- Its layered read (`/meta/:type/:name/layers`), list (`/meta/:type`), book tree (`/meta/book/:name/tree`) and item read forwarded `all` to the store as a package id. The layered read answered `404` and the list `[]` for an item stored in a package. Each now answers `?package=all` as it answers the same request without `?package=`. -- Every branch reads `?package=` through `metaItemPackageBinding` from `@objectstack/rest`, the function `RestServer`'s doors read it through. -- Unchanged: a real package id still scopes each read and still binds a save. diff --git a/.changeset/pre/22190-flat-docs-owner-package.md b/.changeset/pre/22190-flat-docs-owner-package.md deleted file mode 100644 index 369ba9c38de..00000000000 --- a/.changeset/pre/22190-flat-docs-owner-package.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os build`: a multi-package artifact's flat `src/docs/` is now carried by the package that owns the artifact's manifest, so its boot no longer warns about them - -Clause-②: no - -A multi-package artifact (one that carries `packages[]`, ADR-0130 D4) keeps its metadata in its package bodies only. `os build` still wrote the stack's own flat `src/docs/*.md` to the artifact's top level. At every boot, the metadata service registered them under the artifact's `manifest.id` and logged `carries N top-level metadata item(s) that none of its N package bodies declare`. The warning told the author to rebuild the artifact, which did not help: in an ADR-0130 layout the app package's source directory (for example `src/sales/`) is not named after the package, so no docs directory the build reads belongs to it. - -- **What changes.** When exactly one `packages[]` entry has the artifact's `manifest.id` as its id, `os build` writes the flat docs onto that entry's body, after any docs from that package's own `src//docs/`. The artifact's top level no longer carries them. `composeStacks(…, { manifest: 'preserve' })` always produces this case, because the artifact's `manifest` is one of its inputs' manifests. -- **What stays the same.** The docs are served under the same package id, the doc count is the same, and books resolve the same tree. The doc lint still checks their names against `manifest.namespace`, and the `Collecting package docs` step line prints the same count. -- **What a running instance now reports differently.** These docs are now part of the owning package's installed record (`GET /api/v1/packages`) and are protected like that package's other docs (`lock: 'full'`, resettable), the same as the flat docs of a single-package artifact. Before, they were registered outside any package record and read back as freely editable. -- **When nothing moves.** If no entry has the manifest id, if several do, or if the artifact declares no `manifest.id`, the flat docs stay on the top level as before. A single-package artifact (no `packages[]`) is byte-identical to before. -- **To pick it up.** Rebuild with `os build`. An artifact that was already built keeps its shape, and the warning, until it is rebuilt. diff --git a/.changeset/pre/22200-drafts-header-label.md b/.changeset/pre/22200-drafts-header-label.md deleted file mode 100644 index 8933fb6bf8f..00000000000 --- a/.changeset/pre/22200-drafts-header-label.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-protocol": minor ---- - -feat(spec,metadata-protocol): each `GET /meta/_drafts` row carries the draft body's own `label`, or `null` - -Clause-②: yes (widening) - -- **What a client can now read.** Every row of the pending-drafts list (`GET /api/v1/meta/_drafts`, the runtime's `GET /metadata/_drafts`, and the SDK's `client.meta.listDrafts()`) carries `label`: the draft body's own top-level `label`. This list is the only place a client finds an item that exists only as a draft, so before this such an item could be shown by its machine name alone (a draft permission set read `technician`, not "Technician"). -- **Its shape.** `I18nLabel | null`, required on the wire. It is carried as authored: a plain string, or an inline locale map (`{ en: …, 'zh-CN': … }`) on the types whose `label` is an `I18nLabel`. The route takes no locale, so the reader resolves a map the way it resolves every other `I18nLabel` (`resolveI18nLabel` in `@objectstack/spec/ui`). -- **When it is `null`.** The body declares no `label`; it declares one `I18nLabelSchema` refuses (a row stored before its type's schema was enforced on save, or a row of a type with no registered schema); or its stored bytes do not parse, in which case the draft stays listed and every read of its body still fails. It is never the item name standing in for a missing label: a reader that wants a fallback chooses it, knowing the label is absent. -- **Where it comes from.** `SysMetadataRepository.listDrafts` reads it off the `sys_metadata` row it already fetches, so there is no second query, and `ObjectStackProtocolImplementation.listDrafts` passes it through. Of the stored body, only this one member leaves; field definitions and every other body key stay off the header. -- **Unchanged.** The other six members, the filters (`?packageId=`, `?type=`), the org scope, and the authoring gate on both routes. diff --git a/.changeset/pre/22201-granted-by-provenance.md b/.changeset/pre/22201-granted-by-provenance.md deleted file mode 100644 index 906d2af670e..00000000000 --- a/.changeset/pre/22201-granted-by-provenance.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -fix(plugin-security)!: `granted_by` on the grant tables is provenance: the platform stamps the writer, and a non-system caller can no longer name the granter - -Clause-②: no (narrowing) - - - -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. - -`granted_by` records who wrote a grant. The platform already treated it that way: the delegated-admin gate stamped the writer, and the organization-admin reconcile writes the attributed human or null (ADR-0118 D1, an id or null and never a sentinel). But the column was a plain lookup, so a non-system caller could store any existing user as the granter, and the integrity audit filed a granter that no longer resolves as a broken business reference, which raised its `[integrity] stored references that resolve to nothing` warning on every run. - -**What moves for consumers.** - -- **Non-system inserts store the writer.** The delegated-admin gate now stamps the caller's user id into `granted_by` on every `sys_user_permission_set` and `sys_user_position` insert it admits, whatever the payload carried. A tenant-level admin's insert and a delegate's insert are stamped alike. Before, a supplied value was stored as sent, and a tenant-level admin's insert that supplied none stored `null`. -- **Non-system updates cannot change it.** Both columns are now `readonly: true`, so the engine's update strip drops a caller's `granted_by` and the row keeps its granter. Before, the update landed. A caller that passes `strictReadonlyWrites` is refused instead, as for every read-only column. -- **The integrity audit files it as provenance.** A `granted_by` that names a user who no longer exists is reported under the audit's `provenance` bucket, which does not raise the warning. A business reference that resolves to nothing is reported under `dangling` and warns, as before. - -**Unchanged.** - -- System-context writers keep the value they write: invitation placement stamps the issuer, the organization-admin reconcile stamps the attributed human or `null`, and the platform-admin promotion writes `null`. A server-side writer that must record someone other than its caller writes under a system context, as these do. -- Who may write a grant, and what a grant gives, are unchanged. The gate's authority decision runs first; a refused write is not stamped. -- Stored rows are not rewritten, and no stored row is refused on read. A row whose `granted_by` already names someone else, or holds a value that names no user, keeps it. -- `sys_record_share.granted_by` (plugin-sharing) is unchanged: its only writer is the sharing service, which already stamps the acting user under a system context. diff --git a/.changeset/pre/22202-migrate-exit-schema-parity.md b/.changeset/pre/22202-migrate-exit-schema-parity.md deleted file mode 100644 index f4364dc9f9b..00000000000 --- a/.changeset/pre/22202-migrate-exit-schema-parity.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os serve --no-server` with `OS_MIGRATE_AND_EXIT=1` now creates every table the same config's server boot registers, `sys_import_job` included - -Clause-②: no - -- **What was wrong.** `serve` composed the REST API plugin only with the HTTP server on. That plugin's `init()` registers `sys_import_job`, the object the async-import routes write to, and schema sync creates tables only for the objects registered in that boot. So a migrate-and-exit run with `--no-server` created one table fewer than the server boot it prepares for. A deployment that migrated "kernel only" and then served with schema sync off had no import-job table for the async-import routes to write to. -- **What changes.** `serve` composes the REST API plugin on every boot, as it already composes the storage, settings, sharing and auth plugins. With `--no-server` its object registers. With no HTTP server in the boot, its `start()` mounts no route and prints one `warn` that the server is absent, as those plugins do. On `examples/app-todo` the `--no-server` run now creates 70 tables, the same set as the server boot. -- **What does not change.** `--no-server` still adds no HTTP server plugin and no dispatcher. A server boot composes the same plugins in the same order as before. The no-auth boot refusal still applies only with the server on. -- **One composition behaves differently.** A config that puts its own HTTP server plugin in `plugins` and runs `--no-server` now gets the REST routes on that server, as it already got the settings and auth routes. Anonymous data access stays denied there: an unauthenticated `GET /api/v1/data/OBJECT` answers `401`. -- **If you worked around it** by dropping `--no-server` from your migration step, either form now provisions the same schema. diff --git a/.changeset/pre/22203-position-package-door.md b/.changeset/pre/22203-position-package-door.md deleted file mode 100644 index 7a0085e6504..00000000000 --- a/.changeset/pre/22203-position-package-door.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): a stack-declared position is registered under its package, so the metadata save door refuses a save over it like every other non-overridable security type - -Clause-②: no - -- **What was wrong.** `PUT /api/v1/meta/position/NAME` naming a position an installed package declares answered `200`, and the saved environment row then won the by-name read (`GET /api/v1/meta/position/NAME`). The type registry declares `position` `allowOrgOverride: false`, as it declares `permission` and `capability`, so the save door should refuse it. -- **Why.** The save door decides "a code package ships this item" from the engine's SchemaRegistry entry that a package registered. `ObjectQL.registerApp()` puts a stack collection into that registry under its package only for the collections it enumerates. That list carried `permissions` and `capabilities`, but still carried the retired `roles` instead of `positions`. So no package-declared position had an entry, and the save took the runtime-create path. -- **What changes.** `registerApp()` (and the nested-plugin seam) now registers a stack's `positions` under the owning package, with the same ADR-0010 provenance as its permission sets. A save over a package-declared position is refused `403 NOT_OVERRIDABLE` on every topology. A save that names the read-only package (`?package=`) is refused `403 ITEM_LOCKED`, which is how a save naming a read-only package is refused for any non-overridable type. The by-name read keeps serving the package's position. -- **What does not change.** A position no package declares still saves (`allowRuntimeCreate`). Permission sets and capabilities are refused as before. The declared-positions seeder in `@objectstack/plugin-security` now reads the stack's positions from the engine registry rather than the metadata service, as it does for permission sets. It seeds the same names, labels and descriptions. -- **To customize a packaged position,** create a position with a different name. diff --git a/.changeset/pre/22212-lint-sibling-spellings.md b/.changeset/pre/22212-lint-sibling-spellings.md deleted file mode 100644 index b9a454c0863..00000000000 --- a/.changeset/pre/22212-lint-sibling-spellings.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -fix(lint)!: four authoring rules now fire on the sibling spelling of a defect they already caught - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing of the `os lint`, `os validate` and `os build` verdicts, shipped as `minor` under the launch-window convention for accept-set narrowings. No rule id is added. Each rule below now reports a spelling of a defect it already reported, at the severity it already had. - -- **`hook-api-update-readonly-field`** (error) and **`hook-body-write-unknown-field`** (warning) read a hook body's writes from one extractor. That extractor accepted only the literal `ctx.api.object(...)` receiver. A body that binds the handle first, such as `const api = ctx.api as HookApi | undefined; await api.object('crm_case').update({...})`, was invisible to both rules. The byte-identical `ctx.api.object(...)` statement fired both. The receiver can now also be a local bound to `ctx.api`, with or without type-only and non-null wrappers. That covers `const api = ctx.api`, `const { api } = ctx`, `const { api: db } = ctx` and an optional call `api?.object(...)`. The local must be declared once and never reassigned, and the write must sit in the block that declares it. A reassigned, shadowed, defaulted or out-of-scope local is still not judged, and neither is a local bound to `ctx.api.sudo()`. The action-body write rules share the extractor, so they read the same receivers. -- **`visibility-bare-identifier`** (error) used to accept any name written as a namespace. `has(record.x) && foo.x == 'crm_lead'` published clean, and the console's engine then answered `Unknown variable: foo` and rendered the element unconditionally. A namespace root that no layer binds is now reported, with its own message and fix. Every root a renderer binds still passes: `record`, `previous`, `parent`, `current_user`, `user`, `ctx`, `os`, `features`, `page` and `data`, plus everything in `@objectstack/formula`'s `SCOPE_ROOTS`. On a metadata-editing form, a dotted chain on the right of `==` / `!=` is still left to `predicate-rhs-path-shaped`. -- **`security-master-detail-ungranted`** (warning) used to treat an object as a detail only when it had a `master_detail` field. The runtime also resolves a `controlled_by_parent` object's master through a required lookup. That child, granted in no permission set, gets the same never-derived 403 on object-level CRUD. It is now reported too. A child with two `master_detail` fields is reported against the master the runtime picks. -- **`component-props-invalid`** / **`component-props-unknown-key`** (warning) skipped a component whose `properties` bag was absent. A top-level component never arrives without one, because `PageComponentSchema` defaults it to `{}`. A nested component (in `children`, `items[].children` or `footer`) is never parsed by that schema, so every required prop it omitted went unreported. An absent bag is now judged as `{}`. - -**What an author sees.** A stack that passed before can now draw one of these findings. Each one names a real defect: a write the runtime drops or refuses, a predicate that can never evaluate, a role that is denied the object, or a component missing a prop its contract requires. The fix is in the finding's hint. The readonly-write and visibility findings are errors, so they fail `os build`. The others are warnings, which fail only `os lint --strict`. diff --git a/.changeset/pre/22218-chunk-progress-org-change-4xx.md b/.changeset/pre/22218-chunk-progress-org-change-4xx.md deleted file mode 100644 index b80f0c5098f..00000000000 --- a/.changeset/pre/22218-chunk-progress-org-change-4xx.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/service-storage': patch ---- - -fix(service-storage): sending a chunk, or polling an expired upload's progress, after switching active organization answers `409 RESOURCE_CONFLICT` naming the change, not a `500` that reads as a data-engine outage - -Clause-②: no - -`PUT /api/v1/storage/upload/chunked/:uploadId/chunk/:chunkIndex` records each chunk on the upload session, and `GET /api/v1/storage/upload/chunked/:uploadId/progress` stamps a session past its `expires_at` as `expired`. Both writes go by id under the caller's active organization, and they are scoped to it. When the uploader's active organization is no longer the one the upload was started in, the write cannot reach the session row. Both doors used to answer that with `500 INTERNAL` and a message diagnosing a data-engine outage ("Restore the data engine …"). - -- **Now:** each door answers `409 RESOURCE_CONFLICT` in the standard error envelope, with the same message the commit and chunked-completion doors give: the upload was started in a different organization than the active one, and switching back finishes it. The chunk door answers it before the chunk reaches the storage backend and before any write. The progress door answers it only when the expiry stamp is due, before stamping. The server log carries one warning naming the door, the upload, the organization it was started in and the active one. -- **Unchanged:** which organization an upload belongs to (switching back finishes it there); a chunk or a progress read from the starting organization; the progress of a live session, or of one already `expired`, which is a read and still answers `200` from any organization; a real data-engine failure, which still answers `500` with its consequence and fix; the resume-token check on the chunk door and the uploader-only rule on the progress door, which run first, so a caller who cannot act on the upload learns nothing about organizations; sessions stamped with no organization; routes with no session resolver; and a store with no data engine wired. - -What changes for you: when either door answers `409 RESOURCE_CONFLICT`, switch the active organization back to the one the upload was started in and retry the same call. diff --git a/.changeset/pre/22219-migrate-meta-write-range.md b/.changeset/pre/22219-migrate-meta-write-range.md deleted file mode 100644 index 44b45b19c1d..00000000000 --- a/.changeset/pre/22219-migrate-meta-write-range.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `os migrate meta --from N --write` rewrites the declared protocol range the load refuses (#22219) - -Clause-②: no - -The load refuses a manifest whose `engines.protocol` excludes the runtime's protocol major, and the refusal names `objectstack migrate meta --from N` as the command that resolves it. `--write` wrote the chain's mechanical changes and left `engines: { protocol: '^N' }` as it was, so an author who followed the refusal, wrote, and reloaded got the same refusal back. A manifest with nothing to convert was not written at all. - -- `--write` now also rewrites the declared range when the load would still refuse the migrated source under it, in the scaffold's spelling: `'^16'` → `'^17'` on a protocol-17 runtime. It rides the same plan, write and re-check as the chain's changes: a range literal the command cannot trace to one site is left with the reason, and a re-check that disagrees restores it with every other file. -- A manifest with nothing to convert gets the range edit alone. -- The major is `--to`, capped at the protocol this runtime implements. `--to` defaults to the highest major this build carries a step for, which can run ahead of the runtime: on a protocol-17 build, `--from 16` replays 16 → 18 and writes `'^17'`. -- The range is rewritten under the key the load read it from: `engines.protocol`, else `engines.platform`, else the legacy `engine.objectstack` (written as `'^17.0.0'`, the only form that key accepts). -- Left alone: a range the load already admits, an absent or unrecognised range, a range at or above the target major, and a range below `--from` (the report names the `--from` that moves it). -- The dry run (no `--write`) names the range edit `--write` would make. With `--json`, `write.range` reports the edit: `status`, `path`, `from`, `to`, and `file` / `line` when written or `kind` / `reason` when left. diff --git a/.changeset/pre/22220-package-door-before-gates.md b/.changeset/pre/22220-package-door-before-gates.md deleted file mode 100644 index f2cbea796f4..00000000000 --- a/.changeset/pre/22220-package-door-before-gates.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-protocol): a save of a packaged item whose type allows no overlay answers `403 NOT_OVERRIDABLE` before any check that judges its body, on every kernel topology - -Clause-②: no - -- **What was wrong.** `PUT /api/v1/meta/:type/:name` refuses an in-place write onto an item a code package ships when the type has no per-organization overlay channel (`allowOrgOverride: false`, for example `object`, `permission`, `position`). An environment-scoped kernel answered that refusal before it judged the body. A host-config kernel (the CLI's assembler, the showcase's boot shape, `OS_MODE=off`) answered it only at the repository write, after every other check. So on that kernel a publish of a packaged object whose body the authoring gate refuses answered `422 INVALID_METADATA` with findings the author could not land through this door, and a body the spec parse refuses answered `422` in draft and publish mode. After fixing the findings, the author got the `403`. -- **What changes.** The package check now runs on every topology, at the position it already had on an environment kernel: after the code-only and organization-scope refusals, before the item lock and every check that reads the body or the store. The same request now gets the same refusal on both kernels: `403 NOT_OVERRIDABLE`, or `403 ITEM_LOCKED` when the save names the read-only package, with the same sentence. On a host-config kernel that sentence replaces the repository's "is not allowOrgOverride in the registry" text for these saves. -- **What does not change.** Every request refused before is still refused, and every request admitted before is still admitted. The repository refused the same writes on every topology, and it still does, for the doors that reach it without this check. An environment-local item, an item of a type that allows overlays, and a save with `OS_METADATA_WRITABLE` open for the type are judged by the same checks as before, and drafts are still not judged by the authoring gate. diff --git a/.changeset/pre/22225-console-not-built-loud.md b/.changeset/pre/22225-console-not-built-loud.md deleted file mode 100644 index 6d935c4d1a0..00000000000 --- a/.changeset/pre/22225-console-not-built-loud.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): with no built Console, `/_console/` says the Console is not built and names the remedy, instead of answering a bare 404 (#22225) - -Clause-②: no - -When the Console package `os serve` resolves has no built `dist/`, the server printed the "Console dist not found" boot warning and mounted nothing. `GET /_console/` then answered the router's `ENDPOINT_NOT_FOUND` and `GET /` an empty 404, which look exactly like a wrong URL. - -Now, on that boot, the server mounts the Console's not-built answer on the routes the Console would own: - -- **`GET /_console/` and every path under it** answer `503` with a short plain-text body. It says the Console is not built, then gives the remedy the boot warning names: `pnpm objectui:build` in a framework checkout, or reinstalling `@objectstack/cli`'s `@objectstack/console` dependency elsewhere. The same function picks the remedy for both. The HTTP form leaves out the absolute paths on the host, which only the terminal shows. -- **`GET /` and `GET /_console`** redirect to `/_console/`, as they do when the Console is built. - -A built Console is served exactly as before, and `--no-ui` / `--no-console` still mount nothing. The `--ui` flag's help text and the README row now name `@objectstack/console`, the package the resolver reads, instead of `@object-ui/console`. diff --git a/.changeset/pre/22226-grant-holder-must-be-organization-member.md b/.changeset/pre/22226-grant-holder-must-be-organization-member.md deleted file mode 100644 index cf843c68e5c..00000000000 --- a/.changeset/pre/22226-grant-holder-must-be-organization-member.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -fix(plugin-security)!: a position assignment or permission-set grant scoped to an organization must name a member of that organization - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -**What stops being accepted.** A `sys_user_position` or `sys_user_permission_set` row whose `organization_id` is set may name only a user who holds a `sys_member` row in that organization. A non-system insert or update that would store any other user is refused with `400 VALIDATION_FAILED`, one `fields[]` entry at `user_id` with `code: reference_not_found` — the envelope a `user_id` naming no user already receives. No new error code. It applies to every non-system caller, platform administrators included, and to every engine write: `POST` / `PATCH /api/v1/data/...`, batch inserts, predicate updates, and scripts and flows that run as a user or a service principal. The organization judged is the one the row is stored with: its own `organization_id`, or, when it names none, the caller's active organization stamped at write time. - -**What stays accepted.** - -- System-context writes: seed replay, invitation acceptance, the organization-admin reconcile and the platform bootstraps. -- A row stored with no organization: a global grant names no organization to be a member of. -- An update that changes neither `user_id` nor `organization_id`, so a stored row whose holder has since left the organization stays editable and can be end-dated. -- A stock `single`-posture deployment: under the default `auto` membership policy every user is a member of the default organization, so every assignment there names a member. - -**What changes for you.** Add the user to the organization first, then assign the position or grant the permission set. A script that assigned a grant before the user joined now receives the `400` above; reorder it. diff --git a/.changeset/pre/22227-field-deadline-due-like.md b/.changeset/pre/22227-field-deadline-due-like.md deleted file mode 100644 index 9eb8c6718c6..00000000000 --- a/.changeset/pre/22227-field-deadline-due-like.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': minor ---- - -A `date` or `datetime` field can declare that it is a deadline, and when that deadline is settled: `dueLike` and `settledWhen` - -Clause-②: yes - -- **`dueLike: boolean`** (optional, no default) declares the date a deadline. A renderer may then show relative overdue wording and an overdue colour once the date has passed. Absent means not a deadline: nothing is inferred from the field's name. -- **`settledWhen`** (optional) is a per-record CEL predicate in the `visibleWhen` / `readonlyWhen` / `requiredWhen` family. While it holds, the deadline is settled and no overdue wording or colour applies to that record, for example `record.status == 'done'`. -- **The doors that judge them.** `FieldSchema` refuses either key on any type other than `date` / `datetime`, and refuses `settledWhen` unless the field also declares `dueLike: true`. The CEL is judged by `@objectstack/lint`'s field-rule pass, the one `visibleWhen` goes through, at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no root but `record` (plus `previous`, and `parent` on a master-detail line item). -- **Display only.** Nothing on the write path reads either key. Every metadata that parsed before parses unchanged; a field without the keys behaves exactly as before. -- **Who reads them.** objectui's date and datetime cells read `dueLike` on the record detail surfaces today. Reading `settledWhen`, and retiring the field-name guess, follows in objectui once this spec version is published. -- **Where to author them.** The field designer offers both on date and datetime fields, `settledWhen` only once the field is a deadline. The example apps declare them on the showcase and todo `task` and the CRM `activity`. diff --git a/.changeset/pre/22228-field-conditional-formatting.md b/.changeset/pre/22228-field-conditional-formatting.md deleted file mode 100644 index b136e4e9e29..00000000000 --- a/.changeset/pre/22228-field-conditional-formatting.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': minor -'@objectstack/metadata-core': patch -'@objectstack/driver-sql': patch ---- - -A field can declare cell formatting rules: `conditionalFormatting: [{ condition, style }]`, the rule a list view already declares for its rows - -Clause-②: yes (widening) - -- **`FieldSchema.conditionalFormatting`** (optional) is an ordered list of `{ condition, style }` rules. The first rule whose CEL `condition` holds applies its CSS `style` map to that field's cell, wherever the field renders. A list view's row rules still style the row, and both may apply to one record. Example: `amount: Field.currency({ conditionalFormatting: [{ condition: P\`value < 0\`, style: { color: '#b91c1c' } }] })`. -- **One rule element, no second style vocabulary.** The `{ condition, style }` element moved out of `ListViewSchema` into its own spec module, and both `ListViewSchema.conditionalFormatting` and `FieldSchema.conditionalFormatting` mount it. The list view accepts exactly what it accepted before. Its refusal of an unknown rule key now reads the same on both members: for a `color` written beside `style`, it says to put the colour in `style` first, then points a list view at `rowColor`. -- **The scope is `value` and `record`.** `value` is this field's value on the record and `record` is the row. `@objectstack/lint` judges each condition at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no other root (`previous`, `parent` and the user roots are refused, naming the field and the rule). -- **For presentation only.** Use it for rules that carry no meaning of their own, such as an amount below zero in red or stock under its safety level in amber. A deadline's overdue state stays a declaration (`dueLike` with `settledWhen`). -- **Display only.** Nothing on the write path reads the key, and every metadata that parsed before parses unchanged. Field-level security drops a rule whose condition reads a field the caller cannot read, keeping the other rules in order (`@objectstack/metadata-core`). The SQL driver classifies the key as presentation, so it never reaches column DDL (`@objectstack/driver-sql`). -- **Who reads it.** objectui's cell renderers read the rules in a follow-up release, once this spec version is published. Until then a rule is validated when it is authored and styles no cell. diff --git a/.changeset/pre/22237-console-asset-cache.md b/.changeset/pre/22237-console-asset-cache.md deleted file mode 100644 index c3bfe4b10b0..00000000000 --- a/.changeset/pre/22237-console-asset-cache.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -The Console served at `/_console/` now tells the browser how long it may keep each file, so a self-hosted console load stops downloading the whole SPA again on every visit. - -Clause-②: no - -- **Content-hashed build outputs** (a file directly under `assets/` named `name-hash.ext`, the hash being Vite's 8-character one) are sent with `cache-control: public, max-age=31536000, immutable`. A rebuild that changes the bytes emits a new name, so the browser never needs to ask again. Before this change, every non-HTML file was sent with a `content-type` and nothing else, so the browser could reuse none of the roughly 2,450 chunks. -- **Every other non-HTML file** gets `cache-control: public, max-age=300` and an `etag` computed from the file's bytes, and a matching `If-None-Match` is answered with `304` and no body. This covers the two stable-named maplibre files objectui copies into `assets/` (`maplibre-gl-worker.mjs`, `maplibre-gl-shared.mjs`) and the root files such as `favicon.svg` and `manifest.json`. These names can carry different bytes in the next console release, so they are never marked `immutable`. The `etag` is computed from the bytes, not the file's modification time, because a console dist extracted with `tar` gives every file the same fixed mtime. -- **HTML is unchanged**: the shell, a direct `index.html` and the SPA fallback are still rewritten per request and carry no lifetime. diff --git a/.changeset/pre/22238-package-union-readers.md b/.changeset/pre/22238-package-union-readers.md deleted file mode 100644 index 883d19c9fcc..00000000000 --- a/.changeset/pre/22238-package-union-readers.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -`os validate`, `os build`, `os lint` and `os i18n check` now judge a multi-package app by its package bodies. A multi-package app that passed with a required capability that has no installable provider in this edition, or with a missing translation, now fails, as the same app shipped as one package always did. - -Clause-②: no - -An app composed with `composeStacks([a, b], { manifest: 'preserve' })` carries its metadata, its `translations` and its `requires` only inside each package's body, and nothing a package owns at its top level. These readers looked at the top level alone, so on such an app they found nothing to judge and answered clean: - -- **The capability preflight** (`os validate`, `os build`) now reads each package body's `requires` when the top level declares none. A capability with no installable provider in this edition fails the run with exit 1, and each finding names its package: `package 'com.acme.service' — Capability "ai" resolves to …`. The advisory for an installable but absent provider (for example `hierarchy-security`) comes back the same way. A top-level `requires` is read exactly as before, so a single-package app prints the same text it always did. -- **Translation coverage** (`os lint`, `os i18n check`) now expects the keys of every package, so a missing translation in any package is reported. Under `os lint --strict` it fails the run again. -- **`os i18n extract`** now extracts every package's keys, where it extracted none. -- **The undeclared-authoring-key warnings** that `os validate --json` and `os build --json` carry in `warnings` now cover items inside package bodies too. - -A `packages` value that is present but is not an array is now refused by `os validate`, `os i18n check` and `os i18n extract` with `INVALID_ARTIFACT_PACKAGES`, as the other readers already refuse it. `os i18n check` and `os i18n extract` used to accept such a config with exit 0 and zero keys; `os validate` refused it from the schema parse instead. - -Apart from that refusal, no accepted input, exported symbol or `--json` field changes. A single-package app's output is unchanged. diff --git a/.changeset/pre/22249-doctor-env-files-loader.md b/.changeset/pre/22249-doctor-env-files-loader.md deleted file mode 100644 index 7245840e5e5..00000000000 --- a/.changeset/pre/22249-doctor-env-files-loader.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): in the source checkout `os dev` serves, `os doctor`'s `Environment files` row names `os serve` / `os start` as the loaders of the `node_env=production` cascade it reports (#22249) - -Clause-②: no - -- **What changed.** With an `objectstack.config.ts` that boots as itself (alone, or beside its own compiled `dist/objectstack.json`), the `Environment files` row now says whose `.env*` cascade it resolved. A freshly scaffolded project with no `.env*` file reads ``✓ Environment files No .env* files here (node_env=production, the cascade `os serve` / `os start` load) — environment read from this process only — no environment input set``. When files are loaded, the row reads ``.env, … (node_env=production), the cascade `os serve` / `os start` load`` where it used to end ``the cascade `os serve` loads``. Before, the no-file form named no loader at all. That left `node_env=production` sitting directly above the `NODE_ENV` row that says `os dev` runs the project as development. -- **What did not change.** Doctor still resolves the cascade exactly as `os serve` does, for `NODE_ENV || 'production'`. So it reads the same `.env*` files and every env-derived verdict, the tenancy-posture refusal among them, is unchanged in every posture. Outside the source checkout, the row is unchanged byte for byte. That covers an artifact-only project, a config beside `OS_ARTIFACT_URL` or beside an `OS_ARTIFACT_PATH` naming another artifact, and a directory with neither. Doctor's exit code is unchanged. diff --git a/.changeset/pre/22250-meta-fls-runtime-sortability.md b/.changeset/pre/22250-meta-fls-runtime-sortability.md deleted file mode 100644 index 6ee4eae8a1e..00000000000 --- a/.changeset/pre/22250-meta-fls-runtime-sortability.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/metadata-core": minor -"@objectstack/rest": minor -"@objectstack/runtime": patch ---- - -fix(metadata): the object read's `sortability` follows the caller's field permission for every caller class - -Clause-②: yes (widening) - -- **What was wrong.** ADR-0106 D4 serves a caller holding `manage_metadata`, `studio.access` or `setup.access` (a platform admin, and an organization admin through `setup.access`) the object schema UNMASKED, because Studio and Setup authoring need the whole definition. The `sortability` projection served beside it on `GET /meta/object/:name` (and the runtime dispatcher's `/metadata/object/:name`) was derived from that unmasked document, so it marked a field `sortable: true` that the same caller's field permission marks unreadable, while the data route refuses a sort or filter on that field with `403 PERMISSION_DENIED`. A client building its sort picker from the projection offered a click the server refuses. -- **What changes.** `sortability` is now derived from the caller's runtime view: the served document for every caller the mask projects (unchanged), and for a D4-exempt caller the fields their own field permission reads. The definition (`item.fields`) is unchanged for every class: whole for an exempt caller, masked for everyone else. The data route and `/auth/me/permissions` are unchanged. -- **Caching.** On the cached `GET /meta/object/:name` branch an exempt caller's ETag folds a fingerprint of the fields withheld from their projection, and the conditional request is judged after it is folded. An exempt caller whose permission withholds nothing keeps the byte-identical ETag. An exempt caller and a projected caller denied the same field never share a validator. -- **Failure posture.** The exempt caller's field permission is asked lazily, only by the item read that serves `sortability`, and never turns their definition read into a fault: if the security service throws or cannot answer, `sortability` is derived from the served document as before, with one `warn`. -- **New in `@objectstack/metadata-core`.** `resolveObjectSchemaRuntimeView(document, posture)` returns `{ document, withheld, fingerprint }`, the view the runtime projections beside an object schema are derived from. The types `ObjectSchemaRuntimeView` and `ObjectSchemaRuntimeFieldPosture` are exported with it, and the `passthrough` member of `ObjectSchemaMaskPosture` gains an optional `runtime` (set on an `exempt` posture for a capability-exempt caller in a deployment with a `security` service). -- **New in `@objectstack/rest`.** `translateMetaEnvelope` takes an optional fifth argument, `runtimeDocument` (default: the served document), and `MetaItemAnswerSources.translateEnvelope` an optional third. Nothing is removed, renamed or narrowed. diff --git a/.changeset/pre/22255-policy-unbound-info-glyph.md b/.changeset/pre/22255-policy-unbound-info-glyph.md deleted file mode 100644 index 4a947c2b6fa..00000000000 --- a/.changeset/pre/22255-policy-unbound-info-glyph.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -The startup banner's `Flows:` section prints flows left unbound by the deployment's scheduled-work switch as information, not as a warning. - -Clause-②: no - -- **Information, not a warning.** Flows that declare a time trigger but are not bound because package-authored scheduled work is off on this deployment (`OS_AUTOMATION_SCHEDULED_WORK_ENABLED` unset — the default) now print dim, under `ℹ`: `ℹ 8 flows declare a 'schedule' trigger but are NOT bound — disabled by deployment policy — … (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy), so no time trigger arms …: flow_a, flow_b, …`. The line's text, its flow list and its place among the other lines are unchanged. Before, it printed as a yellow `⚠`, though nothing about those flows needs fixing. -- **Every other reason keeps `⚠`.** A binding failure, a missing trigger, an unknown target object and a shadowed flow name still print as yellow warnings. The class is recognised only by the exact sentence the automation engine records for the deployment switch, so a reason that merely mentions the policy stays a warning. A host that turns scheduled work off with its own reason sentence also keeps `⚠`. -- ⛔ Nothing you author changes. Which flows bind, the scheduled-work switch, the level `@objectstack/service-automation` logs at, *Boot diagnostics* and every public key, export and parameter are unchanged. diff --git a/.changeset/pre/22256-composed-load-path-conversions.md b/.changeset/pre/22256-composed-load-path-conversions.md deleted file mode 100644 index 3abac6c0be6..00000000000 --- a/.changeset/pre/22256-composed-load-path-conversions.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/spec': patch ---- - -fix(cli): `os migrate meta` lists and writes a conversion the load already applies inside a composed project's package body (#22256) - -Clause-②: no - -- **What was wrong.** In a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, each package body is assembled from the stack its input's `defineStack` call returns. That call runs the load-time ADR-0087 conversions, both when the schema accepts the input and when `os migrate meta` produces a refused input again in `strict: false` mode. So a spelling the load still converts inside a package body, such as `datasources[].driver: 'mongo'`, was never listed in `applied`, and `--write` never wrote it. Every later load kept printing `converted at load … Update the source to the canonical shape`, and the run exited 0. -- **What changed.** `os migrate meta` now produces every input of the composition again from what its author wrote, with the load-time conversions skipped, before the inputs are composed. Composition then assembles each package body from the authored source. The conversion is listed under the body's path (for example `packages[0].manifest.datasources[0].driver`), `--write` writes it into the file that authored that input, and the next load converts nothing. This holds whether the input's `defineStack` call accepted its argument or was refused. -- **`@objectstack/spec`.** `defineStack` reads one internal parameter for this, which only the CLI passes: an undeclared, symbol-keyed entry on its options, honoured only together with `strict: false`, that skips the conversion pass. It is not part of `DefineStackOptions` and nothing new is exported. A strict call ignores it, and every call without it behaves exactly as before. -- **What did not change.** A project with nothing to convert gives the same `--json` summary and the same `--write` result and written bytes as before. This was measured on the four example apps, one of them composed, at `--from 16` and `--from 17`. Every other command still reads the stacks `defineStack` builds and the composition they make. -- **The `--out` snapshot of a composed project.** Each package body in it is now the body as written, with the chain's changes applied. That is what the snapshot of a one-package stack already is. It no longer carries values the schema's parse fills in and the source never wrote, such as default keys. diff --git a/.changeset/pre/22256-migrate-meta-load-path-conversions.md b/.changeset/pre/22256-migrate-meta-load-path-conversions.md deleted file mode 100644 index bd796637161..00000000000 --- a/.changeset/pre/22256-migrate-meta-load-path-conversions.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os migrate meta` lists and writes a conversion the load already applies, on a stack the current schema accepts (#22256) - -Clause-②: no - -- **What was wrong.** When the current schema accepts a stack, `defineStack` runs its load-time ADR-0087 conversions while the config loads. `os migrate meta` then replayed its chain over a stack that was already converted. So a spelling the load still converts, such as `datasources[].driver: 'mongo'`, was never listed in `applied`, and `--write` never wrote it. Every later load kept printing `converted at load … Update the source to the canonical shape`, the notice that sends the author to this command, and the run exited 0. -- **What changed.** The chain now starts from the argument the stack's `defineStack` call was given. It already did this for a stack the schema refuses. The conversion is listed, `--write` writes it into the source, and the next load converts nothing. -- **What did not change.** A stack with nothing to convert gives the same `--json` summary and the same `--write` result as before. This was measured on the four example apps at `--from 16` and `--from 17`. Every other command still reads the stack `defineStack` builds. -- **The `--out` snapshot.** It is now the stack as written, with the chain's changes applied. That is what it already was for a stack the schema refuses. It no longer carries values the schema's parse fills in and the source never wrote, such as default keys and actions merged into their objects. diff --git a/.changeset/pre/22257-auth-settings-read-after-bind.md b/.changeset/pre/22257-auth-settings-read-after-bind.md deleted file mode 100644 index 13bed71b6b4..00000000000 --- a/.changeset/pre/22257-auth-settings-read-after-bind.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -A seeded boot no longer reads the `auth` settings before the settings engine binds - -Clause-②: no - -Under `os serve` and `os dev`, an app with inline seed data emits `app:seeded` while plugins are still starting. The auth plugin's one-time membership backfill (ADR-0093 D6) ran on that event. Through it, the plugin bound the `auth` settings namespace before `SettingsServicePlugin` had bound its data engine. The boot logged `[SettingsService] Pre-bind READ of namespace 'auth'`, and the binding was computed from the manifest defaults, not from the saved `auth` settings. Measured on the showcase: one such line on every boot. - -The backfill is now armed by its own `kernel:ready` hook, which runs after the settings plugin binds its engine. A trigger of the pass before that hook does nothing: the `kernel:ready` pass runs afterwards and scans the same rows. A seed that settles after `kernel:ready` still re-runs the pass, as before. - -- **Which settings are read is unchanged.** Only the moment of the first read moves. -- **A saved `auth.membership_policy` governs the one-time pass.** Before, the pass's first run could read the manifest default `auto` while a saved `invite-only` sat unread. diff --git a/.changeset/pre/22258-in-process-session-read-no-renewal-behind-cookie.md b/.changeset/pre/22258-in-process-session-read-no-renewal-behind-cookie.md deleted file mode 100644 index 54717417b23..00000000000 --- a/.changeset/pre/22258-in-process-session-read-no-renewal-behind-cookie.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/types': minor -'@objectstack/rest': patch -'@objectstack/runtime': patch -'@objectstack/plugin-hono-server': patch -'@objectstack/cloud-connection': patch ---- - -fix(auth): a server-side session read no longer renews a browser session behind its cookie (#22258) - -**What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Ten doors read the session in-process (`auth.api.getSession`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, later `GET /api/v1/auth/get-session` calls found a fresh row and re-issued nothing, and the cookie expired first: a dead cookie beside a live bearer, and every cookie-only path then saw a signed-out user. Measured on a fresh dev stack (better-auth 1.7.3, `expiresIn` 604800 s, `updateAge` 86400 s, a session aged to `now + expiresIn − updateAge − 60 s`): `GET /api/v1/data/:object`, `GET /api/v1/auth/me/permissions`, `GET /api/v1/meta/object`, `GET /api/v1/i18n/locales`, `GET /api/v1/packages`, `GET /api/v1/marketplace/install-local` and the MCP door each moved `expires_at` by +86460 s and set no session cookie. - -**The rule now, decided by what the request carries.** - -- **A session cookie** (a browser, including a console that sends its cookie beside its bearer): the in-process read passes `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. Measured after the change: every door above leaves `expires_at` unchanged on a cookie request and sets no cookie. -- **No session cookie** (a bearer-only client: `@objectstack/client` outside a browser, the `os` CLI): unchanged. A data read past `updateAge` still renews the session (+86460 s, measured on the same doors), so an active bearer client keeps sliding forward without calling `get-session`. No cookie is ever set on a response to a request that sent none. - -**Upgrading.** Nothing to change. A browser session renews whenever the app calls `GET /api/v1/auth/get-session`; a tab that never calls it now signs out at the session's real expiry instead of keeping a live bearer beside a dead cookie. - -`@objectstack/types` gains `inProcessSessionReadInput(headers)` (the `getSession` input for an in-process read: the request's own headers, plus `query: { disableRefresh: true }` when they carry a better-auth session cookie), `carriesSessionCookie(headers)` and the `InProcessSessionReadInput` type. A host that calls `auth.api.getSession` itself should read through `inProcessSessionReadInput` for the same reason. - -The same rule is applied to the in-process `auth.api.getSession` readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` by their own changeset. diff --git a/.changeset/pre/22258-services-in-process-session-read.md b/.changeset/pre/22258-services-in-process-session-read.md deleted file mode 100644 index 3d1c985619c..00000000000 --- a/.changeset/pre/22258-services-in-process-session-read.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/plugin-auth': patch -'@objectstack/plugin-webhooks': patch -'@objectstack/plugin-sharing': patch -'@objectstack/service-storage': patch -'@objectstack/service-settings': patch -'@objectstack/service-datasource': patch ---- - -fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258) - -**What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`). - -**The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`: - -- **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. -- **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none. - -**Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`. diff --git a/.changeset/pre/22261-settings-organization-row-identity.md b/.changeset/pre/22261-settings-organization-row-identity.md deleted file mode 100644 index 300a287f5fb..00000000000 --- a/.changeset/pre/22261-settings-organization-row-identity.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/service-settings': minor ---- - -fix(service-settings)!: tenant- and user-scope settings rows carry the caller's organization, and the data API read of the settings stores applies each namespace's readPermission - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -`sys_setting` declares its row identity as `(organization_id, namespace, key, scope, user_id)`. `SettingsService` now carries the organization in that identity itself, on every read and write of a `tenant` or `user` row, because it reads and writes the store under its own system context, which no driver tenant scope or organization wall reaches. - -- **Writes.** A `tenant` or `user` row is written with the caller's organization (`SettingsContext.tenantId`) in its key and in its stored `organization_id`. A write by one organization updates only that organization's row. -- **Reads.** The tenant and user rungs draw on the caller organization's rows and on rows stored with no organization, and take the caller organization's own row when it has one. A row stored with no organization stays the fallback for every organization until that organization writes its own. A caller that names no organization reads only the rows stored with no organization under a walled posture (`group`, `isolated`), and every row under `single`. The global rung (`sys_platform_setting`) is unchanged and read by every organization. -- **Locks.** The lock check on a write reads the same upper rows the caller's cascade reads, so a lock on one organization's tenant row locks nothing for another organization. -- **Refused now.** Under a walled posture, `set` and `setMany` refuse a key declared `scope: 'tenant'` when the context names no organization, a `null` reset of one included. The refusal is a `SettingsValidationError` (`code: 'SETTINGS_VALIDATION'`, HTTP 400 at the settings routes) with one `fields` entry per such key (`code: 'invalid_value'`, `constraint: { scope: 'tenant' }`). It refuses the whole batch, before anything is written. The posture is the one the `tenancy` service reports; `SettingsServicePlugin` supplies it through `bindEngine`. -- **Generic read door.** `SettingsServicePlugin` registers an engine middleware on `sys_setting`, `sys_setting_audit` and `sys_platform_setting`: every non-system read (`find`, `findOne`, `count`, `aggregate`) is narrowed to the namespaces whose `readPermission` the principal holds, by the same rule `GET /api/settings/:namespace` applies. A namespace with no registered manifest reads at the default capability, `setup.access`. -- **Unchanged.** Under `single`, a caller in the default organization reads every value it read before, and a process-wide reader that names no organization reads the organization's current value. Keys declared at `scope: 'global'` resolve and write the same for every caller. - -What changes for you: write a tenant-scope setting from inside the organization it belongs to (an active organization on the session, or `SettingsContext.tenantId` in process). Rows already stored with no organization are not rewritten. diff --git a/.changeset/pre/22269-print-render-service.md b/.changeset/pre/22269-print-render-service.md deleted file mode 100644 index cdc78400e31..00000000000 --- a/.changeset/pre/22269-print-render-service.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/service-print': minor -'@objectstack/spec': patch -'@objectstack/service-storage': minor ---- - -feat(service-print): print a record's print page to a PDF in headless Chromium, as the requesting user, and archive it as a file attached to the record - -Clause-②: yes (widening) - -A new package, `@objectstack/service-print`. `PrintServicePlugin` registers the in-process `print` service (`IPrintService`): `status()` and `printRecordToFile({ app, object, recordId, page, filename? }, context)`. It adds no route, no metadata key and no error code. Compose it explicitly: - -```ts -import { PrintServicePlugin } from '@objectstack/service-print'; -plugins: [new PrintServicePlugin()] -``` - -- **One engine, headless Chromium, in one of two placements.** A local executable (`OS_PRINT_CHROMIUM_PATH`, option `chromiumPath`), or a remote browser's DevTools WebSocket endpoint (`OS_PRINT_BROWSER_ENDPOINT`, option `browserEndpoint`). Either one needs `OS_PRINT_CONSOLE_URL` (option `consoleUrl`): the console's URL as that browser reaches it, `/_console` included. Configuring both placements is refused. `playwright-core` is pinned exact on this package only. -- **Loud at startup.** `start()` probes the engine and logs its placement and Chromium version. An engine that is not configured, is misconfigured or does not answer is reported once, with the setting to fix, and `status()` says why. A remote endpoint is never logged beyond its scheme and host. -- **As the requesting user.** The page is rendered through a short-lived session minted for the caller's own principal by the auth service (`mintPrincipalSession`), and revoked in a `finally`. There is no system principal. -- **Refusals first, each with `code` and `status`, before any session is minted or browser started** (an engine that failed its last probe is re-probed only after them all): a caller that is not a human user acting for themselves (`PERMISSION_DENIED` / 403); an engine that is not ready (`SERVICE_UNAVAILABLE` / 503); an unknown page (`RESOURCE_NOT_FOUND` / 404); a page that is not the object's default record page or declares no `print` (`VALIDATION_ERROR` / 400); a record the caller cannot read (`RECORD_NOT_FOUND` / 404); an object without `enable: { files: true }` (`FILES_DISABLED` / 403). -- **The archive runs as the requesting user.** `@objectstack/service-storage`'s `file-attachments` service gains a caller-scoped face, `ICallerFileAttachmentService` (exported type), on the same slot and the same writer: `assertMayAttach(parentObject, parentId, context)` throws the insert's own refusal without writing (`PERMISSION_DENIED` / 403 when the caller may not create attachments, `ATTACHMENT_PARENT_ACCESS` / 403 when the attach rule refuses), and `attachFileAs(input, context)` writes one `sys_file` owned by the caller and one `sys_attachment` inserted under the caller's context, so every insert gate judges the caller and the row is stamped `uploaded_by` the caller. The print service asks `assertMayAttach` in its files step, before any session is minted or browser started. `attachFileAs` acts only in the caller's own organization: an `organizationId` other than the context's `tenantId` (or `null` when it names none) is refused `PERMISSION_DENIED` / 403. `attachFile` (the system verb) is unchanged. - -`@objectstack/spec`'s `llms.txt` counts the new package: the workspace publishes 72 packages. diff --git a/.changeset/pre/22270-print-record-door.md b/.changeset/pre/22270-print-record-door.md deleted file mode 100644 index c87942e46fc..00000000000 --- a/.changeset/pre/22270-print-record-door.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/service-print': minor ---- - -Clause-②: no - -feat(service-print): print a record from the console — `PrintServicePlugin` mounts `POST /api/v1/print/records`, which prints the record as the signed-in caller and answers with the file it attached - -- **What it adds.** One HTTP route on the kernel's HTTP server, mounted at `kernel:ready` whenever the plugin is composed. Its JSON body is the in-process request, `{ app, object, recordId, page, filename? }`. The route resolves the caller with the platform's one authorization resolver, hands that caller's execution context and the request to `IPrintService.printRecordToFile`, and answers `201` with `{ fileId, attachmentId, size, filename }`. The service attaches the PDF once, as the caller; the route never attaches anything itself. -- **How an app uses it.** Declare a `type: 'api'` action whose `target` is `/api/v1/print/records`, with `recordIdParam: 'recordId'` and the rest of the request in `bodyExtra` (`app`, `object`, `page`). The console sends the clicked record's id and those fields, and shows a refusal's message as the action's error. -- **What the route refuses, before the service is asked:** an anonymous request (`UNAUTHENTICATED` / 401); a sign-in held by an authentication policy (`403` with the policy's own code); a request from an impersonation session (`PERMISSION_DENIED` / 403 — a print is archived in the session user's name, so it is never made for them by someone signed in as them); a query parameter, or a body field outside the request (`VALIDATION_ERROR` / 400). A permission store that cannot be read answers its own `SERVICE_UNAVAILABLE` / 503. -- **What it relays.** Every refusal the service declares keeps its `code` and `status` (`SERVICE_UNAVAILABLE` / 503 when no print engine is configured, `VALIDATION_ERROR` / 400 for a page without `print`, `RECORD_NOT_FOUND` / 404 for a record the caller cannot read, the files and attach refusals, `TIMEOUT` / 504, the archive's own). A 4xx keeps the service's message. A 5xx answers with the route's own sentence, and the service's text goes to the server fault log. -- **Stated limits.** The route writes no audit row of its own: a print's session sign-in and sign-out are recorded as any sign-in is, and `sys_file.owner_id` and `sys_attachment.uploaded_by` name the caller. An impersonation session is recognised by the mark the auth service writes on it (`session.impersonatedBy`, stored as `sys_session.impersonated_by`); an auth provider that marks impersonation some other way is not recognised. -- **Unchanged:** the in-process `print` service and its refusals, every export, every plugin option and environment variable, and the error-code vocabulary. A kernel with no HTTP server mounts no route and says so once at `kernel:ready`. diff --git a/.changeset/pre/22272-kernel-boot-phase-hooks-option.md b/.changeset/pre/22272-kernel-boot-phase-hooks-option.md deleted file mode 100644 index 3e149918a7b..00000000000 --- a/.changeset/pre/22272-kernel-boot-phase-hooks-option.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/core": minor ---- - -feat(core): `ObjectKernelConfig.bootPhaseHooks`, a declared option to bootstrap without dispatching `kernel:ready`, `kernel:bootstrapped` and `kernel:listening` - -Clause-②: yes (widening) - -- **What is new.** `new ObjectKernel({ bootPhaseHooks: false })` runs dependency ordering, every plugin's `init()`, the core-service fallbacks, every plugin's `start()` and the system-requirement check exactly as before. It then stops without dispatching the three boot-phase hooks, and the kernel is `running`. Only an explicit `false` withholds them. An absent option or `undefined` keeps today's boot, unchanged. `Runtime` forwards the option through its existing `kernel` config. -- **Who it is for.** A host that needs a kernel's registered definitions and started services, and nothing the boot phase does. The example is a repair kernel for an environment whose normal boot cannot finish because a boot-phase handler throws, never settles, or outlasts the host's timeout. Such a host no longer has to wrap the kernel's private `context.hook` to drop those names. -- **What it guarantees.** Every service and every definition a plugin registers in `init()` or `start()` is present, a driver it connects there included. `shutdown()` is unchanged: `kernel:shutdown`, every `destroy()` and every `onShutdown()` handler run. -- **What it does not guarantee.** That the definitions equal a full boot's. A definition a plugin registers in a boot-phase handler is absent, and nothing a plugin does in those handlers runs: seeds, reconciles, schedulers, audits, and the HTTP listener a server plugin opens on `kernel:listening`. Whether a given set of plugins registers anything there is a reading of that set, not a property of the option. The option withholds the kernel's own dispatch, not the hook names. -- **Loud, once.** A withholding boot logs one `warn` naming each withheld hook and how many registered handlers it left uncalled, with the counts also in the line's `withheldHandlers` field. Its completion line says the hooks were withheld. diff --git a/.changeset/pre/22274-option-visible-when-members.md b/.changeset/pre/22274-option-visible-when-members.md deleted file mode 100644 index 0ecf4e4e589..00000000000 --- a/.changeset/pre/22274-option-visible-when-members.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor ---- - -fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads a member of `ctx` or `os` the server's option check never binds, such as `os.org.id`, `os.env` or `ctx.locale` (#22274) - -Clause-②: no (narrowing: a select option's `visibleWhen` that reads an unbound member of a bound root is refused at build and at the object save door) - -A select option's `visibleWhen` is a gate the server enforces on write. The option check binds `record`, `previous` and the acting user, as `current_user` and its ADR-0068 aliases `user`, `ctx.user` and `os.user`. Under `ctx` and `os` it binds the `user` member and nothing else: it passes no organization and no environment. The build already refused a root the option check does not bind, such as `parent`, but it judged `ctx` and `os` as whole roots. So an option predicate that read `os.org.id != ''`, `os.env == 'prod'` or `ctx.locale == 'en'` passed `os build` and the object save door with no finding. On every write that picked the option the predicate then faulted (`No such key: org`, `env` or `locale`), the server logged "the option's gate was NOT enforced on this write", and the value was admitted. - -The build's expression rule (`validateStackExpressions`) now judges the members of `ctx` and `os` in an option's `visibleWhen`, in the same verdict that judges its roots. A member other than `user` is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the member, says that the option check binds only the `user` member under that root, and gives the remedy. Every spelling of the read is judged the same: `os.org`, `os.?org`, `os['org']` and `has(os.org)`. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. - -**BREAKING — what moves for consumers.** - -- `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a member of `ctx` or `os` other than `user`, such as `os.org.id`, `os.org.tier`, `os.env` or `ctx.locale`. -- An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). - -**Remedy.** - -- For the caller's organization, compare `current_user.organizationId`, which the option check does bind: it holds the acting user's organization id, or `null` when the caller acts outside an organization. `os.org.id != ''` becomes `current_user.organizationId != null`. No other organization fact, such as its tier, is available to an option predicate; read a column the object declares instead. -- `os.env`, `ctx.locale` and any other member: rewrite the predicate against `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`. -- Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. - -**Unchanged.** - -- The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind a member such as `os.org` for an option, this refusal is lifted for that member in the same change. -- The acting user is still accepted under all four ADR-0068 spellings (`current_user`, `user`, `ctx.user`, `os.user`), and so are its fields (`current_user.positions`, `ctx.user.id`) and a grant check such as `current_user.can('OBJECT', 'edit')`. -- The same members are still accepted where they are bound, such as `os.org.id` in a `formula` field's `expression`. The refusal is the option slot's alone. -- A computed key such as `os[name]` names no member, so it is not judged. -- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. -- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. -- Measured before crossing: the objects this repository ships carry 5 option predicates, all on `showcase_cascade`, which read `record` (four) and `current_user` (one). None reads `ctx` or `os`. That holds over every object in its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them. They have 0 refusals at the build and at the door, before this change and after it. -- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. - - diff --git a/.changeset/pre/22277-cel-timestamp-string.md b/.changeset/pre/22277-cel-timestamp-string.md deleted file mode 100644 index 59b4db5adc9..00000000000 --- a/.changeset/pre/22277-cel-timestamp-string.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/formula": minor ---- - -feat(formula): `isoDate(t)` and `isoDatetime(t)`, the string form of a CEL timestamp - -Clause-②: yes (widening) - -- **What is new.** Two CEL stdlib functions that turn a timestamp into ISO text on the UTC calendar. `isoDate(t)` returns `YYYY-MM-DD` and `isoDatetime(t)` returns `YYYY-MM-DDTHH:mm:ss.sssZ`, always with three-digit milliseconds and a `Z`. Both are in `CEL_STDLIB_FUNCTIONS`, so `introspectScope` advertises them and the build check accepts them. -- **The bytes the flow template dialect wrote.** `isoDate(today())` is the text `{TODAY()}` wrote, `isoDatetime(now())` is the text `{NOW()}` wrote, and `isoDate(daysFromNow(n))` / `isoDate(daysAgo(n))` are the text `{TODAY() + n}` / `{TODAY() - n}` wrote, in a flow value envelope. This holds at month, year, leap-day and DST-transition instants, whatever the host's zone. -- **`isoDate(today())` is the reference-timezone day.** `today()` is that day at UTC midnight, and `isoDate` reads the UTC calendar. Under a non-UTC reference zone, `isoDate(now())` can be a different day: it is the UTC day of the instant. -- **Only a timestamp is accepted.** Text, a number or `null` is refused, at build when the argument's type is known and at run otherwise. It is never coerced or rendered. For ISO text, parse it first: `isoDate(date(s))`. An invalid timestamp, or one outside 0001-01-01 to 9999-12-31, is refused at run. -- **`string(timestamp)` is still refused.** CEL defines that conversion as RFC 3339 text that drops a zero fraction, which is not the template's `.000Z`, so each shape has one spelling. -- **No write path changes.** An envelope that returns a timestamp (`today()`, `now()`, `daysFromNow(n)`, `addDays(…)`) still evaluates to a `Date` and writes what it wrote before. diff --git a/.changeset/pre/22278-tenant-wall-judges-emptied-organization.md b/.changeset/pre/22278-tenant-wall-judges-emptied-organization.md deleted file mode 100644 index 956e98cd745..00000000000 --- a/.changeset/pre/22278-tenant-wall-judges-emptied-organization.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -fix(plugin-security)!: the Layer 0 tenant write wall refuses a non-system update that would leave a row with no organization - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -**What stops being accepted.** Under a walled tenancy posture (`isolated` or `group`), on an object the Layer 0 tenant wall covers, a non-system update that would leave the row with no organization is refused with `403 PERMISSION_DENIED`. That holds for the value the caller sends and for the value the stored row ends up with after the `beforeUpdate` hooks, on the by-id and the predicate update paths alike, and for every non-system caller the wall covers, platform administrators on public tenant objects included. The rule the wall already stated for an update, that a row's organization stays one the caller's organization scope admits, now covers "no organization" as well as "another organization". An organization-less row is not inert: on an ordinary tenant object it leaves every reader's scope, and on the organization-scoped grant tables (`sys_user_position`, `sys_user_permission_set`) it is read as a grant that applies in every organization. - -**What stays accepted.** - -- An update that does not touch `organization_id`, and one that keeps the row's own organization. -- System-context writes (seed replay, imports, migrations, platform bootstraps). -- A platform administrator on an object whose posture permits crossing the wall, as before. -- The `single` posture, where no tenant wall applies. -- Inserts: an absent or empty organization on an insert is still filled with the caller's active organization. - -**What changes for you.** A script or flow that cleared a row's organization through a user or service-principal context now receives the `403` above. Keep the row in its organization; if a row genuinely must carry no organization (a global grant, for example), write it from a system context. diff --git a/.changeset/pre/22279-analytics-absent-security-comments.md b/.changeset/pre/22279-analytics-absent-security-comments.md deleted file mode 100644 index 380caf8e5bf..00000000000 --- a/.changeset/pre/22279-analytics-absent-security-comments.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -docs(service-analytics): the shipped API docs no longer say a deployment with no security service keeps analytics reads open - -Clause-②: no - -The doc comments on `AnalyticsServiceConfig.admitObjectRead` and `AnalyticsServiceConfig.getReadableFields`, and on the service's read gates, said that a missing hook means "the deployment has no security service" and that such a deployment keeps its analytics behaviour. Both parts were wrong. `AnalyticsServicePlugin` always wires both hooks. On `ObjectKernel` and `LiteKernel`, looking up a `security` service that was never registered throws, so the bridges refuse the query, fail-closed: `PERMISSION_DENIED` / 403 naming the object, plus an `error` line. The docs now say so. A missing hook now means only a host that constructs `AnalyticsService` itself without one. - -Only comments change. No behaviour, export or type moves. The text changes in the published `dist/index.d.ts` and `dist/index.d.cts`, and in the JSDoc kept in `dist/index.js` and `dist/index.cjs`. diff --git a/.changeset/pre/22283-storage-limits-honoured.md b/.changeset/pre/22283-storage-limits-honoured.md deleted file mode 100644 index 8f8858be403..00000000000 --- a/.changeset/pre/22283-storage-limits-honoured.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/service-storage': minor ---- - -fix(service-storage)!: the storage settings' Limits group (`max_upload_mb`, `presigned_ttl`, `session_ttl`) is enforced at the upload doors - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -Setup's File Storage page has always rendered a "Limits" group — "Max upload size (MB)", "Presigned URL TTL (seconds)" and "Upload session TTL (seconds)" — and saved it, but nothing read it. `StorageServicePlugin` now reads the group on every settings pass and its upload doors honour it, on the plugin's own mount and on a host's `mountStorageRoutes` mount alike. - -**What stops being accepted.** Where the plugin is bound to the `storage` settings namespace (its default, `bindToSettings: true`, with a settings service present), an upload larger than the resolved `max_upload_mb` is refused with `413` and the registered code `PAYLOAD_TOO_LARGE`, in the usual error envelope, with a message naming the limit and the setting. Nothing is stored: no `sys_file` or `sys_upload_session` row is written and no byte reaches the adapter. The doors that judge it: - -- `POST /storage/upload/presigned` — the declared `size`; -- `POST /storage/upload/chunked` — the declared `totalSize`; -- `PUT /storage/_local/raw/:token` (the local adapter's presigned upload) — the body, by its `content-length` before it is read and by the bytes actually read; -- `PUT /storage/upload/chunked/:uploadId/chunk/:chunkIndex` — the bytes the session has received so far plus this chunk, the same two ways. - -With nothing saved, the limit is the setting's declared default, **100 MB** (counted as MiB: 104,857,600 bytes). Uploads above it used to be stored and are now refused. The largest value the setting accepts is 10240 MB. - -**What else changes.** A saved `presigned_ttl` is now the lifetime of every presigned upload URL and of every non-gated download URL; a saved `session_ttl` is the lifetime of every new chunked upload session. URLs and sessions issued before a save keep the lifetime they were issued with. With nothing saved both are unchanged — 3600 s and 86400 s, the declared defaults and the built-in ones being the same numbers. A save reaches the doors without a restart. - -**Precedence, per key.** A saved value (an admin save or an env override) wins over the host's `presignedTtl` / `sessionTtl` option (the plugin constructor's, or `mountStorageRoutes`'), and the host's option wins over the namespace's declared default — the precedence the adapter keys already had. - -**What stays accepted.** - -- A mount with no settings namespace bound — no settings service, `bindToSettings: false`, or a `mountStorageRoutes` kernel whose `storage` service is not this plugin's — keeps no size limit and its option TTLs. -- On the S3 adapter, a presigned upload is judged by its declared size only: the bytes go straight to the bucket, so the platform never sees them. -- The gated download URL keeps its own short lifetime (`downloadTtl`, 300 s); `presigned_ttl` does not apply to it. - -**What changes for you.** If your deployment accepts uploads larger than 100 MB, set "Max upload size (MB)" in the File Storage settings to the size you need before upgrading; a larger upload receives the `413` above. - -**New API (additive).** `StorageRoutesOptions.limitsSnapshot`, the `StorageLimitsSnapshot` and `StorageLimitReading` types, and `SwappableStorageService.setLimitsSnapshot()` / `getLimitsSnapshot()` — the Limits group as the plugin last read it, which the route composition hands to the doors. diff --git a/.changeset/pre/22286-kernel-signal-listeners.md b/.changeset/pre/22286-kernel-signal-listeners.md deleted file mode 100644 index a0d0a3b23e9..00000000000 --- a/.changeset/pre/22286-kernel-signal-listeners.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/core': patch ---- - -fix(core): a stopped `ObjectKernel` removes its SIGINT/SIGTERM/SIGQUIT listeners and never exits the process - -Clause-②: no - -- **What was wrong.** With `gracefulShutdown: true` (the default) the constructor added one listener per signal and kept no handle, so `shutdown()` never removed them. Every kernel a process built left three listeners behind, and each one called `process.exit(0)` on the next signal. A process that had built and stopped five kernels and still ran a sixth exited six times on one SIGTERM, and the first exit landed before the live kernel had drained. Eleven built-and-stopped kernels also tripped Node's `MaxListenersExceededWarning`. -- **What a kernel does now.** It holds its listeners from construction until it reaches `stopped`, and removes them at that point, whichever way it got there: `shutdown()` completing, the shutdown timeout, or a failed `bootstrap()`. A stopped kernel has no listener, so it cannot handle a signal. While a kernel is `stopping` its listener stays installed, so a repeated signal is absorbed and does not trigger Node's default action mid-drain. -- **Who exits the process.** Only the handler whose signal started the drain, and only once. A signal that arrives while the host is already stopping the kernel through `shutdown()` is logged and left to the host. The kernel no longer exits the process in that case. -- **Unchanged.** A running kernel still drains and exits with code 0 on SIGINT, SIGTERM or SIGQUIT (code 1 if `shutdown()` throws). `gracefulShutdown: false` still installs nothing. With `gracefulShutdown: true`, a genuine shutdown timeout still calls `process.exit(1)`; a `gracefulShutdown: false` kernel no longer exits (#22335). diff --git a/.changeset/pre/22288-cli-package-owned-readers.md b/.changeset/pre/22288-cli-package-owned-readers.md deleted file mode 100644 index e27a052ab98..00000000000 --- a/.changeset/pre/22288-cli-package-owned-readers.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -The CLI reads a multi-package app's package-owned keys (`requires`, `tiers`, `analyticsCubes`, `flows`, `objects` and the rest) out of its package bodies, as it reads a one-package app's top level. - -Clause-②: no - -A `composeStacks([…], { manifest: 'preserve' })` config carries each of these keys only inside the body of the package that declared it. Its top level carries none of them. These readers looked at the top level only, so they read nothing on such an app, and did so silently: - -- **`os serve` capability providers.** A config boot with no compiled artifact did not mount the provider a package declares in `requires` (`automation`, say). The boot logged `Optional service not present` and the features that need the provider were absent. `os dev`, `os start`, and `os serve` beside a built `dist/objectstack.json` already mounted it, because they boot the artifact. -- **`os serve` tiers.** A package's own `tiers` were ignored on `os serve`, `os dev` and `os start`. The preset's tiers were used instead. -- **`os serve` analytics cubes.** The always-on analytics provider started with none of the cubes a package declares in `analyticsCubes`, on every boot of such an app. -- **`os serve` flow line.** The ready banner's line `N flow(s) declared but the automation engine is not enabled` never counted a package's flows, so it did not appear. -- **`os migrate plan` / `apply`.** A host plugin that hard-depends on a provider a package's `requires` supplies could not be ordered. Both commands exited 1 with `Dependency '…' not found for plugin '…'`. -- **`os generate`.** After writing a flow, it warned that the config does not require `automation` and `triggers` even when a package declares both. `os generate types`, `client` and `migration` emitted none of a package's objects. -- **`os doctor`, `os diff`, `os migrate meta`.** `os doctor` skipped every metadata check and reported a healthy environment. `os diff` reported no change for an object added to a package. `os migrate meta` did not list the data migration a package's file fields need. - -Each now reads by one rule: the top-level value when the stack carries one, otherwise every package body's. This is the rule `os validate` and `os build` already use for `requires`. A stack with no `packages[]` reads exactly as before. - -**Two behaviours narrow.** In each, a two-package app is now refused where the same declarations in one `defineStack` were always refused: - -- A capability a package declares in `requires` with no installed provider (`requires: ['ai']` in the open edition, say) now stops an `os serve` boot of the config, with the same `Capability "…"` error. `os dev` and `os start` already stopped there. -- A package's `tiers` now apply. A package that declares `tiers` without `auth` gets a stack with no auth, which `os serve` refuses to boot with its data API, as it always has for one package. To keep the preset's tiers, remove `tiers` from the package, or list every tier it needs. diff --git a/.changeset/pre/22289-migrate-meta-composed-project.md b/.changeset/pre/22289-migrate-meta-composed-project.md deleted file mode 100644 index 528ae138149..00000000000 --- a/.changeset/pre/22289-migrate-meta-composed-project.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os migrate meta` runs on a project built with `composeStacks`, and converts the retired spellings inside its package bodies (#22289) - -Clause-②: no - -- **What changed.** On a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, `os migrate meta --from N` used to exit 1 with `STACK_PROVENANCE_MISSING`, telling the author to wrap each input in `defineStack`, as soon as any input carried a spelling the current schema refuses. Every input already was wrapped. The command now loads such a project: an input whose `defineStack` call the current schema refuses is produced again by `defineStack(input, { strict: false })` with the load-time conversions skipped (#22256) before it is handed to `composeStacks`, and composition runs as it does at build time. -- **The package bodies are migrated.** A composed artifact keeps each definition under the package that owns it (`packages[i].manifest`), and the migration chain used to reach only the stack's own top level. It now also runs over each package body, and lists each change under the body's path, such as `packages[0].manifest.objects[0].fields.starts_at.defaultValue`. `--write` writes a change in package i into the file that authored input i only when input i and every input before it in the `composeStacks([…])` list is a stack literal (a `defineStack({ … })` call on an object literal, written in place or reached through a `const` or an import) that writes its own `manifest` and no `packages`. Only then is package i that one input's body. Otherwise `--write` lists the change with the reason, as it does for any site it cannot trace. -- **What did not change.** A one-package project loads, migrates and writes exactly as before. An input that was never built by `defineStack` (a plain object, or a spread of a built stack) is still refused with `STACK_PROVENANCE_MISSING`. diff --git a/.changeset/pre/22290-value-slot-remedy-cel-claimed-head.md b/.changeset/pre/22290-value-slot-remedy-cel-claimed-head.md deleted file mode 100644 index d68972e5a72..00000000000 --- a/.changeset/pre/22290-value-slot-remedy-cel-claimed-head.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -A value-slot `{…}` refusal now names a CEL spelling that evaluates when the path's head variable is named like a CEL type or keyword (`{list.0}` → `vars["list"][0]`) - -Clause-②: no - -The refusal for a `{…}` path token in a flow value slot (`valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals`, shown by `objectstack validate`, `registerFlow` and the executors) printed the path as bare CEL. For a head variable named `list`, that remedy was `list[0]`, and CEL read `list` as its own type, not the variable: `objectstack validate` refused the remedy it had just suggested (`Cannot index type 'type' with type 'int'`), and the bare `{list}` remedy `list` passed validation and evaluated to the type instead of the variable's value. A head that CEL claims for itself is now read through the flow scope's `vars` map, the route a `$`-named head already took: `{list.0}` → `vars["list"][0]`, `{list}` → `vars["list"]`. - -The claimed names are read off the CEL implementation the formula engine builds (cel-js 8.0.0): the type identifiers `bool`, `bytes`, `double`, `int`, `list`, `map`, `null_type`, `string`, `type` and `uint`; the namespaces `cel`, `google` and `optional`; the reserved words, such as `for`, `if` and `var`; and the keywords `true`, `false`, `null` and `in`. `timestamp`, `duration` and `dyn` are functions there, not bindings, so a variable with one of those names already read correctly and is unchanged. A later path segment that is a keyword is indexed by name (`{record.in}` → `record["in"]`). The `has()` guard the refusal suggests is now printed only where `has()` accepts it. CEL refuses `has()` over an index at run time (`has(rows[0].name)`, `has(vars["list"].tags)`), so a path with an index gets no guard, and a claimed head is guarded a step at a time from `vars`, like any other head: `has(vars.list) && has(vars.list.tags) ? vars.list.tags : null`. - -Ordinary heads (`record.owner`, `items[0]`) print the same remedy as before. Which strings are refused and which are kept is unchanged; only the remedy text changes. diff --git a/.changeset/pre/22300-formula-projection-trim.md b/.changeset/pre/22300-formula-projection-trim.md deleted file mode 100644 index e7ead50fbf8..00000000000 --- a/.changeset/pre/22300-formula-projection-trim.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): a `find` / `findOne` whose `fields` names a formula field returns that projection, not every stored column - -Clause-②: no - -To evaluate a formula field, the engine widens the projection it hands the driver to every stored column of the object (CEL's `record.` reads whatever the formula needs off the full row). The rows were never cut back, so a projection that named a formula field returned every column: the tenant, owner, owning unit and audit columns, and every field the caller did not name. A flow's `get_record`, whose `config.fields` is declared as "only these fields are read", passed all of them on to its later nodes. - -Each row is now cut back to the caller's projection once the read is done: the columns the caller named, plus the formula's computed value. `id` is returned only when it is named. `driver-memory` and `driver-mongodb` add `id` to every projection at the driver layer, so on those two drivers a projection that names a formula field but not `id` no longer carries `id`, while the same projection without the formula still does: name `id` when you need it. The formula still sees the full row, and so do the `afterFind` hooks and the middlewares, as before. A key an `afterFind` hook derives is kept. - -Unchanged: a projection that names no formula field, and a read with no projection (every declared column, with the formulas computed). Field-level security is unchanged as well: a field the caller may not read was already masked off the widened row, and still is. diff --git a/.changeset/pre/22301-seed-temporal-stored-form.md b/.changeset/pre/22301-seed-temporal-stored-form.md deleted file mode 100644 index 5ddbe5b797c..00000000000 --- a/.changeset/pre/22301-seed-temporal-stored-form.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -A cel-dated seed row reaches every hook in the stored form of its temporal field - -Clause-②: no - -`` cel`daysFromNow(n)` ``, `daysAgo(n)`, `today()` and `now()` in a seed record resolve to a JS `Date`. The seed loader handed that `Date` to the engine, so what a hook saw depended on how the hook ran: - -- An in-process `handler` (a source config, which `@objectstack/verify`'s `bootStack` boots) saw the `Date` object. -- A sandboxed `body` (the compiled artifact `objectstack dev` boots) saw a full ISO instant, even on a `date` field. - -A hook that reads a date field as a string therefore accepted the row under `objectstack dev` and refused it under `bootStack`, and the row was not stored. - -The loader now puts each `Date` on a `date`, `datetime` or `time` field into the stored form the drivers write (`YYYY-MM-DD`, the UTC ISO instant, `HH:MM:SS`) before any hook runs, so every hook sees the value the column stores. A body hook on a `date` field now sees `2026-10-09` where it saw `2026-10-09T00:00:00.000Z`. Stored values do not change on any driver, because each driver already normalises a declared temporal field on write (`@objectstack/driver-memory` through the same `temporalStorageForm`). A `Date` on a field that is not temporal, or on a field the object does not declare, is handed over unchanged. diff --git a/.changeset/pre/22301-spec-ledger-verify-provenance.md b/.changeset/pre/22301-spec-ledger-verify-provenance.md deleted file mode 100644 index ec704943fe2..00000000000 --- a/.changeset/pre/22301-spec-ledger-verify-provenance.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): the error-code ledger lists `INVALID_REQUEST` under `@objectstack/verify` - -Clause-②: yes (widening: a new owner provenance row in the published error-code ledger) - -`ERROR_CODE_LEDGER['@objectstack/verify']` now lists `INVALID_REQUEST`. The verify handle's two write doors, `hooks.run` and `hooks.updateWhere`, answer it for a malformed call, such as a call naming two callers (`{ as: token, system: true }`), or a `hooks.updateWhere` the engine would write by id. They answer in-process, with a thrown `Error` carrying `code`, `status` and `statusCode`; it is a test door with no HTTP path. No code is added: `ErrorCode`, `RegisteredErrorCode` and `REGISTERED_ERROR_CODES` are unchanged, so `ApiErrorSchema` accepts exactly what it accepted before. diff --git a/.changeset/pre/22301-verify-boots-the-served-slate.md b/.changeset/pre/22301-verify-boots-the-served-slate.md deleted file mode 100644 index 944001b9afd..00000000000 --- a/.changeset/pre/22301-verify-boots-the-served-slate.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/verify': minor -'@objectstack/plugin-email': minor -'@objectstack/service-sms': minor -'@objectstack/cli': patch ---- - -`bootStack` mounts the always-on capability slate `objectstack serve` mounts for every app, and builds each provider from the app's configuration the way `serve` builds it - -Clause-②: yes (narrowing: a configuration whose mail or SMS settings, or the OS_EMAIL_* / OS_SMS_* environment, name a transport that cannot deliver, now fails bootStack where it booted; widening: bootStack mounts the always-on slate objectstack serve mounts for every app and hands each provider the app's configuration, so the app's analytics cubes now reach the registry, where it mounted fewer and built them with defaults) - - - -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. - -**`@objectstack/verify` — the composition `serve` mounts, built the way `serve` builds it.** For one configuration, `bootStack(config, opts)` already mounted the providers the app's `requires` names and the plugins in its own `plugins` array. It now also: - -- **Mounts the always-on slate** `serve` mounts for every app, whether or not `requires` names it: `queue`, `job`, `cache`, `settings`, `email`, `storage`, `sms`, `sharing`, `messaging`, `analytics` and `package-registry`. Before, a slate provider was mounted only where a mounted plugin hard-depended on it, so an app's tests ran without the inbox delivery, the mail service, the file storage and the package registry its users' server has. -- **Builds each provider from the app's configuration**, by the same rule `serve` reads: the analytics service gets the app's `analyticsCubes` (the top level, then each package body's), the email service the app's `email` block with `OS_EMAIL_*` over it, the SMS service the `sms` block with `OS_SMS_*` over it, and the storage service the `OS_STORAGE_LOCAL_ROOT` root (`.objectstack/data/uploads` under the working directory by default). Before, each was built with its own defaults: no cubes, no mail configuration. So the app's cubes now reach the analytics registry; a cube the service refuses (one over an object the API does not serve) is warned and skipped by the service, under `bootStack` as under `serve`, and no boot fails on it. -- A caller's `extraPlugins`, `security` and `analytics` instances still take precedence by identity, and are not handed the configuration. A suite that needs a provider configured its own way (a temporary storage root, a mail transport) passes its instance in `extraPlugins`. -- Not mounted, because they are decisions about a server process rather than about the configuration: `serve`'s MCP endpoint (`OS_MCP_SERVER_ENABLED`) and its pinyin search (`OS_SEARCH_PINYIN_ENABLED`). A suite that exercises either passes the provider in `extraPlugins`. - -**What now fails that booted before (the narrowing).** A provider `bootStack` constructs and cannot build fails the boot, naming the capability token and the package — whether the app declared the token or the slate appended it. `serve` logs a slate provider it cannot build and boots on; a test boot does not, so an app's tests never pass on a composition its server does not run. In practice: - -- a configuration whose `email` or `sms` settings, or the `OS_EMAIL_*` / `OS_SMS_*` environment of the test process, name a transport that cannot deliver (`provider: 'smtp'` with no host, `resend` / `postmark` with no API key, an unknown provider tag) is refused with the reader's own remedy. Set `OS_EMAIL_PROVIDER=log` / `OS_SMS_PROVIDER=log` in the test environment if it is not meant to send mail or SMS. - -**`@objectstack/core`** exports the rule both boots read: `resolveServedCapabilities` (given the tokens a stack declares, which capability tokens a served boot mounts providers for — those, `email` for a declared `auth`, the host's own defaults, the always-on slate unless the preset is `minimal`, and `job` / `queue` ahead of what schedules background work) and `resolveCapabilityArgument` (what each provider is constructed with), with their `ServedCapabilities`, `CapabilityArgumentInput` and `CapabilityArgument` types; and `resolveStorageCapabilityArg`, `resolveStorageLocalRootEnv` and `StorageCapabilityArg`, moved here from the CLI. - -**`@objectstack/plugin-email`** exports `resolveEmailCapabilityArg`, `resolveDeploymentAppName` and `EmailCapabilityArg`, and **`@objectstack/service-sms`** exports `resolveSmsCapabilityArg` and `SmsCapabilityArg` — the readers that build each provider from the deployment's configuration, moved from the CLI to sit beside the transport vocabulary they refuse against. `resolveCapabilityArgument` reads each off the provider module it is handed. - -**`@objectstack/cli`**: `serve` reads both rules from `@objectstack/core` and composes exactly what it composed before. `os migrate plan` / `apply`'s declaration boot reads the same token rule for the providers it composes (the same set as before; a declared `auth` now places `email` right after the declared tokens, as `serve` does). `os verify` boots through `bootStack`, so it now mounts the always-on slate and builds the providers from the app's configuration. The moved functions stay importable from the `serve` command module. diff --git a/.changeset/pre/22301-verify-boots-what-serve-boots.md b/.changeset/pre/22301-verify-boots-what-serve-boots.md deleted file mode 100644 index 7f42256b8e9..00000000000 --- a/.changeset/pre/22301-verify-boots-what-serve-boots.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/verify': minor -'@objectstack/cli': patch ---- - -`bootStack` composes what `objectstack serve` composes from the same configuration: the providers the app's `requires` names, and the plugins in the app's own `plugins` array - -Clause-②: yes (narrowing) - - - -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. - -**`@objectstack/verify` — one composition rule.** For one configuration, `bootStack(config, opts)` now mounts what `objectstack serve` mounts from it, so an app's tests boot the composition its users get: - -- **The providers the app's `requires` names**, by `serve`'s own reader and table (top-level `requires`, otherwise each package body's), plus the always-on providers a mounted plugin hard-depends on. An app no longer lists them in `extraPlugins` by hand. -- **The plugins in the app's own `plugins` array**, by `serve`'s rule for an entry: an instance is mounted as written, a plain bundle is wrapped into `AppPlugin`, a package name is loaded from the app's root (`hostRoot`). -- **The caller wins by identity.** An `extraPlugins` instance takes precedence over a `requires` provider it is (exact `name` or class name) and over an app plugin with the same `name`; that app plugin is not mounted. `security` and `analytics` instances take precedence over an app plugin of the same `name` the same way. -- **`hostRoot` is the app's root** in the two places `serve` uses the config's directory: the automation service's `packageRoot` (where a declarative connector's package-relative file ref is read) — now also when `automation: true` asks for the service — and the root a string `plugins` entry is resolved from. A suite that does not run from the app's directory passes it. -- **Offline CI.** An app whose `plugins` array wires the marketplace-facing `@objectstack/cloud-connection` plugins gets them mounted, pointed at `OS_CLOUD_URL` (by default the public catalog). Set `OS_CLOUD_URL=off` in the test environment, before the configuration module is imported, to keep the suite offline. - -**What now fails that booted before (the narrowing).** - -- **A second live boot of the same configuration object is refused** with `code: 'RESOURCE_CONFLICT'`, `status: 409`, and so is a copy (`{ ...config }`) that carries an app-plugin instance a live boot mounted: the instances in a `plugins` array are module-level, and two kernels must not share them. Live means until `stop()` resolves. Remedy: `stop()` the first stack before booting again; or share one boot with `bootStackOnce(config, opts)`; or, to keep two stacks of one app live at once, boot the second on a configuration built again (call its builder once more, or import a fresh module instance of it). A `{ ...config }` spread is not a configuration of its own: a live boot keeps references into the configuration's nested definitions. -- **An app `plugins` entry that cannot be loaded or registered fails the boot**, naming the entry (`plugins[i]`) and its remedy. `serve` logs such an entry and boots on; a test boot does not, so a plugin the app declares is never silently absent from its tests. -- **A provider or app plugin that refuses to start fails the boot** where the fixed plugin set never mounted it — for example a declarative connector whose package-relative file ref does not resolve from `hostRoot`. - -**`@objectstack/core`** exports the pieces both boots read: `CAPABILITY_PROVIDERS`, `CapabilitySpec`, `CapabilityIdentities` and `providesCapability` (the `requires` token → provider table and its exact identity match); `stackDeclaredCapabilities`, `resolveStackCollection`, `declaredPackageEntries`, `stackPackageBodies` and `collectFromPackageBodies` (the package-owned collection reader); and `materializeStackPlugin` with `StackPluginLoaders` (what a `plugins` entry becomes). - -**`@objectstack/cli`**: `os verify` boots the app anchored at the directory holding its config (`hostRoot`), as `serve` anchors it, so `os verify --app path/to/objectstack.config.ts` run from another directory reads the app's package-relative files (a declarative connector's spec, a string `plugins` entry) and resolves `--multi-tenant`'s organizations package from the app, not from the working directory. `serve` itself does not change: `Serve.CAPABILITY_PROVIDERS` and `Serve.providesCapability` are handles over the `@objectstack/core` declarations, the stack-collection readers are re-exported from there, and `serve`'s `plugins` loop reads the entry rule from there. diff --git a/.changeset/pre/22301-verify-evaluate-condition-door.md b/.changeset/pre/22301-verify-evaluate-condition-door.md deleted file mode 100644 index 8e25cccc834..00000000000 --- a/.changeset/pre/22301-verify-evaluate-condition-door.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/verify': minor ---- - -feat(verify): the handle gains a door to the automation engine's condition evaluator - -Clause-②: yes (widening) - -An app's tests can evaluate a flow condition on the booted stack's own evaluator through the in-process handle, so a suite no longer calls the booted kernel's `automation` service by hand for a condition's truth table. - -- **`stack.automation.evaluateCondition(condition, variables)`** calls the booted `automation` service's `evaluateCondition`, the method a flow's start gate, its edges and its `decision` branches are decided by. It resolves with the engine's boolean, unchanged. It is for a truth table over variable shapes no write produces. -- **`condition`** takes the shapes the engine takes, and is handed over as given: an envelope (`{ dialect: 'cel', source }`) or a string, which the engine reads as CEL; a `{var}` hole is refused as the brace trap, and a `template` or `flow` envelope is refused too (the legacy template dialect is deleted in this release). The engine's own flow sites hand the evaluator an envelope (a start node's string `condition` and a `decision` branch's `expression` are wrapped as `{ dialect: 'cel', source }`, and an edge's condition already is one), so pass the envelope to model any of them. -- **`variables`** is a plain object. Each own key becomes one entry of the `Map` the engine takes: `{ record, previous }` binds `record` and `previous`. -- **Refusals are not the handle's.** A condition the engine cannot evaluate rejects with the engine's own error, never `false`. On a stack with no automation service it rejects with the kernel's own `SERVICE_NOT_REGISTERED` error, whose `serviceName` is `'automation'` (`isServiceNotRegisteredError` from `@objectstack/core` answers `true`). Boot with `automation: true`, or have the app declare `requires: ['automation']`. No error code is added. diff --git a/.changeset/pre/22301-verify-lowered-body-door.md b/.changeset/pre/22301-verify-lowered-body-door.md deleted file mode 100644 index 174d3b85430..00000000000 --- a/.changeset/pre/22301-verify-lowered-body-door.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/verify': minor ---- - -feat(verify): `bootStack(config, { artifact })` boots the compiled artifact as the app, so a test runs each hook's and action's lowered `body` in the sandbox, as a deployment does - -Clause-②: yes (widening) - -`objectstack build` lowers each inline hook `handler` and action `target` it can into a metadata-only `body`, and a deployment runs that body in the runtime's QuickJS sandbox, against the sandbox's `ctx`. `bootStack` mounted the source configuration only, so a handle test ran the function in-process, and a handler that refuses once lowered passed every test. - -- **`BootOptions.artifact`** names the compiled `objectstack.json`: a path, resolved against `hostRoot` (so `'dist/objectstack.json'` names the app's own build), or an `http(s)://` URL. The artifact is read by the runtime's own artifact loader (`loadArtifactBundle`, with the options every artifact boot reads it with) and mounted as the app, in place of `config`. `AppPlugin` binds each hook's `body` ahead of its `handler`, through the same sandbox runner every artifact boot uses, so every door (`hooks.run`, `actions.run`, `api`, …) runs what the build shipped. -- **A refused body answers as the body-only path answers.** The handle rejects with the sandbox's `SandboxError`, which carries a `code` and `status` the body declared on its throw. A body that crashes (a `TypeError`) carries none, and REST answers it `500` `INTERNAL_ERROR`. For example, a handler that writes to `ctx.dispatch.scope` passes in-process and refuses every insert once lowered, because the sandbox hands a body `ctx.dispatch` without `scope`. -- **The rest of the stack is still composed from `config`**: its `requires`, its own `plugins`, its declared default profile, its datasources. `objectstack serve ` composes the same way. So `config` is the configuration the artifact was built from. -- **A local artifact gets its `objectstack-runtime.*.mjs` merged**, so a callable the build could not lower runs in-process from it, as under `objectstack start`. `objectstack build --strict-body` refuses such a callable at build time, and its artifact is body-only. -- **Refused before anything boots**, each with its `code` and `status`: an artifact that cannot be loaded (`RESOURCE_NOT_FOUND`, 404; the boot never falls back to the source), an artifact built from another app (`RESOURCE_CONFLICT`, 409), and a configuration carrying `onEnable` (`INVALID_REQUEST`, 400). No artifact carries `onEnable`; for a configuration that mounts no plugin instance of its own, `objectstack serve ` grafts it from the configuration module, a `@objectstack/cli` rule this package does not re-derive. No error code is added. -- The handle lowers nothing: build the artifact first. A stale artifact boots stale bodies. diff --git a/.changeset/pre/22301-verify-observe-writes-door.md b/.changeset/pre/22301-verify-observe-writes-door.md deleted file mode 100644 index 9c53c699970..00000000000 --- a/.changeset/pre/22301-verify-observe-writes-door.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/verify': minor ---- - -feat(verify): the handle gains a door that observes the writes the engine receives, a hook's refused write included - -Clause-②: yes (widening) - -An app's tests can see what a hook handed the engine through the in-process handle, so a suite no longer wraps the booted engine's write methods in a spy to find a refused write, to wait for a fire-and-forget hook, or to stage a refusal. - -- **`stack.observeWrites(fn)`** runs `fn` and resolves with every insert, update and delete the booted engine received meanwhile, in the order each reached it. Each entry (`ObservedWrite`) carries `object`, `operation`, the payload (`data`), the update or delete selection (`where`), `multi`, the execution context the write carried (`context`), and the engine's answer (`outcome`): `pending`, then `resolved` with its result, or `refused` with the error the engine threw, unchanged, and that error's own `code`. -- **What it is.** One global middleware on the engine's own chain (`registerMiddleware`), added on the first call. It passes every operation on unchanged and decides nothing. So it sits after the write gates the boot registered. Every write those gates let through is recorded, from any door (`hooks.run`, `seed`, a flow, a route, a hook's `ctx.api`), and so is every refusal the engine raises past them: a declared validation rule (`VALIDATION_FAILED`), a hook's throw, a unique conflict (`DUPLICATE_RECORD`). A write a gate refuses, the permission check among them, never reaches it and leaves no entry. Its refusal still reaches whoever made the write. -- **An outcome is the engine's answer to that call, not a statement about stored rows.** A write an `after*` hook refuses was refused after the driver wrote, and its row can stay. Read rows back with `stack.rows`. -- **The window is time, not cause.** A write the stack makes for its own reasons while `fn` runs is recorded too, so filter by `object`. -- **An `async: true` hook.** Its write reaches the engine after the write that fired it has returned. `fn` is handed the live observation (`WriteObservation`), and `observation.settled(match)` resolves with the first write `match` accepts once the engine has answered it. It rejects after `timeoutMs` (default 2000), naming every write observed, or when the observation closes first. A hook that writes nothing leaves nothing to observe. -- **Staging a refusal.** Arrange one the engine makes itself: seed the row that already holds a unique value the hook will write, or set up the state a declared validation rule refuses. The handle has no switch that refuses a write in the engine's place. -- A `fn` that is not a function, a `match` that is not a function, and a `timeoutMs` that is not a positive number are refused with `code: 'INVALID_REQUEST'`, `status: 400`. A rejection of `fn` is rethrown unchanged once the observation has closed. No error code is added. -- New exported types: `ObservedWrite`, `ObservedWriteOutcome`, `WriteObservation`. diff --git a/.changeset/pre/22301-verify-public-form-door.md b/.changeset/pre/22301-verify-public-form-door.md deleted file mode 100644 index 9c3961cc32c..00000000000 --- a/.changeset/pre/22301-verify-public-form-door.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/verify': patch ---- - -docs(verify): `hooks.run` names the anonymous public-form door a booted stack already serves - -Clause-②: no - -An app's web-to-lead or web-to-case branch runs on an anonymous public-form submission, and a booted stack already serves that write: it is the form's own route, `api('/forms/:slug/submit', { method: 'POST', body })` with no token. `bootStack` mounts that route, and the in-process handle has no method for it, by design: its one owner is `@objectstack/rest`. Nothing changes in what the package does. `hooks.run`'s documentation now names the door, so a suite reaches it instead of calling the engine with a hand-built guest context. - -What the door does, as the package's own tests now pin it on a booted stack: - -- It hands the engine the route's own context: the form's one-object grant, the `guest_portal` permission set, and `anonymous`. There is no user and no system principal. -- A bound hook sees no session and no user, so an app's guest branch runs, and the record-change trigger fires the object's flows with no trigger user. -- It keeps only the fields the form collects and answers `201` with `{ id }`. Read the row back with `rows(object, { id })`. -- An unknown slug, or a form that is shared but not anonymous, is answered `404` with `code: 'FORM_NOT_FOUND'`, and nothing is written. diff --git a/.changeset/pre/22301-verify-system-insert-delete-door.md b/.changeset/pre/22301-verify-system-insert-delete-door.md deleted file mode 100644 index e15b47827a3..00000000000 --- a/.changeset/pre/22301-verify-system-insert-delete-door.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/verify': minor ---- - -feat(verify): `hooks.run` takes the system principal on an insert and a delete, so a user-less record trigger can be driven - -Clause-②: yes (widening) - -An app's tests can drive a record-change flow fired by a write with no user (an integration's or a system job's insert or delete) through the in-process handle, so a suite no longer hands a flow a record through the booted kernel's `automation` service by hand. - -- **`hooks.run(object, 'insert' | 'delete', input, { system: true })`** is the engine's own `insert` / `delete` under `{ isSystem: true }`, the same call the person's write makes with only the context changed. `{ system: true }` on `update` is unchanged. No permission gate applies. The bound hooks still run (they see `session.isSystem` and no `userId`), declared validations still refuse, and the record-change trigger fires the object's flows with no trigger user. -- **A record the engine no longer holds.** A system (or a person's) delete hands a `record-after-delete` flow the row's pre-image as `record` and as `previous`, and by then the engine no longer holds the row: the flow's `get_record` of that id finds nothing. A `record-before-delete` flow on the same delete still finds it. -- **Not `seed`.** `seed(object, rows)` also runs as the system, and it sets `skipTriggers`, so it fires no record-change flow: use it for end-state fixture data, and `{ system: true }` for a write the app should react to. -- A call naming both callers (`{ as: token, system: true }`) is still refused with `code: 'INVALID_REQUEST'`, `status: 400`, before anything is written. Every other refusal is the engine's own error, unchanged: assert on its `code` and `status`. diff --git a/.changeset/pre/22301-verify-update-doors.md b/.changeset/pre/22301-verify-update-doors.md deleted file mode 100644 index ae8240f8225..00000000000 --- a/.changeset/pre/22301-verify-update-doors.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/verify': minor ---- - -feat(verify): the handle gains a system-context update door and a predicate update door - -Clause-②: yes (widening) - -An app's tests reach two more engine write paths through the in-process handle, so a suite no longer calls the booted kernel's `objectql` service by hand for them. - -- **The system principal on an update.** `hooks.run(object, 'update', { id, ...fields }, { system: true })` writes one row by id under `{ isSystem: true }`, the context a system job or an integration writes under. No permission gate applies. The bound hooks still run (they see `session.isSystem` and no `userId`), declared validations still refuse, and the record-change trigger fires its flows with no trigger user. The new type is `AsSystem` (`{ system: true }`), exported beside `AsUser`. - - `{ system: true }` beside an `as` token is refused with `code: 'INVALID_REQUEST'`, `status: 400` before anything is written. Fixture rows are still `seed(object, rows)`, which also skips record triggers. (`insert` and `delete` take `{ system: true }` too: see the system insert and delete entry.) -- **The predicate update.** `hooks.updateWhere(object, where, data, opts)` is the engine's own predicate path, `update(object, data, { where, multi: true, context })`. It writes one payload to every row `where` selects and resolves with the affected-row count. The engine dispatches `beforeUpdate` and `afterUpdate` once per matched row, each bound to that row's own pre-image as `previous`, so record-change flows evaluate and fire per row. No REST door reaches this path: `POST /data/:object/updateMany` writes by id. The caller is `{ as: token }` or `{ system: true }`. - - It refuses with `code: 'INVALID_REQUEST'`, `status: 400`, before the engine is touched, a `where` that is not an object, and a call the engine's own update dispatch would write by id: a `where` that names only an `id`, or an `id` in `data` beside a `where` that selects by nothing else. Write one row by id through `hooks.run(object, 'update', { id, ...fields }, opts)`. An `id` inside a larger predicate (`{ id, status: 'open' }`) stays a predicate update. An `id` in `data` beside a real predicate is refused by the engine itself, as it is on every caller. - -Every other refusal from either door is the engine's own error, unchanged: assert on its `code` and `status`. diff --git a/.changeset/pre/22302-security-service-principal-less-text.md b/.changeset/pre/22302-security-service-principal-less-text.md deleted file mode 100644 index ce7675d497b..00000000000 --- a/.changeset/pre/22302-security-service-principal-less-text.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -docs(spec): `ISecurityService` no longer says a context with no principal is admitted or keeps its scope - -Clause-②: no - -The contract text on `ISecurityService.getReadFilter`, `ISecurityService.canExport` and `ISecurityService.canReadObject` (its verdict's arm list and its fails-closed paragraph) said that a context carrying no principal at all keeps the scope the other layers compose for it, or is admitted, as the security middleware hands it through. Since ADR-0096 D5 strict mode `plugin-security` does neither. A non-system context with no position, no named permission set and no user id now gets `false` from `canReadObject` and `canExport`, the deny filter from `getReadFilter`, and `403 PERMISSION_DENIED` (`PermissionDeniedError`) from the engine middleware's principal-less refusal. The text now says so, at each of the three methods, and cites ADR-0096 D5. - -The TSDoc on `ExecutionContext.flowRunId` and on the `ExecutionContext` input type (`kernel/execution-context.zod.ts`), and on `BaseEngineOptionsSchema.context` (`data/data-engine.zod.ts`), no longer teaches `{ flowRunId }` alone as an admitted, unscoped context: it carries no principal and is refused like any other. - -Only comments change. No behaviour, export or type moves, and the system bypass and the ADR-0056 D2 deny baseline for a caller that carries a principal and resolves no permission set read as before. The corrected text ships in the published `.d.ts`. - -What changes for you: nothing at runtime. If your code read the old text and passes the engine a context that carries neither the caller's principal nor `isSystem: true`, it is refused; pass the caller's own context or, for platform code whose own door already authorized the caller, the explicit system opt-in `{ isSystem: true }`. diff --git a/.changeset/pre/22305-cascade-sibling-restrict.md b/.changeset/pre/22305-cascade-sibling-restrict.md deleted file mode 100644 index 13fac092bd4..00000000000 --- a/.changeset/pre/22305-cascade-sibling-restrict.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -Deleting a record no longer fails because of a record that the same delete removes. Before this fix, when a record's delete cascaded to two kinds of children, and one child held a required lookup to the other, the delete could answer `409 DELETE_RESTRICTED` naming the child that holds the lookup. Whether it did depended on which object was registered first. A measured example: an account cascades to its contacts and to its contracts, and each contract has a required lookup to a contact. With the contacts registered first, deleting the account was refused, naming a contract that the same delete was about to remove. With the contracts registered first, the same delete succeeded. - -Clause-②: no - -A by-id cascade delete now collects, before any refusal is judged, every record it will delete across its cascading relations, the root record included. A `restrict` raised by a record in that set against another record in the set is no longer a refusal. That covers an authored `deleteBehavior: 'restrict'` and a required lookup whose `set_null` cannot clear it. A `set_null` writes nothing to a record in the set, so that record's hooks and audit rows show the delete without an earlier update. A refusal from a record outside the set is unchanged. It still answers `409 DELETE_RESTRICTED` and names the dependent object, and on a single datasource the whole delete still rolls back. The refusal now counts only rows outside the set. - -The cascade also no longer re-enters a record it is already deleting. A cycle in the data, such as two rows that cascade to each other, now deletes both rows. Before, the walk re-entered the same rows without end. A row that two cascade paths reach is deleted once, instead of the second path answering `404`. - -The cost is one more read for each cascading relation of each record the cascade deletes: the set is collected by a read-only walk before the existing walk deletes. No key, export or error code changes. diff --git a/.changeset/pre/22306-insert-readonly-withheld-from-before-insert.md b/.changeset/pre/22306-insert-readonly-withheld-from-before-insert.md deleted file mode 100644 index 90f5f195189..00000000000 --- a/.changeset/pre/22306-insert-readonly-withheld-from-before-insert.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -A `beforeInsert` hook is no longer shown a caller-supplied value for a `readonly` field, so a hook that stamps an absent read-only column stamps it even when the caller sent one. - -Clause-②: no (narrowing) - - - -**BREAKING** — what a `beforeInsert` handler reads on `ctx.input.data` changes on a non-system create. This ships as `minor` under the launch-window convention for breaking changes. - -- **What it reads now.** A caller-supplied value for a field the create-side static `readonly` strip takes is not on `ctx.input.data`. The field holds its `defaultValue`, or there is no key, which is exactly what the hook sees when the caller did not send the field. -- **Which fields.** The withheld set is the create strip's own subject (`staticReadonlyInsertSubject`): author-declared `readonly: true` fields that are not runtime-owned, on an object outside the `sys_` namespace and the platform-internal `managedBy` buckets. It has two edges: - - a runtime-owned `autonumber` value stays visible to the hook; - - under `preserveAudit`, the audit timeline (`created_at`, `created_by`, `updated_at`, `updated_by`) stays visible, because the historical-import channel reinstates it through a `beforeInsert` hook. -- **Two answers at the REST door change.** - - A hook that self-assigns a withheld field with no `defaultValue` (`data.x = data.x`) used to turn the caller's forged value into a hook write, which was stored. Under `strictReadonlyWrites` that create now answers 400 where it answered 201. - - A hook or sandboxed `body` that reaches through a withheld field (`ctx.input.meta.who = …`) now faults, and the create answers 400 naming the field where it answered 201. -- A `system`-context create (`isSystem`) is unchanged: the strip does not apply to it, so nothing is withheld. - -## The defect - -On a non-system create, the engine took a caller's value for a static `readonly` field out of the row only after `beforeInsert` had run. A hook that stamps such a field only when it is absent (`if (!data.stage_entry_date) data.stage_entry_date = today`) saw the caller's value and did nothing, and the strip then dropped that value. The row stored NULL where the hook would have stamped today. Measured on a real engine over the SQL driver: with the key in the payload, the row stored `stage_entry_date` and `days_in_stage` NULL; without it, the hook stamped both. This is the insert-side twin of the `beforeUpdate` rule: a hook is never handed a value that will not be stored. - -## What did not move - -- **The strip still runs after the hooks.** The caller's values are handed back to it there, wherever no hook wrote the key. So `onFieldsDropped`, the read-only WARN and `strictReadonlyWrites` judge the same payload as before. -- **A hook that assigns a read-only field still has its value stored.** -- **A self-assignment (`data.x = data.x`) of a withheld field** follows the engine's rule: - - with no `defaultValue` the hook reads `undefined`, the write is undone, and the caller's value is stripped and reported. `undefined` is never stored. - - with a `defaultValue` the hook reads and re-assigns the default, which counts as the hook's write. The default is stored, and nothing is reported. -- **Faults are explained.** A hook that faults reaching through a withheld field is answered 400 naming the field, the way `beforeUpdate` already answers. - -## Who is affected - -A `beforeInsert` handler that reads a `readonly` field from `ctx.input.data` on a non-system create. - -- **A handler that derives a value from the field:** the value it used to read was never stored, so it should derive nothing from it. -- **A handler that owns the field:** it assigns the field. -- **A handler that reports what the caller sent:** a create has no `ctx.submitted` to read it from. diff --git a/.changeset/pre/22307-cold-boot-catalog-refusal.md b/.changeset/pre/22307-cold-boot-catalog-refusal.md deleted file mode 100644 index 370b80256f4..00000000000 --- a/.changeset/pre/22307-cold-boot-catalog-refusal.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/objectql': major ---- - -feat(objectql)!: a cold boot refuses a package-held position or permission-set name the environment catalog already holds, as a hot install does - -Clause-②: no - - - -**BREAKING** — an accept-set narrowing at boot, shipped as `major` on the v18 pre-release line (`.changeset/pre.json` is in `next` pre mode on `main`). A deployment whose environment catalog holds a position or permission-set name that a configured package also declares booted before this release and is refused at boot after it. - -**Why.** Positions, permission sets and capabilities hold one name per deployment, and a package registering a name the environment catalog already holds was already refused on a hot install. A cold boot did not refuse it: every package registers in the kernel's first phase, before the environment catalog loads from `sys_metadata` in the engine plugin's `start()`, so the package door could not see the environment's name. The stored row loaded over the package's definition, the registry printed a `[Registry] Collision` warning, and the by-name read served the environment's definition in place of the package's. The maintainer ruled that the cold boot refuses too, so that a cold boot, a hot install and an artifact boot answer alike (ADR-0048 addendum N.3). - -**What is refused, and where.** Right after `sys_metadata` hydration in `ObjectQLPlugin.start()`, before any plugin that depends on the engine starts, the engine checks every package-held position and permission-set name against the environment catalog's items. One such name fails the boot. Every conflict is listed in one refusal. The check reads the two types an environment can author: the runtime metadata API refuses to create a capability (`403`, a code-only type), so the environment catalog holds none. The hydration write itself is still not judged. - -**What an operator sees.** The kernel reports `Plugin com.objectstack.engine.objectql failed to start`, and the cause is the package door's envelope: `code: 'NAMESPACE_CONFLICT'` (`NAMESPACE_CONFLICT_CODE` is exported), `status: 422`, and `conflicts[]` with `{ catalogType, name, incomingPackageId, existingHolder: { kind: 'environment' } }`. The message names the package that declares each name and the environment catalog that holds it. - -**The upgrade shape.** A deployment fails to boot after this release when an active, environment-wide `sys_metadata` row of type `permission` or `position` (or the legacy plural `permissions` / `positions`, which the boot's load folds to the same types) has the name of a permission set or position that a configured package declares. That includes a row saved over a package-held name before the packaged locks refused such saves, whether or not the row was bound to the package, and a row over one of the platform security plugin's own permission sets (`member_default`, `admin_full_access` and the rest it declares). **Run `os migrate security-catalog-overlays` before the first v18 boot**, with the flags and environment the deployment boots with: it lists exactly these rows, and with `--apply` deletes them. - -**The one-line fix: before the first v18 boot, run `os migrate security-catalog-overlays` to list the rows, then `os migrate security-catalog-overlays --apply` to delete them; or rename the item in the package.** - -- **Before upgrading, for a permission set.** On the release you run now, a boot whose environment catalog overlays a package-declared permission set logs at `kernel:ready`: `[security] N package-declared permission set(s) are being shadowed by an environment overlay`, with the set names. Those are the permission sets this release refuses at boot. The audited **Discard Overlay** action on the set's record in Setup (`POST /api/v1/security/permission-sets//discard-overlay`, documented under "Declared ≠ enforced" on the Permission Sets page) removes the overlay and resyncs the set to the package's definition. So does `DELETE /api/v1/meta/permission/`, which answers "Customization overlay deleted … reset to artifact default". Either works for the platform security plugin's own sets too, and neither needs direct database access. Positions have no such reading and no such action. -- **After upgrading, for any refused name.** The same step: `os migrate security-catalog-overlays`, then `--apply`. It needs no server, so a deployment whose boot is refused can run it, and it covers permission sets and positions, a name the platform security plugin declares, and a row stored under a legacy plural. -- **What the step deletes.** The rows that refuse the boot are the active, environment-wide ones of a package-held name: `organization_id IS NULL` and `state = 'active'`, whatever their `package_id`, under the type or its legacy plural. In SQL, for one permission-set name, the step's deletion is `DELETE FROM sys_metadata WHERE organization_id IS NULL AND state = 'active' AND type IN ('permission', 'permissions') AND name = '';` (for a position, `type IN ('position', 'positions')`). The step runs it through the metadata write path, so each deleted row leaves a `sys_metadata_history` tombstone. A draft row and an organization-scoped row are not loaded at boot and do not refuse it. - -`DELETE /api/v1/meta/permission/` and `DELETE /api/v1/meta/position/` reach a row stored under `permission` or `position` only, one row per call. A row stored under the legacy plural `permissions` / `positions` is not reached: the call answers `200` that nothing was found and removes nothing. Where a name has two active rows, for example one bound to no package and one bound to the package, each call removes one. A plural-typed row is removed by Discard Overlay before upgrading (a permission set), or by `os migrate security-catalog-overlays --apply`, for any name. - -`os migrate security-catalog-overlays` is the one `os` command that deletes these rows with no server running; `os meta delete` and `os data delete` call a running server. Nothing renames or removes either item automatically: the step deletes only under `--apply`, and adopts nothing. - -**What is NOT refused.** A stored definition under a built-in position name (`org_admin`, `everyone` and the other four): the platform declares its built-in positions itself, after this check, and the stored definition keeps answering first. The same package restarting with its own names. A package whose names the environment catalog does not hold, booting beside the environment's own items. diff --git a/.changeset/pre/22310-elevated-flow-trigger-door.md b/.changeset/pre/22310-elevated-flow-trigger-door.md deleted file mode 100644 index 4c4a6a637c5..00000000000 --- a/.changeset/pre/22310-elevated-flow-trigger-door.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): the trigger door refuses a self-triggered flow declared to run as system to any caller but the system principal - -Clause-②: no - -A self-triggered flow declared to run as system could be started by any signed-in member through the trigger door. `POST /api/v1/automation/:name/trigger` (and the legacy `POST /api/v1/automation/trigger/:name`, and `@objectstack/verify`'s `flows.run`, which answer through the same function) asked only whether the caller was anonymous, so a flow meant to run on its own trigger, or as a sub-flow, also ran elevated for anyone who named it. - -**What changes.** A caller that is not the system principal now gets `403 PERMISSION_DENIED` (ADR-0112 envelope) when it starts a flow declared `runAs: 'system'` whose `type` is `autolaunched`, `record_change` or `schedule`. Nothing runs: no run record, no node, no side effect. The refusal names no part of the flow. This includes a platform admin's session, which is a signed-in user, not the system principal. - -**What stays as it was.** - -- `screen` and `api` flows, elevated or not: doors the author designed (ADR-0073 D2). -- Every flow that does not declare `runAs: 'system'`. -- A parent flow's `subflow` node calling an elevated flow: the child starts through the engine, never through the door. -- The system principal (an in-process caller, such as a job), which starts every flow. - -**If a call now answers 403.** That flow runs on its own trigger. To start its work on a user's request, call it from a parent flow's `subflow` node, or, if it is meant to be a door, declare it `type: 'screen'` (or `type: 'api'` for a signed inbound hook) so the elevation is a reviewable choice. diff --git a/.changeset/pre/22313-chunked-completion-assembles-held-parts.md b/.changeset/pre/22313-chunked-completion-assembles-held-parts.md deleted file mode 100644 index c110035f58c..00000000000 --- a/.changeset/pre/22313-chunked-completion-assembles-held-parts.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/service-storage': minor ---- - -fix(service-storage)!: a chunked upload completes with the file it was sent — the completion door assembles the parts the upload holds, and a re-sent chunk is counted once - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -**What was wrong.** `POST /storage/upload/chunked/:uploadId/complete` assembled the parts the *request* listed. The SDK's `resumeUpload` lists only the chunks its own pass sent, so an upload whose first chunks were stored before an interruption completed with `200` and the declared `size`, while the stored file held only the resumed chunks. And `PUT …/chunk/:chunkIndex` added a re-sent chunk to the session's progress a second time, so `GET …/progress` overstated `uploadedSize`, `uploadedChunks` and `percentComplete` — the counts `resumeUpload` resumes from. - -**What the completion door does now.** It assembles the upload's own record of the chunks it holds — every chunk the chunk door stored, with the eTag the backend answered for it — and checks the request's `parts` against that record. A resumed upload therefore completes with the whole file, whatever subset of parts the client lists, and the SDK's `resumeUpload` needs no change. - -**What stops being accepted.** The door refuses with `409` and the standard code `RESOURCE_CONFLICT`, before anything is assembled, when: - -- the upload does not hold every chunk it declared (chunk indexes `0` to `totalChunks - 1`), or holds one beyond them; -- the chunks it holds do not add up to the declared `totalSize`; -- the request lists a chunk the upload does not hold, or lists one with an eTag other than the one the upload holds for it. - -`error.details` carries `missingChunks`, `unexpectedChunks`, `unheldListedChunks` and `mismatchedChunks` (zero-based chunk indexes), with `totalChunks`, `totalSize` and `heldBytes`. The session stays `in_progress` and the file stays `pending`: upload the missing chunks with `PUT …/chunk/:chunkIndex`, then complete again. A `parts` value that is not a list of `{ chunkIndex, eTag }` is refused `400 INVALID_REQUEST`; an absent `parts` is still an empty list. - -**What changes for you.** A client that completed an upload before sending all of its bytes — including a completion with no chunks at all, which used to commit an empty file — now receives the `409` above. Send every chunk first. - -**Progress.** A chunk index sent again replaces its slot, as it replaces its bytes in the backend: `uploadedChunks` counts distinct chunks and `uploadedSize` is the sum of each chunk's latest size. A session started before chunk sizes were recorded keeps its running byte total until it ends, and is completed only when that total reaches `totalSize`. diff --git a/.changeset/pre/22314-storage-payload-too-large-provenance.md b/.changeset/pre/22314-storage-payload-too-large-provenance.md deleted file mode 100644 index ab220c4967f..00000000000 --- a/.changeset/pre/22314-storage-payload-too-large-provenance.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): the error-code ledger lists `PAYLOAD_TOO_LARGE` under `@objectstack/service-storage` - -Clause-②: yes (widening: a new owner provenance row in the published error-code ledger) - -`ERROR_CODE_LEDGER['@objectstack/service-storage']` now lists `PAYLOAD_TOO_LARGE` (`413`), the code `@objectstack/rest` already registers for its import row ceilings. The storage upload doors answer it for an upload over the File Storage settings' `max_upload_mb`: the presigned upload's declared `size`, the chunked upload's declared `totalSize`, the local raw PUT body and each chunk's running total. - -No code is added. `ErrorCode`, `RegisteredErrorCode` and `REGISTERED_ERROR_CODES` are unchanged, so `ApiErrorSchema` accepts exactly what it accepted before. What widens is the per-package list a reader consults to learn which packages answer a code: a code emitted by several packages is listed under each of them. diff --git a/.changeset/pre/22318-audit-plugin-host-locale.md b/.changeset/pre/22318-audit-plugin-host-locale.md deleted file mode 100644 index 4ee756119cf..00000000000 --- a/.changeset/pre/22318-audit-plugin-host-locale.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/plugin-audit": minor ---- - -`AuditPluginOptions` gains `getLocale`, an optional host locale resolver asked before the settings-derived locale, so a multi-organization host can write each organization's activity summaries in its language - -Clause-②: yes (widening) - -- **The option.** `new AuditPlugin({ getLocale })`, where `getLocale` is `(tenantId?: string, userId?: string) => string | undefined | Promise`. It picks the language of `sys_activity.summary` and of the @mention notification title the plugin emits, and is called with the write's organization and user (for an @mention title, the mentioned recipient). -- **Precedence: host first, settings second.** An answer that is a well-formed BCP-47 tag is used, in its canonical form (`zh-cn` → `zh-CN`). `undefined`, `null` or a blank string falls back to the settings-derived `localization.locale` (ADR-0053) the plugin used before this option. So do a malformed answer (`zh_CN`, a non-string) and a throw or rejected promise; each of those two faults is logged once at `warn`, and neither fails the audited write. There is no other locale source. -- **Without the option, nothing changes.** The settings-derived locale stays the only source, as before. -- The locale only picks the message catalog, and a summary is rendered once, when its row is written. Rows already written keep their text. diff --git a/.changeset/pre/22328-auth-seeded-hook-registered-at-ready.md b/.changeset/pre/22328-auth-seeded-hook-registered-at-ready.md deleted file mode 100644 index 1dd7c6f4d5a..00000000000 --- a/.changeset/pre/22328-auth-seeded-hook-registered-at-ready.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -The auth plugin registers its `app:seeded` membership-backfill handler when its backfill is armed, not when the plugin starts - -Clause-②: no - -The one-time membership backfill (ADR-0093 D6) re-runs on `app:seeded`, so users written by a seed that settles after `kernel:ready` still get a membership. Its handler used to be registered in `start()` and kept from acting early by a runtime flag: until the backfill's own `kernel:ready` hook armed it, the handler returned without doing anything. The handler is now registered by that `kernel:ready` hook, at the moment it arms the backfill, so it does not exist before the settings engine binds. The boot-ordering gate reads this from the source, which it cannot do with a flag. - -- **What the backfill does is unchanged.** An `app:seeded` that fires before the backfill is armed still does nothing, and one that fires after it still re-runs the pass. -- **No option, setting or export changes.** `OS_SKIP_MEMBERSHIP_BACKFILL=1` still registers no `app:seeded` handler at all. diff --git a/.changeset/pre/22332-chunk-part-record-compare-and-set.md b/.changeset/pre/22332-chunk-part-record-compare-and-set.md deleted file mode 100644 index 644fabdfa5c..00000000000 --- a/.changeset/pre/22332-chunk-part-record-compare-and-set.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/service-storage': patch ---- - -fix(service-storage): chunks sent in parallel to one chunked upload each record their part - -Clause-②: no - -`PUT /storage/upload/chunked/:uploadId/chunk/:chunkIndex` merges its chunk into the upload session's record of the chunks it holds (`parts`, `uploaded_chunks`, `uploaded_size` on `sys_upload_session`). It read that record, merged in memory and wrote the whole record back, so two chunk PUTs to one upload at the same time both answered `200` while the record kept only one of them: `GET …/progress` undercounted, and the completion was refused `409 RESOURCE_CONFLICT` naming the chunk the record had lost until the client sent it again. - -The record is now written with a compare-and-set: the write lands only while the row still holds the progress the chunk door read, and when another chunk's write landed first the door reads the record again and merges again. On a wired data engine this is the engine's own conditional update, evaluated in the same statement that writes, so it holds across server processes. Every chunk sent in parallel is recorded, and a parallel upload completes on its first completion. A sequential upload is unchanged. - -A chunk whose record write loses to another write on 16 attempts in a row is refused `409 RESOURCE_CONFLICT`, with `error.details` `{ chunkIndex, attempts }`: its bytes are stored, but the upload does not hold it. Send that chunk again. diff --git a/.changeset/pre/22335-graceful-shutdown-false-no-exit.md b/.changeset/pre/22335-graceful-shutdown-false-no-exit.md deleted file mode 100644 index 23f77f80535..00000000000 --- a/.changeset/pre/22335-graceful-shutdown-false-no-exit.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/core': minor ---- - -fix(core)!: a `gracefulShutdown: false` kernel no longer exits the process when its teardown times out - -Clause-②: no - - - -**BREAKING** for a host that builds `ObjectKernel` with `gracefulShutdown: false` and counted on the kernel to end the process when a teardown hung: a behaviour change, shipped as `minor` under the launch-window convention for breaking changes. - -- **What changes.** When `shutdown()` overruns `shutdownTimeout`, a kernel built with `gracefulShutdown: false` no longer calls `process.exit(1)`. It logs the timeout at `error`, marks itself `stopped`, and `shutdown()` resolves, so the host decides whether the process exits. The hung teardown is not cancelled: it keeps running in the background and holds whatever it has not released until it finishes or the process ends. -- **Why.** `gracefulShutdown: false` already meant that the host, not the kernel, handles the process signals, and the kernel lifecycle docs say such a kernel stays out of the process-management business. The timeout branch did not read the option. So a host that runs several `false` kernels in one process, one per environment, lost all of them when one environment's teardown hung. -- **Unchanged.** With `gracefulShutdown: true` (the default; the kernel installs its own SIGINT, SIGTERM and SIGQUIT listeners) a genuine timeout still logs `Shutdown timed out — forcing exit` and calls `process.exit(1)`. A teardown that throws instead of hanging still never exits the process, under either setting. `shutdown()` still never rejects, and no option, key or export is added. Every kernel `@objectstack/cli` builds takes the default, so its commands behave as before. -- **Who must act.** A host that sets `gracefulShutdown: false` (a server framework with its own signal handlers, or a host running one kernel per environment in one process) and relied on that exit to end a process whose teardown hung. Such a host already handles the signals itself; it now also ends the process itself. Call `process.exit()` in that host once `await kernel.shutdown()` returns, instead of waiting for the event loop to drain: `shutdown()` returns within `shutdownTimeout` whether or not the teardown finished, but a hung teardown can keep the loop alive after it. The timeout is reported in the kernel's `error` log line. diff --git a/.changeset/pre/22340-registry-allow-environment-overlay.md b/.changeset/pre/22340-registry-allow-environment-overlay.md deleted file mode 100644 index eb150c25bcb..00000000000 --- a/.changeset/pre/22340-registry-allow-environment-overlay.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': major -'@objectstack/metadata-protocol': major ---- - -feat(spec,metadata-protocol)!: the metadata type registry's `allowOrgOverride` is renamed to `allowEnvironmentOverlay`, on the registry and on `GET /meta/types` - -Clause-②: yes (narrowing) - - - -**BREAKING**: a renamed authorable key and a renamed response field, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** ADR-0131 D6 retired the per-organization overlay axis: every metadata write and read is environment-wide. The registry flag kept the name that said "per-org" while it decided one environment-scope question at every door — whether an environment may overlay an item of the type that a managed package ships: the metadata protocol's save door, the managed-content seal on write and on the served read, the repository's put gate, and Studio's write gate through `GET /meta/types`. It is renamed to say that. One key, not two, and not the old name with a new meaning; no window in which both spellings are accepted or served. - -## FROM → TO - -| where | you wrote / read | write / read instead | -|:--|:--|:--| -| a `DEFAULT_METADATA_TYPE_REGISTRY` entry (`MetadataTypeRegistryEntry`, `@objectstack/spec/kernel`) | `allowOrgOverride: true` | `allowEnvironmentOverlay: true` | -| a reader of a registry entry | `entry.allowOrgOverride` | `entry.allowEnvironmentOverlay` | -| each `entries[]` item of `GET /api/v1/meta/types` (`GetMetaTypesResponse`) | `allowOrgOverride` | `allowEnvironmentOverlay` | - -**The one-line fix: rename the key, and every read of it; the value is unchanged.** - -Every value is unchanged: `true` on `view`, `dashboard`, `report`, `translation` and `email_template`, `false` on every other built-in type. On the wire the value is still the registry's grant OR the `OS_METADATA_WRITABLE` hatch naming the type (`overrideSource: 'env'`), and the hatch still opens only items no managed package ships, never a managed one. `overrideSource` keeps its `registry` / `env` values; its describe now names the new field. - -⚠️ **Read the old key nowhere.** Writing `allowOrgOverride` fails to compile (its input type is `never`) and fails to parse with the rename prescription (issue code `invalid_type` at path `allowOrgOverride`). A READER does not fail: `if (!entry.allowOrgOverride)`, a filter on it or a partial-match assertion still compiles against the tombstone and reads `undefined`, which treats every type as closed to environment overlays. A client that declares its own type for the `/meta/types` response, instead of importing `GetMetaTypesResponse`, sees no compile error at all and reads every type as locked. Search for the old name, do not wait for `tsc`. - -**`supportsOverlay` keeps its name; its describes now say what it gates.** It decides one thing: a stored overlay row of an item a managed package ships may be removed through the ordinary delete door without the type's environment-overlay permission: the removal removes no managed definition, and changes what is served for `permission` alone. It opens no save and it decides no read. The registry's describe, the `GET /meta/types` describe and `MetadataTypeInfoResponse`'s describe used to promise that "the loader can merge overlays", in the retired per-organization wording. - -**Who is affected, measured.** No manifest, stack, plugin configuration or stored row can carry a registry entry (`contributes.kinds` is closed, `config.additionalTypes` is a tombstone, the registry is a TypeScript literal), so no authored metadata changes. The readers are code: every reader in this repository moved in this change, and a reader of the `/meta/types` field outside it moves with it — the Studio console reads the field and moves in its own repository's paired change, which the console pin carries. - -### The kit - -- **The tombstone.** `MetadataTypeRegistryEntry.allowOrgOverride` is a `retiredKey()` naming `allowEnvironmentOverlay` (`kernel/metadata-plugin.zod.ts`); the 28 built-in rows spell the new key. -- **The ledger.** The retired-keys entry `kernel/MetadataTypeRegistryEntry:allowOrgOverride` and the D3 semantic entry `metadata-type-registry-allow-org-override-renamed` (protocol 18), which records the registry key and the wire field together. No D2 conversion: there is no authored or stored source a conversion could rewrite. -- **The wire.** `getMetaTypes` writes the hatch elevation onto `allowEnvironmentOverlay` on both of its arms — a registry type and a runtime-registered type with no registry row — and no entry carries `allowOrgOverride`. -- **The refusal sentences** that named the flag name the new key: `… takes no environment overlay (allowEnvironmentOverlay=false) …`, `… has neither allowEnvironmentOverlay nor allowRuntimeCreate …`, `… allowRuntimeCreate=false and allowEnvironmentOverlay=false …`. Codes and statuses are unchanged. diff --git a/.changeset/pre/22340-s3-governed-text-rename.md b/.changeset/pre/22340-s3-governed-text-rename.md deleted file mode 100644 index 8bc14022cd3..00000000000 --- a/.changeset/pre/22340-s3-governed-text-rename.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -docs(skills): the `objectstack-ai` skill names the `agent` type's environment-overlay switch by its protocol-18 spelling, `allowEnvironmentOverlay:false` (#22340 S3) - -Clause-②: no - -`skills/objectstack-ai/SKILL.md`'s "Platform agents — reference only" section said the `agent` type is closed with `allowOrgOverride:false`. That key was renamed to `allowEnvironmentOverlay` in protocol 18 (ADR-0131 D6 retired the per-organization overlay axis, so the key names the one scope it governs; the old key stays on the registry entry schema only as a tombstone that refuses at parse with the new name in its prescription), so the published line now reads `allowEnvironmentOverlay:false`. The value, the `allowRuntimeCreate:false` beside it and the ADR-0063 §2 citation are unchanged; nothing an author writes changes. The same rename is recorded in `AGENTS.md` rule 7 and as header amendment notes on ADR-0005 and ADR-0010, none of which publishes. diff --git a/.changeset/pre/22343-retry-policy-try-catch-undeclared-keys-refused.md b/.changeset/pre/22343-retry-policy-try-catch-undeclared-keys-refused.md deleted file mode 100644 index 788dcc932e4..00000000000 --- a/.changeset/pre/22343-retry-policy-try-catch-undeclared-keys-refused.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -The shared retry policy refuses a key it does not declare, and the build doors now judge a `try_catch` node's config keys like every other builtin's: a `retry.maxRetry` slip under a `try_catch` node, or on a job's `retryPolicy`, no longer passes `objectstack validate` and `objectstack compile` to be dropped or refused later. - -Clause-②: no (narrowing: an undeclared key on the shared retry policy is refused at parse wherever it is written, a job's retryPolicy and a try_catch node's retry, and an undeclared try_catch config key at the build doors and the save door, where each passed) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. - -**Why.** `RetryPolicySchema` — the one declaration behind `job.retryPolicy` and a `try_catch` node's `retry` — was a plain object schema, so it dropped a key it did not declare. Its defaults are opt-in (`maxRetries: 0`, `backoffMultiplier: 1`), so a dropped key fell back to "no retry" or to a flat delay: a job with `retryPolicy: { maxRetry: 3 }` parsed, deployed and never retried, and nothing said so. On a `try_catch` node the same strip kept the flow parse from judging the node's keys at all: its node type descriptor closes `retry` to five keys, so only `registerFlow`'s descriptor walk refused such a key, after `objectstack validate` and `objectstack compile` had passed it. - -**What is refused.** - -- **A key the retry policy does not declare**, on a job's `retryPolicy` or under a `try_catch` node's `retry`. The policy declares `maxRetries`, `backoffMs`, `backoffMultiplier`, `maxRetryDelayMs` and `jitter`. The refusal names the key and, for a near miss or a spelling borrowed from a neighbouring retry vocabulary, the declared key to write (`maxRetry` → `maxRetries`, `maxDelayMs` → `maxRetryDelayMs`, `baseDelayMs` or `initialDelayMs` → `backoffMs`, `retries` → `maxRetries`). A `maxAttempts` is answered with the off-by-one it carries: write `maxRetries` one lower. On a job it is the issue `unrecognized_keys` at `jobs.N.retryPolicy` (`defineStack` → `STACK_SCHEMA_INVALID`, 422; an artifact carrying it is refused whole at load). -- **A key a `try_catch` node's executor contract does not declare** — beside `try`, `catch`, `errorVariable` and `retry`, or under `retry` — at every door that parses a flow: `FlowSchema`, `defineFlow()`, `defineStack`, `objectstack validate`, `objectstack compile`, an artifact's parse, the metadata save door and `registerFlow`. It is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType: 'try_catch', key }`, anchored at the key (`nodes.N.config.retry.maxRetry`), in the contract's words and closed with the rename-or-remove remedy, from the same judge (`flowNodeConfigRefusals`) that already refused an undeclared key on every other builtin. `builtinNodeConfigKeysJudged('try_catch')` now answers `true`. -- **A `retryDelayMs` the load-path conversion leaves behind** under a `try_catch` node's `retry`: the conversion renames the pre-17 spelling to `backoffMs`, but keeps it when a `backoffMs` beside it holds a different value, and leaves a `null`. That copy is refused at `nodes.N.config.retry.retryDelayMs` in the tombstone's own words (rename it to `backoffMs`). Registration refused both before, as undeclared keys. - -**What stays as it was.** - -- Registration refuses exactly what it refused before. The descriptor walk (`validateNodeConfigKeys` in `@objectstack/service-automation`) stands aside for `try_catch` and now judges plugin node types only. A `try_catch` flow that carried such a key was already refused there, so a running flow changes nothing; the refusal now arrives as the parse's located issue instead of the walk's `Flow '…' rejected: N undeclared config key(s)` text. -- A key on a `try_catch` region object (`try`, `catch`), or on a region's nodes and edges: the region check's, at registration, as before. -- A pre-17 `retryDelayMs` alone: still renamed to `backoffMs` before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`, the save door). Met by a direct `FlowSchema.parse` or `defineFlow()`, it meets its tombstone, like every other retired spelling. -- `Flow.errorHandling`, which carries the same policy keys, was already closed; a `script` node's retired keys keep the scope they had. - -**Also corrected:** `Flow.errorHandling`'s refusal of a `maxAttempts` key said a bare rename to `maxRetries` would run one attempt fewer than asked for; it runs one more (`maxAttempts: 3` is 3 runs, `maxRetries: 3` is 4), and the message now says so — the prescription, `maxRetries` one lower, is unchanged. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `maxRetry`, `retries` or `attempts` | `maxRetries` | -| `maxAttempts: n` (counts the first attempt) | `maxRetries: n - 1` | -| `baseDelayMs` or `initialDelayMs` | `backoffMs` | -| `maxDelayMs` | `maxRetryDelayMs` | -| `retryDelayMs` beside a `backoffMs` | delete it, keeping the `backoffMs` you meant | -| a key nothing reads | delete it | - -**The one-line fix: rename or delete the key the refusal names.** - -**Who is affected, measured.** Before closing the policy, every writer of either parser was read: in this repository the showcase job and flows, the `app-todo` flows, the docs, the service READMEs, the test fixtures and the load-path conversion's output; in the pinned objectui the job preview sample and the flow designer, whose descriptor form closes `retry` to the same five keys. All of them write only declared keys, and `objectstack validate` over the showcase and `app-todo` examples stays clean. `service-job`'s `runWithPolicy` reads an already-parsed policy and parses nothing. hotcrm, deployed metadata and other repositories were not measured. - -### The kit - -- **The refusal.** `RetryPolicySchema` (`shared/retry-policy.zod.ts`) is a `strictObject` with a curated near-miss table; the key arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts` judges every builtin in the executor contract map, and refuses a tombstoned key the author wrote on every judged type but `script`. No new code joins `FLOW_SLOT_REFUSAL_CODES`. -- **The ledger.** The D3 semantic entry `try-catch-and-retry-policy-undeclared-keys-refused` (protocol 18). No key is removed, so there is no new tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. diff --git a/.changeset/pre/22344-get-record-id-projection.md b/.changeset/pre/22344-get-record-id-projection.md deleted file mode 100644 index 799dc3f49b4..00000000000 --- a/.changeset/pre/22344-get-record-id-projection.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -fix(service-automation): a `get_record` whose `fields` leave out `id` answers the row's id as its `id` output on the SQL drivers - -Clause-②: no - -- **What was wrong.** On its single-row branch (no `limit` above 1) a `get_record` node answers `output: { record, id, object }`, and a later node reads that `id` as `{read.id}` (for a node whose id is `read`). The node handed the author's `fields` to the engine unchanged, and the SQL drivers return exactly the columns a projection names. So a `get_record` with `fields: ['title']` read a row with no `id` column, and `{read.id}` was empty with no error. A later `update_record` filtering on it was refused for a filter condition that resolved to nothing, and any other reference read nothing. -- **What it does now.** That branch names `id` in the projection it hands the engine, so `{read.id}` is the row's id whatever `fields` names, on every driver. The record the node serves, and binds to its `outputVariable`, carries that `id` beside the columns the author named, so the variable's `id` reads the same value. The stored-metadata tables (`sys_metadata`, `sys_metadata_history`) are read the same way: the served row is the named columns plus `id`. -- **Unchanged.** A `get_record` with no `fields`, or an empty list, still reads the whole row. A projection that already names `id` is handed over as written. The multi-row branch (`limit` above 1) answers `records` only and is untouched. diff --git a/.changeset/pre/22345-run-provenance-context-retired.md b/.changeset/pre/22345-run-provenance-context-retired.md deleted file mode 100644 index f39500a97cd..00000000000 --- a/.changeset/pre/22345-run-provenance-context-retired.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/service-automation': minor -'@objectstack/plugin-security': patch -'@objectstack/runtime': patch -'@objectstack/service-analytics': patch -'@objectstack/platform-objects': patch ---- - -fix(service-automation)!: retire the `RunProvenanceContext` type; `RunDataContext` names only what `resolveRunDataContext` returns - -Clause-②: no (narrowing) - - - -**BREAKING** for TypeScript code that imports from `@objectstack/service-automation`: one type export is removed and one type narrows. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes in any package. - -**What is removed.** The `RunProvenanceContext` type export, the `{ flowRunId }`-only "provenance" envelope, and its arm of `RunDataContext`. No code in this repository builds or reads one. Since ADR-0096 D5, the data engine's security middleware refuses a context that carries no principal and is not a system context (`403 PERMISSION_DENIED`, for every verb). So the type described a context the engine refuses, while its docstring said such a context was "indistinguishable from passing no context at all" and was let through. - -**What `RunDataContext` is now.** Exactly what `resolveRunDataContext` returns: `isSystem: true` for a `runAs: 'system'` run, and the triggering user's principal for a `runAs: 'user'` run. A run that resolves no principal is refused before any data operation (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`), as before. - -**If your code named the type.** There is no replacement, because the context it described is refused. A custom data node builds its context with `resolveRunDataContext(context)`, typed `RunDataContext`. - -**`@objectstack/plugin-security`, `@objectstack/runtime`, `@objectstack/service-analytics`, `@objectstack/platform-objects`:** comment and docstring text only. Shipped comments that said the security middleware hands through, skips or falls open for a context with no principal now say that it used to, and that ADR-0096 D5 refuses such a context. Nothing these packages do changes. diff --git a/.changeset/pre/22360-declared-position-package-provenance.md b/.changeset/pre/22360-declared-position-package-provenance.md deleted file mode 100644 index 805428cbae7..00000000000 --- a/.changeset/pre/22360-declared-position-package-provenance.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -fix(plugin-security)!: a definition edit or delete of a position a code package declares is refused at the data door, as the metadata door already refuses it; its row now carries package provenance, and its row state stays switchable - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing of the `sys_position` data door, shipped as `minor` under the launch-window convention for breaking changes. It carries ADR-0131 D6 (no door edits a managed definition) and D3 (managed items are read-only and clonable). - -**What was wrong.** The declared-position seeder wrote the row of a package-declared position with no provenance stamp, so the row carried the object default, `managed_by: 'admin'`, and the system-row write gate did not protect it. A Setup or data API edit of its label answered `200`, and the next boot wrote the declared label back over it with no message. The metadata door already refused the same edit with `403 NOT_OVERRIDABLE`. - -**What is refused now.** A position is package-declared when the engine registry's artifact lookup answers a code package for its name, the same lookup the metadata door refuses a save from. On such a position's row, the admin door now answers `403 PERMISSION_DENIED`, with the message it already gives for a built-in position, to: - -- an update that touches a definition column (`name`, `label`, `description`, `delegatable`) or any column other than the two row-state columns below. That covers single-record edits, `updateMany`, and filtered updates. Until now a label or description edit answered `200` and the next boot wrote the declaration back over it; a `delegatable` edit answered `200` and persisted. -- a delete. Until now it answered `200` and the next boot created the row again. The other lifecycle writes (transfer, restore, purge) are refused as well. - -**What stays switchable.** A patch that touches only row state (`active` and/or `is_default`) still passes, so Setup's Activate, Deactivate and Set as Default keep working on a package-declared position and persist across restarts. This is the rule a packaged permission set already follows: switching it off is not an edit of its definition. A filtered update passes on the same terms, as long as its filter reaches no built-in position. - -System-context writes are unchanged: the boot seeder still refreshes the row's label and description from the declaration. - -**What is unchanged.** - -- A position an administrator created in Setup, and a position the environment authored through the metadata door, keep an unmanaged row (`admin`) and stay editable. -- The built-in positions are refused exactly as before, a bare `active` or `is_default` patch included. -- Assigning a position to users and binding permission sets to it are writes on other rows (`sys_user_position`, `sys_position_permission_set`), and this change does not touch them. - -**At the first boot after upgrading.** The existing row of each package-declared position is re-stamped `package` in place when it carries `admin`, no value, or the legacy `user`. Only `managed_by` changes: the `active`, `is_default` and `delegatable` values an administrator set before the upgrade are kept. The boot's `declared positions seeded` info line counts these rows as `restampedPackageProvenance`. The seeder matches a row by name, as its label refresh always has, so a position created in Setup whose name a package declares later becomes that package's position: its definition is refused the same way, and its row state stays switchable. - -**What to do.** To change a package-declared position's label, description or `delegatable`, change it in the package and publish a new version. To have a position you can edit in Setup, clone it under a new name with the Clone Position action, bind its permission sets, and assign the clone. diff --git a/.changeset/pre/22362-unscoped-run-strings.md b/.changeset/pre/22362-unscoped-run-strings.md deleted file mode 100644 index 63c76e179f4..00000000000 --- a/.changeset/pre/22362-unscoped-run-strings.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/service-automation': patch -'@objectstack/objectql': patch ---- - -fix(service-automation, objectql): the `runAs: 'user'` refusals and the run-setup warning say what a principal-less operation meets on both kernels - -Clause-②: no - -Three author-facing texts told a flow or hook author that a `runAs: 'user'` data operation with no trigger user "would execute UNSCOPED (elevated, RLS-bypassing)" if it were not refused. That holds only on a kernel with no security plugin. Since ADR-0096 D5, a kernel with `@objectstack/plugin-security` refuses an operation that carries no principal (`403 PERMISSION_DENIED`). Each text now says both: without a user the operation would carry no principal, refused by the security plugin where one is composed, unscoped where none is. - -- `@objectstack/service-automation`: the `UnscopedRunDataAccessError` message, and the `[runAs]` warning logged at run setup for a flow whose trigger resolved no user. -- `@objectstack/objectql`: the `HookUnscopedDataAccessError` message. - -Only the wording changes. The refusals, their order, their codes (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`, `HOOK_UNSCOPED_DATA_ACCESS` with its `403` status) and their remedy are unchanged: declare `runAs: 'system'` to make the elevation explicit and intended, or arrange for the trigger to supply a user. diff --git a/.changeset/pre/22371-security-catalog-overlays-step.md b/.changeset/pre/22371-security-catalog-overlays-step.md deleted file mode 100644 index 4cdae48695e..00000000000 --- a/.changeset/pre/22371-security-catalog-overlays-step.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/cli": minor -"@objectstack/objectql": minor -"@objectstack/core": minor -"@objectstack/runtime": minor -"@objectstack/spec": minor ---- - -feat(cli): `os migrate security-catalog-overlays` lists, and with `--apply` deletes, the environment-wide rows a v18 cold boot refuses — run it before the first v18 boot - -Clause-②: yes (widening) - -- **What it is for.** A v18 cold boot refuses to start when the environment catalog holds a permission-set or position name that a configured package also declares (ADR-0048 N.3). A deployment upgraded from 17.x can carry such rows: a set or position saved in the environment over a package's name before the packaged locks refused that, or over one of the platform security plugin's own sets (`member_default`, `admin_full_access` and the rest). The server cannot clear them, because it does not start. This step is the offline remedy. -- **What it lists.** Every active, environment-wide `sys_metadata` row (`organization_id IS NULL`, `state = 'active'`) of type `permission` or `position`, the legacy plurals `permissions` and `positions` included, whose name a configured package holds. That is the population the cold boot refuses: the list is computed from the engine's own reading of who holds a name, not from a copy of it. A draft row and an organization-scoped row are not loaded at boot, so they are never listed. Each row is shown with its stored type, its `package_id` binding and the package that holds its name. -- **How it gets there without the refusal.** It composes the deployment as `os serve` does: the host config's plugins, the application or the compiled artifact, and the security plugin behind `serve`'s auth gate. It runs the kernel's first phase for them only, and boots without reading `sys_metadata` back into the registry, so the cold-boot check meets nothing and the boot comes up. No server starts. The security plugin's shipped sets are package-held names only where `serve` composes that plugin, so the step reads the environment `serve` reads. With `OS_AUTH_SECRET` (or `AUTH_SECRET` / `BETTER_AUTH_SECRET`) set, or on a development boot, the plugin is composed and its sets are held, unless the `auth` tier is off. The step takes `os serve`'s `--preset` and `--dev` with `serve`'s meaning, read through the rules `serve` reads them by: `--preset` names the tier preset the auth tier falls back to, and `--dev` composes the config's `devPlugins` and makes the boot a development one (as does `NODE_ENV=development`). The report says which way it went. **Run the step with the flags and environment the deployment boots with.** -- **Preview by default.** The preview boots read-only, like `os migrate plan`. It writes nothing, creates no database file, and exits `1` when it lists a row: the next boot would be refused. `--apply` deletes the listed rows, after a `[y/N]` prompt or `--yes`, and prints one audit line per row: what was deleted, through which write path, or why not. It exits `0` when every listed row is gone. `--json` prints one document (`listed`, `deleted`, `failed`, `rows[]` with `outcome`, `securityPlugin` and `serveFlags`). `--force` gets past a busy SQLite file. `--database-url` / `$OS_DATABASE_URL` name the target. -- **How it deletes.** A row stored under `permission` or `position` goes through the metadata protocol's own delete, the one `DELETE /api/v1/meta/:type/:name` uses. That writes a `sys_metadata_history` tombstone and a `sys_metadata_audit` row, with actor `os migrate security-catalog-overlays`. A row stored under a legacy plural is out of that door's reach, so it goes through the `sys_metadata` repository beneath it. That delete is addressed by the row's stored type, name, `package_id` and checksum, and writes the same history tombstone. -- **The refusal names it.** The cold boot's `NAMESPACE_CONFLICT` message now points at `os migrate security-catalog-overlays` for the environment's rows, in place of "through the metadata API on a boot that leaves the package out of the configuration, or in the database". The envelope (`code`, `status`, `conflicts[]`) is unchanged. -- **What it never does.** It adopts nothing: `managed_by` and `package_id` are never rewritten, and no row is renamed. A row the environment needs under its own name must be re-created under a name no package holds. A host config that exists and cannot be loaded is refused before any row is read, because the held names would be incomplete. -- **New exports it is built on.** - - `@objectstack/objectql` exports `findPackageHeldSecurityCatalogNames(registry)`: every package-held permission-set and position name, with its holder packages. The cold-boot check now reads the same list. - - `@objectstack/core` exports the auth gate `os serve` and the step share. `resolvePlatformAuthComposition` answers whether the platform composes `AuthPlugin` and the security plugin beside it, or why not. With it come `resolveStackTiers`, `STACK_TIER_PRESETS`, `CAPABILITY_TO_TIER`, `resolveAuthSecret`, `isDevelopmentBoot`, `DEV_AUTH_SECRET_FALLBACK`, `stackSuppliesAuthPlugin`, `isHostKernelComposition` and the `PlatformAuthComposition` / `PlatformAuthSkipReason` types. `Serve.TIER_PRESETS` and `Serve.CAPABILITY_TO_TIER` are now handles over the core declarations, and `os serve` composes exactly what it composed before. - - `@objectstack/runtime`'s `createStandaloneStack` accepts `hydrateMetadataFromDb: false`, which skips the `sys_metadata` read-back. The default stays on. - - `@objectstack/spec` registers the ADR-0087 D3 entry `security-catalog-environment-overlay-refused` in its migration registry and `spec-changes.json`, so `os migrate meta --from 17` names the refusal and this step as its remedy. Its projection is the entry under protocol 18 in the upgrade guide (`docs/protocol-upgrade-guide.md`). diff --git a/.changeset/pre/22384-auth-session-api-getsession-input-query.md b/.changeset/pre/22384-auth-session-api-getsession-input-query.md deleted file mode 100644 index 81abb2c9ab8..00000000000 --- a/.changeset/pre/22384-auth-session-api-getsession-input-query.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`AuthSessionApi.getSession` (`@objectstack/spec/contracts`) now declares the optional `query.disableRefresh` that the in-process session readers send - -Clause-②: yes (widening) - -- **The declaration.** `getSession`'s input is `{ headers: unknown; query?: { disableRefresh?: boolean } }`. It was `{ headers: unknown }`. The return type and the rest of `AuthSessionApi` are unchanged. -- **Why.** The in-process readers call `api.getSession(inProcessSessionReadInput(headers))` (`@objectstack/types`). For a request that carries a better-auth session cookie, that input is `{ headers, query: { disableRefresh: true } }`, so the session renews only on the `get-session` route, which re-issues the cookie. A bearer-only request is still read with `{ headers }` alone. The declaration said the readers send only `{ headers }`, which stopped being true when the readers moved to the helper. -- **Nothing to migrate.** The added key is optional. A caller that passes `{ headers }` compiles as before, and so does an implementation of `getSession` that accepts `{ headers }` and ignores the rest. An implementation that reads `input.query?.disableRefresh` now type-checks against the contract instead of needing a cast. diff --git a/.changeset/pre/22386-spec-object-attached-on-read.md b/.changeset/pre/22386-spec-object-attached-on-read.md deleted file mode 100644 index 3c65c049b18..00000000000 --- a/.changeset/pre/22386-spec-object-attached-on-read.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`ObjectSchema.attachedOnRead`: an object declares the blocks a service attaches to each row it serves, computed per caller on read and never stored - -Clause-②: yes (widening: a new optional `ObjectSchema` key) - -- **The key.** `attachedOnRead` is an optional map from block name to that block's leaves, each leaf naming its value type: `attachedOnRead: { viewer: { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' } }`. Block names and leaf keys take the field-name grammar (lowercase snake_case). A leaf's type is one of `number`, `text`, `boolean` or `date`, the four value types `Field.returnType` declares. -- **It is not a field.** It provisions no column, and no driver, form, list view, export, write path or translation bundle reads it. Its reader is the shared build validator, `@objectstack/lint`'s expression rule over `@objectstack/formula` (what `os build` and `os validate` run): `record.` resolves, and `record..` resolves only to a leaf the block declares (see the `@objectstack/formula` and `@objectstack/lint` entries). No other field-existence check reads it. -- **Refused at parse:** a leaf type outside the four, a leaf that is a nested block or a field definition, a block or leaf name outside the grammar, a block that names no leaf, and a block that repeats a field name the object declares. Each refusal is located at the offending key. -- **Nothing to migrate.** The key is optional and nothing writes it by default; an object without it parses and validates exactly as before. diff --git a/.changeset/pre/22386-validator-attached-on-read-leaf.md b/.changeset/pre/22386-validator-attached-on-read-leaf.md deleted file mode 100644 index 9e8ff7dc20a..00000000000 --- a/.changeset/pre/22386-validator-attached-on-read-leaf.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/formula": minor -"@objectstack/lint": patch -"@objectstack/metadata-core": patch ---- - -The shared build validator (`@objectstack/lint`'s expression rule over `@objectstack/formula`) judges `record..` against an object's declared read attachments (`ObjectSchema.attachedOnRead`) - -- **`@objectstack/lint`.** The field index the expression rule builds now adds each block an object declares under `attachedOnRead` to the names `record.` resolves to, and hands the shared validator the block's declared leaves. A predicate such as `record.viewer.can_act` on an object that declares the `viewer` block is accepted where it was refused as an unknown field. -- **`@objectstack/formula`.** `ExprSchemaHint` gains an optional `attachedOnRead` (block name → declared leaf keys). When a `record.` or `previous.` reference names a declared block, its next segment must be a leaf the block declares. Anything else is refused with the existing `unknown-field` code: `field` is the dotted path as written (`viewer.can_actt`), `suggestion` the nearest declared leaf (`viewer.can_act`), and the new optional `block` and `leaves` params carry the leaves the block declares, which the message names. No new refusal code. -- **`@objectstack/metadata-core`.** The ADR-0106 field-level-security masker classifies the new top-level key as passed through unchanged (`OBJECT_REFERENCE_POSITIONS.attachedOnRead`): a block name and its leaf keys never name a field, so no caller's served object definition loses or gains anything. -- **Unchanged.** An object that declares no block keeps every verdict it had, and a second segment off any other field is not judged. Index access, a method call on a block and a third segment stay unjudged. diff --git a/.changeset/pre/22387-plugin-approvals-viewer-attached-on-read.md b/.changeset/pre/22387-plugin-approvals-viewer-attached-on-read.md deleted file mode 100644 index a082786106c..00000000000 --- a/.changeset/pre/22387-plugin-approvals-viewer-attached-on-read.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -fix(plugin-approvals): `sys_approval_request` declares its per-caller `viewer` block under `attachedOnRead`, so the shared validator accepts the object's own action predicates (#22211, #22387) - -`sys_approval_request` ships eight action `visible` predicates that read `record.viewer.can_act`, `record.viewer.can_override` or `record.viewer.is_submitter`. `viewer` is the block the approvals service attaches to every request it serves through `listRequests` and `getRequest`, computed from the caller. The object never declared it, so `os build` / `os validate` refused all eight with ``unknown field `viewer` on `sys_approval_request` ``, and the object save door's authoring gate refused a save of the shipped body the same way. - -The object now declares `attachedOnRead: { viewer: { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' } }`. The eight predicates pass the build and the save door's gate. A misspelt leaf (`record.viewer.can_actt`) is still refused, and the refusal names the three declared leaves. - -Unchanged: who sees which decision button. `viewer` is computed as before and served on the same two reads only; the generic data door, a record-change flow's `record` and a flow action's subject row do not carry it. The served object definition gains the `attachedOnRead` key. The exported `SysApprovalRequest` type resolves to the same type. diff --git a/.changeset/pre/22394-option-visible-when-evaluser-members.md b/.changeset/pre/22394-option-visible-when-evaluser-members.md deleted file mode 100644 index f52c2beecae..00000000000 --- a/.changeset/pre/22394-option-visible-when-evaluser-members.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/metadata-protocol": minor ---- - -fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads a member of the acting user the server's option check never binds, such as `current_user.roles` (renamed `positions`), `ctx.user.roles` or `current_user.email` (#22394) - -Clause-②: no (narrowing: a select option's `visibleWhen` that reads an `EvalUser` member the schema does not declare is refused at build and at the object save door) - -A select option's `visibleWhen` is a gate the server enforces on write. The option check binds the acting user under four spellings, `current_user` and its ADR-0068 aliases `user`, `ctx.user` and `os.user`, all one `EvalUser` object. That object carries the caller's `id`, `positions`, `isPlatformAdmin` and `organizationId`, and nothing else. The build already refused a root the option check does not bind (such as `parent`) and a member of `ctx` or `os` other than `user`, but it stopped there. So an option predicate that read `'admin' in current_user.roles`, `ctx.user.roles == ['a']` or `current_user.email == 'a@b.c'` passed `os build` and the object save door with no finding. `roles` has not been a member of the acting user since ADR-0090 D3 renamed it `positions`; `name` and `email` are declared by `EvalUserSchema` but the server never sets them for this check. On every write that picked the option the predicate then faulted (`No such key: roles`, `email`), the server logged "the option's gate was NOT enforced on this write", and the value was admitted. - -The build's expression rule (`validateStackExpressions`) now judges the members of the acting user in an option's `visibleWhen`, under all four spellings, in the same verdict that judges its roots and the members of `ctx` and `os`. A member is accepted only when `EvalUserSchema` (`@objectstack/spec`) declares it AND the option check binds it; the allowlist is read off the schema and off `@objectstack/formula`'s `buildScope`, never written out. Any other member is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the member, names the members the acting user does carry there, and gives the remedy. Every spelling of the read is judged the same: `current_user.roles`, `current_user.?roles`, `current_user['roles']`, `has(current_user.roles)`, and the same below `ctx.user` and `os.user`. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. - -**BREAKING — what moves for consumers.** - -- `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a member of `current_user`, `user`, `ctx.user` or `os.user` other than `id`, `positions`, `isPlatformAdmin` and `organizationId`, such as `current_user.roles`, `current_user.role`, `ctx.user.roles`, `current_user.email` or `user.name`. -- An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). - -**Remedy.** - -- `roles` / `role` → `positions`: `'admin' in current_user.roles` becomes `'admin' in current_user.positions`, in whichever spelling the predicate used (`ctx.user.roles` becomes `ctx.user.positions`). -- `name`, `email` and any other member: rewrite the predicate against a member the option check binds (`current_user.id`, `current_user.positions`, `current_user.isPlatformAdmin`, `current_user.organizationId`), or against a column the object declares (`record.FIELD`, `previous.FIELD`). -- Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. - -**Unchanged.** - -- The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind a member such as `email` for an option, this refusal lifts for that member in the same change, because the allowlist is derived from what the option check binds. -- The same members are still accepted where they are bound, such as `current_user.email` in a row-level security policy or an object action's `visible` predicate. The refusal is the option slot's alone. -- A grant check such as `current_user.can('OBJECT', 'edit')` is a call, not a member read, and is still accepted. -- A computed key such as `current_user[name]` names no member, so it is not judged. -- Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. -- `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. -- Measured before crossing: the objects this repository ships carry 5 option predicates, all on `showcase_cascade`, which read `record` (four) and `current_user` (one, `current_user.positions`). None reads a member the option check does not bind. That holds over every object in its `*.object.ts` files and the two `app-multi-package` sub-stacks. They have 0 refusals at the build, before this change and after it. -- No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. - - diff --git a/.changeset/pre/22398-plugin-auth-session-redispatch.md b/.changeset/pre/22398-plugin-auth-session-redispatch.md deleted file mode 100644 index 37adf349950..00000000000 --- a/.changeset/pre/22398-plugin-auth-session-redispatch.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -fix(auth): the plugin-auth doors that re-dispatch to better-auth or call its endpoints in-process no longer renew a browser session behind its cookie (#22398) - -**What was wrong.** A better-auth session read renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that read's own response. Eight doors read the session in-process by a route other than `auth.api.getSession`, so the rule the `getSession` readers follow did not reach them, and each kept only the JSON, or the status and body, of the response the cookie was staged on. Measured on better-auth 1.7.3 with a session aged to `now + expiresIn − updateAge − 60 s`, each moved `expires_at` by +86460 s on a cookie request and set no session cookie, before its own answer (a refusal included): - -- through a `/get-session` re-dispatch and the bridge's forward to a better-auth route: `POST /api/v1/auth/admin/sso/register`, `POST /api/v1/auth/admin/sso/register-saml`, `POST /api/v1/auth/admin/sso/request-domain-verification`, `POST /api/v1/auth/admin/sso/verify-domain` and `POST /api/v1/auth/send-verification-email`; -- through an in-process vendor endpoint call carrying the request's headers: `POST /api/v1/auth/organization/add-member` (`addMember`), `POST /api/v1/auth/set-initial-password` (`setPassword`) and `POST /api/v1/auth/sys-oauth-application/register` (`createOAuthClient`). - -**The rule now** is the one every in-process `getSession` reader follows, decided by what the request carries: - -- **A session cookie** (a browser): the re-dispatched URL carries `disableRefresh=true`, and a vendor endpoint call takes `inProcessSessionReadInput(headers)` from `@objectstack/types`, whose `query` better-auth's session middleware passes into its read. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie, so cookie and session expire together. Measured after the change: each door leaves `expires_at` unchanged on a cookie request and sets no cookie. -- **No session cookie** (a bearer-only client): unchanged. Each door still renews the session to `now + expiresIn` and sets no cookie on a response to a request that sent none. - -**Upgrading.** Nothing to change. The shared helpers the cloud auth proxy mounts (`runRegisterSsoProviderFromForm`, `runRegisterSamlProviderFromForm`, `runRequestDomainVerification`, `runVerifyDomain`, `runResendVerificationEmail`, `runSetInitialPassword`) carry the same rule, so both mount points stay in step. `SetPasswordCapableApi.setPassword` now also accepts an optional `query: { disableRefresh: true }`; better-auth's own `auth.api.setPassword` honours it (measured on 1.7.3). diff --git a/.changeset/pre/22402-option-gate-fails-closed.md b/.changeset/pre/22402-option-gate-fails-closed.md deleted file mode 100644 index f025e1577c2..00000000000 --- a/.changeset/pre/22402-option-gate-fails-closed.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/lint': patch -'@objectstack/spec': patch ---- - -A select option whose `visibleWhen` cannot be evaluated refuses the write instead of admitting the value with one warn line (#22402) - -Clause-②: no (narrowing) - - - -**BREAKING** accept-set narrowing on `@objectstack/objectql`, shipped as `minor` under the launch-window convention for breaking changes (recorded in `scripts/check-changeset-no-major.mjs`). The governing decision is ADR-0137 D2, which the maintainer's ruling on #22402 (letter A) extended to the option gate on the write path. - -A select, multiselect, radio or checkboxes option may gate itself with a `visibleWhen` predicate, and the server re-checks it for every value a write picks. ADR-0137 D2 says a field-rule predicate that cannot be evaluated refuses the submit. The option gate is the server's enforcement of who may pick the option (ADR-0124 D1), so D2 now reaches it on the write path too. - -- **Before.** When an option's predicate faulted on a write (an undeclared key, a root or member the gate does not bind, a read through a reference, a computed key or receiver, `current_user.can()` with no permission data), the server logged "the option's gate was NOT enforced on this write" and admitted the value. A role-gated option whose rule was broken was open to every caller. -- **Now.** The write is refused with `VALIDATION_FAILED` (HTTP 400) and one field entry in the envelope a faulting `requiredWhen` or `readonlyWhen` already uses: `code: 'rule_violation'`, `constraint.reason: 'unevaluable'`, `constraint.rule: 'visibleWhen'`, `constraint.fault` naming the fault, and the picked option as `value`. The message names the option, the field and the fault, for example `Option 'gold' of field 'tier' visibleWhen could not be evaluated (runtime: No such key: statsu) — write rejected.` Nothing is written. This holds on insert (one row or a batch), by-id update and bulk update, and so on an import, which writes through them; `validate()` previews the same refusal. -- **Unchanged.** A predicate that evaluates is judged exactly as before: false refuses with `invalid_option`, true admits. A system write with no acting user whose predicate reads the acting user (`'admin' in current_user.positions` on a seed) is still admitted and logged with reason `no-acting-user`, because the gate has nobody to ask about. A system write whose predicate faults for any other reason is refused, as a faulting `requiredWhen` is. The form still offers an option whose predicate faults (ADR-0137 D3); the refusal comes at save. -- **`current_user.can()` with no permission data.** On an engine with no effective-permission resolver registered (no security plugin composed), a write by an acting user that picks a `can`-gated option is now refused, naming the missing permission data, instead of admitted. -- **A preview limit this inherits.** An `update`-mode `validate()` preview (an import dry run of a matched row) reads no stored row, so a cascade predicate whose parent column the patch omits faults there and the preview now refuses the row, as it already refuses a `requiredWhen` that reads an omitted column. The write itself reads the stored row and judges the predicate cleanly. - -**The remedy: fix the predicate, which `os build` names for every statically judgeable shape.** The shapes no build can judge (a computed key, a computed receiver, a row stored before the build verdicts, or a save under `OS_ALLOW_UNLINTED_METADATA_WRITES=1`) now meet this refusal at the first write that picks the option, naming the fault. - -Measured before the change, on `origin/main` 3054516ef1: the objects this repository ships carry 5 option predicates, all on the showcase's `showcase_cascade` (four `record.country == …` cascades and one `'org_admin' in current_user.positions` role gate), and none of them faults on a write. Insert and update evaluate them over a total record, so a payload without `country` reads `null == 'cn'`, a clean false. No seed or dogfood write picks a gated option. - -Earlier entries in this release that say an option predicate which faults is still logged and admitted describe the gate before this change. - -`@objectstack/lint` and `@objectstack/spec`: the published text that said the server admits a faulting option predicate now says it refuses the write. This covers the `objectstack validate` finding for an option predicate that reads through a reference, a root or member the gate does not bind, and the `SelectOption.visibleWhen` description. No verdict moves. diff --git a/.changeset/pre/22405-serve-flat-docs-owner.md b/.changeset/pre/22405-serve-flat-docs-owner.md deleted file mode 100644 index 8b779186d19..00000000000 --- a/.changeset/pre/22405-serve-flat-docs-owner.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os serve `: a multi-package config now serves its flat `src/docs/` pages, under the package that owns the stack's manifest - -Clause-②: no - -A config boot (`os serve objectstack.config.ts`, with or without `--dev`, and no compiled artifact) of a stack that carries `packages[]`, such as one `composeStacks([…], { manifest: 'preserve' })` returns, did not serve the stack's own `src/docs/*.md` pages. The boot put them on the config's top level, and a config boot registers that config's package bodies, never its top level. `GET /api/v1/meta/doc` did not list them, and nothing warned. `os build` of the same project, booted as an artifact, served them. - -- **What changes.** The config boot places the flat pages where `os build` places them: on the body of the one `packages[]` entry whose id is the stack's `manifest.id`, after that package's own `src//docs/` pages. `GET /api/v1/meta/doc` now lists them under that package, as an artifact boot of the same project does. -- **What stays the same.** A single-package config (no `packages[]`) serves its flat pages under its one package, as before. Each package's own `src//docs/` pages are served under that package, as before. Artifact boots (`os dev`, `os start`, `os serve` with `dist/objectstack.json`) do not change. -- **When nothing moves.** If no entry has the manifest id, if several do, or if the stack declares no `manifest.id`, the flat pages stay on the top level, as `os build` keeps them. A config boot of such a stack still does not list them. -- **A name shared by an inline doc and a flat page.** `os build` refuses this (`docs/duplicate-name`); a config boot does not lint docs. On a single-package config the flat page still replaces the inline doc of the same name. On a multi-package config the flat page is now served under the owning package, and an inline doc on that package's body with the same name is served with the flat page's content. diff --git a/.changeset/pre/22407-flow-door-elevated-start.md b/.changeset/pre/22407-flow-door-elevated-start.md deleted file mode 100644 index 07704781fea..00000000000 --- a/.changeset/pre/22407-flow-door-elevated-start.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): the action door and the declared flow endpoint refuse a self-triggered flow declared to run as system to any caller but the system principal - -Clause-②: no - -The trigger door already refused this start, but two other doors that start a flow by name did not ask: an action of `type: 'flow'` (`POST /api/v1/actions/:object/:action`, and the MCP `run_action` tool, which reaches the same dispatch) and a declared endpoint of `type: 'flow'`. Any signed-in member who was refused at the trigger door could start the same elevated flow through either one, and its elevated write landed. - -**What changes.** All three doors now apply one rule from one place. A caller that is not the system principal gets `403 PERMISSION_DENIED` (ADR-0112 envelope) when it starts a flow declared `runAs: 'system'` whose `type` is `autolaunched`, `record_change` or `schedule`. Nothing runs: no run record, no node, no side effect. This includes a platform admin's session and the guest principal an `authRequired: false` endpoint admits. The refusal names no part of the flow, and its message is now the same at every door: it no longer names the trigger door. - -**What stays as it was.** - -- `screen` and `api` flows, elevated or not: entries the author designed (ADR-0073 D2). -- Every flow that does not declare `runAs: 'system'`. -- A parent flow's `subflow` node calling an elevated flow: the child starts through the engine, never through a door. -- The system principal (an in-process caller, such as a job), which starts every flow. -- The action's own `requiredPermissions` gate (ADR-0066 D4) still answers first. Holding that capability does not open a self-triggered elevated target. -- A declared endpoint's policy chain: an anonymous caller at an `authRequired: true` endpoint still gets `401` first. - -**If an action or an endpoint now answers 403.** Its target flow runs on its own trigger. If members are meant to start that elevated work, declare the target `type: 'screen'` (or `type: 'api'` for a signed inbound hook), which states that it is an entry and keeps the elevation reviewable. Or keep it self-triggered and point the action or endpoint at such an entry whose `subflow` node calls it. diff --git a/.changeset/pre/22410-dev-banner-order.md b/.changeset/pre/22410-dev-banner-order.md deleted file mode 100644 index 1ac7cb20356..00000000000 --- a/.changeset/pre/22410-dev-banner-order.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os dev` prints the server's ready banner whole, then the MCP connect block whole, instead of interleaving the two (#22410) - -Clause-②: no - -- **What a terminal showed.** `os dev` is two processes writing one terminal. The parent prints the MCP connect block (`🤖 MCP server — connect a coding agent:` with its Endpoint, Skill, Connect and Disable rows) when the `serve` child sends `objectstack:listening`, and the child sent that message before it printed its ready banner. Measured under a pty on the Build-with-Claude-Code tutorial project, 6 of 7 boots printed the block above or inside the banner. In 2 of them, banner lines landed between the block's rows, in one case the `➜ Console:` and `➜ MCP:` rows. A setup boot before those seven printed the `🔑 Dev admin` credential lines and the whole plugin section between `Skill` and `Connect`. -- **What changed.** The child now sends `objectstack:listening` after its ready banner and boot diagnostics have printed. The parent prints the block on that message, so it always comes last, under the banner's `Press Ctrl+C to stop` row. The order follows from the sequence itself, with no timer: the banner is written to the terminal before the message is sent. After the change, every measured boot printed the banner whole and then the block whole. -- **What did not change.** The message is still `{ type: 'objectstack:listening', port, url }`, carrying the port actually bound. The runtime state file is still written before either announcement, and `objectstack:seed-settled` still follows `objectstack:listening`. A program that spawns `os serve` with an IPC channel receives `objectstack:listening` slightly later than before: after the banner has printed instead of before. diff --git a/.changeset/pre/22411-hatch-legacy-spelling.md b/.changeset/pre/22411-hatch-legacy-spelling.md deleted file mode 100644 index 417ee98d0af..00000000000 --- a/.changeset/pre/22411-hatch-legacy-spelling.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol)!: one reader of `OS_METADATA_WRITABLE` — the legacy `OBJECTSTACK_METADATA_WRITABLE`, removed in 11.0, no longer opens the hatch at the type listing either - -Clause-②: no (narrowing) - - - -**BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. - -This finishes 11.0's removal at the reader that kept it. The 11.0 release removed ObjectStack's own legacy environment-variable names, `OBJECTSTACK_METADATA_WRITABLE` among them. That change edited one of the two readers of `OS_METADATA_WRITABLE` (the metadata repository's write gate) and missed the other (the protocol's, which feeds `GET /api/v1/meta/types` and the protocol's write gates). So with only the legacy spelling set, the type listing reported a named type as writable (`allowOrgOverride: true`, `overrideSource: 'env'`), while creating a new item of that type answered `403 NOT_CREATABLE`. One deployment gave two answers to one question. - -**What changes.** The protocol now reads the setting through the repository's reader, so there is one reader and it reads `OS_METADATA_WRITABLE` only. With only `OBJECTSTACK_METADATA_WRITABLE` set, the hatch is shut everywhere: the listing reports no env override, and every write the hatch would open is refused as it is with nothing set. No deprecation warning is printed for the legacy name any more, because nothing reads it. - -**What does not change.** `OS_METADATA_WRITABLE` behaves exactly as before. - -**Operator action.** A deployment that still sets `OBJECTSTACK_METADATA_WRITABLE` sets `OS_METADATA_WRITABLE` in its place, with the same comma-separated type list. This is the rename 11.0 already published; nothing else changes. diff --git a/.changeset/pre/22420-console-drift-responder.md b/.changeset/pre/22420-console-drift-responder.md deleted file mode 100644 index b3b2b7eb4a6..00000000000 --- a/.changeset/pre/22420-console-drift-responder.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): a dev server that refuses a drifted Console answers `503` at `/_console/` naming the rebuild, instead of a bare `404` (#22420) - -Clause-②: no - -A refused Console mount answers `503` naming the remedy instead of the router's bare `404` on the same routes, as the not-built position already does. No accepted input, key or export changes. - -`os dev` refuses to mount a Console whose built `dist/.objectui-sha` differs from the repository's `.objectui-sha` pin. The terminal printed the refusal, but nothing was mounted, so `GET /_console/` and `GET /` answered `404 ENDPOINT_NOT_FOUND`, the same answer as a wrong URL. That boot now mounts the same routes as the not-built answer: - -- `GET /_console/` and every path under it answer `503` (`text/plain`, `no-store`). The body is the refusal's own text: both objectui SHAs, why the stale build is refused, `pnpm objectui:build`, and the `OS_ALLOW_CONSOLE_DRIFT=1` escape hatch. The pin file is named without its absolute path. -- `GET /` and `GET /_console` redirect to `/_console/`, as they do when the Console is served. - -The same answer now covers an install where `@objectstack/console` does not resolve at all. That boot used to print nothing and mount nothing. It now prints ``Console package `@objectstack/console` not found``, followed by the reinstall sentence an install with no `dist/` already gets. `/_console/` answers `503` saying the Console is not installed. - -`--no-ui` and `--no-console` still mount nothing, so those routes answer `404`. diff --git a/.changeset/pre/22423-mcp-stdio-package-cwd.md b/.changeset/pre/22423-mcp-stdio-package-cwd.md deleted file mode 100644 index 93428bc947a..00000000000 --- a/.changeset/pre/22423-mcp-stdio-package-cwd.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/connector-mcp": minor ---- - -feat(connector-mcp): a declarative stdio MCP transport runs in the declaring app's root, so its relative command and args resolve there wherever the server was started - -Clause-②: yes (widening) - -- **What changes.** For a declarative `provider: 'mcp'` connector with a stdio transport, the `mcp` provider factory now sets the child process's working directory to the app's root. It reads that root from the host through `ConnectorProviderContext.resolvePackagePath('.')`. Relative paths in the entry now resolve against the same root as the app's other relative refs, such as the `openapi` provider's `providerConfig.spec`. That covers a relative `command` path (`./bin/server`) and a script the launched program opens itself (`args: ['./scripts/server.mjs']` with `command: 'node'`). Before this change they resolved against the directory the server was started from. A boot from any other directory, such as `os verify --app examples/app-showcase/objectstack.config.ts` from the repository root, therefore registered the connector degraded, with no actions. -- **What does not change.** A bare executable (`node`, `npx`) is still looked up on `PATH`, and an absolute command or argument resolves as before. The `declarativeStdio` policy judges the `command` string exactly as written, and it runs before the root is resolved. `providerConfig.transport` gains no key: the working directory comes from the host, and an authored `cwd` there is not used. A host that does not provide `resolvePackagePath` starts the child exactly as before, in the host's current directory. A server that opens relative files of its own now opens them under the app's root. -- **New on the exported type.** The stdio variant of `McpTransport` gains an optional `cwd`, which the default client passes to the MCP SDK's `StdioClientTransport`. A hand-wired transport (`new ConnectorMcpPlugin({ transport })` or `createMcpConnector`) may set it. Left out, the child inherits the host's current directory, as before. -- **Nothing to migrate.** An app that started its server from its own directory sees no difference, because both anchors were the same directory. diff --git a/.changeset/pre/22428-field-existence-every-spelling.md b/.changeset/pre/22428-field-existence-every-spelling.md deleted file mode 100644 index c5b44054691..00000000000 --- a/.changeset/pre/22428-field-existence-every-spelling.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/formula": minor ---- - -fix(formula)!: the unknown-field check judges every spelling of a `record` / `previous` member, not only the dot, so `os build` and the object save door refuse `record['typo']`, `previous['typo']`, `record.?typo` and `record[?'typo']` (#22428) - -Clause-②: yes (narrowing) - - - -**BREAKING**: an accept-set change, in both directions, of the `os build`, `os validate` and `os lint` verdicts and of the object save door, shipped as `minor` under the launch-window convention for accept-set narrowings. It narrows: an expression that names an undeclared field of its object in a non-dot member spelling (`record['FIELD']`, `record["FIELD"]`, `record.?FIELD`, `record[?'FIELD']`, or any of those on `previous`), or an undeclared leaf of a declared read attachment in such a spelling, is now refused where it was accepted. It widens: three shapes the dot-only regex misread as a member of the root, and refused as unknown fields, are now accepted, because none of them names a member. They are text inside a string literal (`record.name == 'record.typo'`), a root name after another root (`vars.record.x`), and a method call on the root itself (`record.size()`). No refusal code is added. - -`validateExpression`'s field-existence pass judges each member an expression reads on `record` or `previous` against the object's fields, and refuses an undeclared one with the `unknown-field` code. It found those members with a regex over the dot spelling, so it judged `record.typo` and `has(record.typo)` and nothing else. `record['typo']`, `record["typo"]`, `previous['typo']`, `record.?typo` and `record[?'typo']` name the same column, and they reached the evaluator with no verdict at every slot the pass judges, among them a select option's `visibleWhen`, a field's `requiredWhen` and a validation rule's condition. There the expression faults on a key the record never carries, or reads a value that never exists. - -The pass now reads members through the same AST member reader the relationship-traversal analysis is built on, so every spelling gets the dot spelling's verdict, did-you-mean included: - -- `os build`, `os validate` and `os lint` refuse an expression that names an undeclared field of its object as `record['FIELD']`, `record["FIELD"]`, `record.?FIELD`, `record[?'FIELD']` or any of those on `previous`, at `error`, with the message they already give for `record.FIELD`. -- The object save door runs the same pass, so an object write in publish mode that carries such an expression is now refused with an `expression-invalid` issue located at the slot, the build's own finding. -- A declared read attachment's leaf (`ObjectSchema.attachedOnRead`) is judged in the same spellings: `record.viewer['can_actt']` and `record.viewer.?can_actt` are refused like `record.viewer.can_actt`. An earlier entry in this release says index access on a read attachment stays unjudged; that describes the check before this change. - -**Remedy.** Declare the field on the object, or fix the typo to the field the message suggests. The spelling itself is not the defect: `record['status']`, `record.?status` and `record[?'status']` on a declared `status` are accepted, as before. - -**Unchanged.** - -- A computed key (`record[record.kind]`, `record[someVar]`) names no member before evaluation, so it is not judged. -- A method call on a member (`record.name.startsWith('A')`) still judges the member (`name`). -- Stored rows are not migrated or refused on read; an object stored before this change keeps loading until it is next saved, and that save is judged. Drafts are not gated. -- Measured before crossing: the stacks this repository ships give the same expression findings before and after the change. That covers `examples/app-todo`, `examples/app-crm`, both `examples/app-multi-package` sub-stacks, the objects, actions, flows, views and pages of `examples/app-showcase`, the 51 objects `@objectstack/platform-objects` exports, and `plugin-approvals`' `sys_approval_request`, whose eight action predicates read the leaves of its declared `viewer` read attachment and pass. No in-repo producer writes a non-dot spelling of an undeclared field. -- No public export or signature moves; only the doc comments of `ExprSchemaHint.fields` and `ExprSchemaHint.attachedOnRead` change. `analyzeRelationshipTraversals` answers exactly what it answered before: measured identical, set order included, on 4,528 analyses of the 2,264 `record` / `previous` expressions in this repository's TypeScript sources. diff --git a/.changeset/pre/22430-api-description-anonymous-deny.md b/.changeset/pre/22430-api-description-anonymous-deny.md deleted file mode 100644 index 67de542c82e..00000000000 --- a/.changeset/pre/22430-api-description-anonymous-deny.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest)!: the API-description endpoints refuse an anonymous caller - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -`RestServer.registerOpenApiEndpoints` mounts the API-description endpoints: the document and its viewer page. Until now they served a caller with no session, while the data and metadata routes beside them refused one. ADR-0056 D2 denies anonymous callers by default. - -- **Refused now:** a caller with no session, with `401` and code `UNAUTHENTICATED` — the same body the data routes answer. Both handlers check before any other work, so the refusal does not say whether a document is bundled. -- **Also refused:** a session held by an auth policy (an expired password, enforced MFA) gets that policy's `403`, as on every other protected route. -- **Unchanged:** a signed-in caller is served the same document and the same viewer page as before. The viewer page loads the document from the browser with the browser's session, so a signed-in browser sees the viewer work as before. - -What changes for you. If you published the API description to readers without an account, have them sign in first, or publish a static copy of the document instead. diff --git a/.changeset/pre/22431-unclaimed-download-signed-in.md b/.changeset/pre/22431-unclaimed-download-signed-in.md deleted file mode 100644 index b29ac6dc16d..00000000000 --- a/.changeset/pre/22431-unclaimed-download-signed-in.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/service-storage': minor ---- - -fix(service-storage)!: downloading a file with no attachments scope and no field owner requires a signed-in caller - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The storage download routes, both the one that answers a signed URL and the stable one that redirects to the bytes, now require a signed-in caller for a file that has neither an attachments scope nor a field owner: an upload no record has claimed. That is the class an avatar or an organization logo stored as a URL belongs to, and so is a picked file not yet saved to its record. ADR-0104 made the anonymous capability URL an opt-in, `acl: 'public_read'`; this was the one class still served anonymously by default. - -- **Refused now:** a caller with no session, with `401 AUTH_REQUIRED`, the answer the upload routes and the attachments gate already give an unauthenticated caller. No signed URL is minted for the refused caller. -- **Unchanged:** a signed-in caller is served exactly as before, including the signed URL's lifetime. A browser's `` and `` send the session cookie the sign-in set, so a signed-in page keeps rendering these files. A file marked `acl: 'public_read'` stays anonymous. Attachments-scope and field-owned files keep their parent-record verdicts. A deployment with no `auth` service, whose storage routes run without a session resolver, keeps these downloads open as before and says so once in its log. - -What changes for you. Before this release, anyone holding such a file's id could download it; now, sign in first. A file that must render before sign-in (on a sign-in page, in an email, on a public page) needs `acl: 'public_read'` on its `sys_file` row. diff --git a/.changeset/pre/22432-i18n-anonymous-deny.md b/.changeset/pre/22432-i18n-anonymous-deny.md deleted file mode 100644 index 623d6179050..00000000000 --- a/.changeset/pre/22432-i18n-anonymous-deny.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime)!: the `/i18n` dispatcher domain refuses a caller without a session with `401 UNAUTHENTICATED`, like every other dispatcher domain - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The `/i18n` dispatcher domain serves the application's translations: the locale list, the translation bundle and the field labels. The bundle carries the labels of every object, field, app and page the application declares. Until now the domain served a caller with no session, while the metadata read of the same objects refused one. ADR-0056 D2 denies anonymous callers by default. - -**What changed.** The domain handler opens with the shared anonymous-deny decision (`shouldDenyAnonymous`), the same floor the `/meta`, `/actions`, `/automation`, `/packages` and `/analytics` domains stand on. It is the handler's first statement: - -- every face of the domain answers a caller without a session `401` with code `UNAUTHENTICATED`, in the dispatcher's wrapped envelope (`{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }`), and serves nothing of the bundle; -- it runs before the i18n provider is looked up, so the answer is `401` whether or not a provider is installed, never the `501` an empty slot answers; -- it runs before a face reads its parameters, so a request that leaves out its locale is `401`, never the `400` that face answers. - -**What is not affected.** A signed-in caller, an API-key caller and an internal system context are served exactly as before: the same bundle, the same `400` for a missing locale, the same `501` when no provider is installed. A CORS preflight is unchanged. - -**The Console.** The Console this release pins renders its sign-in page from its built-in language packs and loads the application's translations after sign-in, with the signed-in session. A signed-in Console user sees the application's labels as before. - -**If you read `/i18n` from your own client,** send the signed-in user's session or bearer token, or an API key, with the request. Render anything shown before sign-in from strings your client ships, and load the application's translations once the user has signed in. diff --git a/.changeset/pre/22434-connector-provider-package-path.md b/.changeset/pre/22434-connector-provider-package-path.md deleted file mode 100644 index b6df72d552f..00000000000 --- a/.changeset/pre/22434-connector-provider-package-path.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-automation": minor ---- - -feat(spec): `ConnectorProviderContext.resolvePackagePath` — a connector provider factory can resolve a path against the declaring app's root - -Clause-②: yes (widening) - -- **What is new.** `ConnectorProviderContext` gains one optional, host-provided member, `resolvePackagePath(relativePath): Promise`, beside `loadPackageFile`. It resolves a relative path against the root of the stack or package that declared the connector entry and returns the absolute path. `'.'` returns the root itself. It refuses (throws on) an empty path, an absolute path, and a path that escapes the root after resolution, such as `../x` or `a/../../x`. That is the same rule `loadPackageFile` follows. It reads nothing and does not check that the path exists. -- **Who hands it.** The connector materializer in `@objectstack/service-automation` hands it to every provider factory. It is anchored at the plugin's `packageRoot` option, the same root `loadPackageFile` reads under, and falls back to `process.cwd()` the same way. The two members now share one confinement check, so they cannot disagree about what is inside the root. `loadPackageFile`'s behaviour and error messages are unchanged. -- **Who reads it.** Nothing yet. `@objectstack/connector-mcp` is next: a declarative stdio transport will use it as the launched process's working directory, so a relative command or argument resolves against the app's root instead of the directory the server was started from. -- **What does not change.** Which commands a declarative stdio transport may launch is untouched. A factory that does not read the member behaves as before. -- **Nothing to migrate.** A host that builds its own `ConnectorProviderContext` may leave the member out. A factory must then keep its existing behaviour, or fail with a clear message if it needs the root. diff --git a/.changeset/pre/22437-public-form-submit-answers-id.md b/.changeset/pre/22437-public-form-submit-answers-id.md deleted file mode 100644 index 678145ae229..00000000000 --- a/.changeset/pre/22437-public-form-submit-answers-id.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest)!: an anonymous public-form submit answers the created record's id, and nothing the insert stored - -Clause-②: no (narrowing) - - - -**BREAKING** (a response narrowing): this ships as `minor` under the launch-window convention for breaking changes. - -`POST /api/v1/forms/:slug/submit` used to answer `201` with the protocol's whole create answer, `{ object, id, record, droppedFields? }`. The `record` was the row as stored after the insert pipeline. So the anonymous caller was shown every field it never sent: a `defaultValue`, a field a `beforeInsert` or `afterInsert` hook stamped, and a value a hook running elevated (`runAs: 'system'`) derived from existing records the caller's grant may never read. The form's field whitelist filtered what the caller could write. Nothing filtered what it was then shown. - -The answer is now the created id alone: - -```json -{ "id": "r7p8cUZoBJbFWudt" } -``` - -- **Still `201`**, as a bare JSON object with no envelope. The created id stays at the top-level `id`, where the console's public form page reads it, so its confirmation and `redirect` behaviors keep working. -- **What the submitter typed is not echoed back either.** The caller already holds it. On the console's `redirect` behavior, a `{{record.field_name}}` token over a submitted field still resolves from the submitted values, and `{{record.id}}` from the answer. A token over a field only the server fills in now resolves empty. That value is exactly what this change stops serving. -- **The write is unchanged**: the same whitelist, the same server-managed anchors stripped, the same grant, the same hooks. Only the answer shrank. - -**If your host read the record off this answer**, read it through an authenticated read instead: `GET /api/v1/data/:object/:id` with the id from the answer, as a principal allowed to see that record. diff --git a/.changeset/pre/22443-storage-scope-public-retired.md b/.changeset/pre/22443-storage-scope-public-retired.md deleted file mode 100644 index 885596c0202..00000000000 --- a/.changeset/pre/22443-storage-scope-public-retired.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-storage': minor ---- - -feat(storage)!: the storage scope `public` is retired — no scope ever made a file publicly readable, and `acl: 'public_read'` stays the one opt-in for anonymous download (#22443) - -Clause-②: no (narrowing) - - - -**BREAKING** — an accept-set narrowing on two published surfaces, shipped as `minor` under the launch-window convention for accept-set narrowings. - -`StorageScopeSchema` described `public` as "publicly accessible static assets", and the upload doors stored a caller's `scope: 'public'` on the new file. Neither ever made a file public. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope `public` and the default acl was stored private, needed a signed-in caller, and nobody was told (ADR-0049 enforce-or-remove). ADR-0104 makes `acl: 'public_read'` the one opt-in for anonymous download, so the scope is retired, not enforced: enforcing it would have let any uploader make a file anonymous at upload. - -### What now refuses `public` - -- **The presigned upload door and the chunked upload door** (`@objectstack/service-storage`) answer an upload naming `scope: 'public'` with `400 INVALID_REQUEST`, before any file row, session row, upload URL or backend upload exists. The message names the remedy. Every other scope, and an omitted one, is taken exactly as before. -- **`StorageScopeSchema`** (`@objectstack/spec`): `public` left the enum. Writing it fails `tsc`, and parsing it, on its own or as `ObjectStorageConfig.scope`, fails with the prescription instead of zod's generic enum message. - -### FROM → TO - -| before | what to write instead | -| --- | --- | -| an upload with `scope: 'public'` | another scope, or no `scope` for the default `user`; then set `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in | -| `ObjectStorageConfig` with `scope: 'public'` | another scope, or no `scope` for the default `global` | - -**The one-line fix: stop naming scope `public`, and mark each file that must render before sign-in `acl: 'public_read'` on its stored file record.** The upload request carries no `acl`: every upload is stored `acl: 'private'`, so adding an `acl` to the upload request changes nothing. - -**Files already stored with scope `public`** download exactly as they did: a signed-in caller gets them, an anonymous one gets `401`, unless the file is `acl: 'public_read'`. Their stored file records are rewritten to scope `user` by the one-time operator sweep `@objectstack/service-storage` exports, which changes no access: the storage key and the bytes stay as they are. - -### The retirement kit - -- **Schema.** `StorageScopeSchema` retires the member with `enumWithRetiredValues`. No D2 conversion: no metadata type carries this schema or the upload request, so `os migrate meta` has no authored source to rewrite. -- **D3 entry `storage-scope-public-retired`** carries the judgement no rewrite can make: whether a file that was uploaded as `public` must really be readable before sign-in. -- **Upload request contract.** The `scope` description on the presigned and chunked request schemas no longer offers `public` as an example, and says what the scope is and is not. -- **Liveness.** No ledger row: the liveness ledger walks metadata types, and no metadata type carries `StorageScope`. - -**Measured producers: none.** At origin/main da159f74e6, nothing in `packages/`, `examples/`, `apps/`, `skills/` or `content/docs/` uploads with scope `public` or declares an `ObjectStorageConfig` with it. The one hit was a `@objectstack/spec` request-schema test fixture, changed to `tenant` here. The same search finds 29 `scope: 'attachments'` writes, which is its control. At the `.objectui-sha` pin f0268ad784 and at objectui main 2063f7a, the upload adapter forwards a caller's scope and no caller names `public`: the console passes none, and the record attachments panel passes `attachments` (the control). Deployed callers and stored rows NOT MEASURED. diff --git a/.changeset/pre/22443-sys-file-public-scope-rewrite.md b/.changeset/pre/22443-sys-file-public-scope-rewrite.md deleted file mode 100644 index 98881e9b62f..00000000000 --- a/.changeset/pre/22443-sys-file-public-scope-rewrite.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@objectstack/service-storage': minor -'@objectstack/spec': patch ---- - -feat(service-storage)!: the `sys_file` scope option `public` is retired, and rows already stored with it are rewritten to `user` by a one-time operator sweep (#22443) - -Clause-②: yes (narrowing) - - - -**BREAKING** — an accept-set narrowing on the `sys_file` object's `scope` select, shipped as `minor` under the launch-window convention for accept-set narrowings. It completes the retirement registered as `storage-scope-public-retired`: `StorageScopeSchema` and both upload doors already refuse `public`, and now the stored vocabulary does too. - -### What changes - -- **`sys_file.scope` no longer lists `public`.** Its options are `user`, `tenant`, `private`, `temp` and `attachments`. The engine refuses a write of `public` to the column as it refuses any undeclared option: `VALIDATION_FAILED`, `invalid_option` on `scope`. No access behaviour changes: the only scope value any code reads is `attachments`, and whether a file can be read before sign-in is decided by `acl: 'public_read'` alone. -- **Rows already stored with scope `public` are rewritten to `user`**, by a one-time sweep the operator runs (below), never at boot. `user` is the scope the upload doors and the field-reference copy path default to, and no reader tells it apart from `public`. One column moves: the storage key keeps its `public/` prefix, the acl and ownership columns are untouched, and no byte moves in the storage backend. - -### FROM → TO - -| before | write instead | -| --- | --- | -| a `sys_file` row written with `scope: 'public'` | `scope: 'user'`, or no scope; and `acl: 'public_read'` on the row if the file must be readable before sign-in | -| rows already stored with `scope: 'public'` | nothing by hand: the sweep below rewrites them to `user` | - -**The one-line fix: stop writing scope `public` on `sys_file`, and run the sweep once on each deployment that stored it.** - -### The operator step: run the sweep once after upgrading - -Until the sweep has run on a deployment that stored `public` rows, a record write that names a `public` file another field already owns is refused. The field-reference copy path copies the file into a new row with the source row's scope, the engine refuses `public` there, and the write fails with `ERR_FILE_REFERENCE_COPY`. The data REST doors answer that `500 INTERNAL_ERROR` ("Internal server error"); the server log carries the full sentence, which ends `Scope must be one of: user, tenant, private, temp, attachments`. Reads, downloads and updates that do not write `scope` are unaffected. - -The sweep ships in `@objectstack/service-storage` itself: `planSysFilePublicScopeBackfill`, `applySysFilePublicScopeBackfill`, `runSysFilePublicScopeBackfill` and `formatSysFilePublicScopeBackfillReport` are exported from the package, with their report types. It has the shape of the `sys_file` organization backfill: an operator step, never a boot hook. Import it from the package you upgraded to and run it server-side, from a context that holds the engine. It is a dry run first, and by default: - -```ts -import { - planSysFilePublicScopeBackfill, - applySysFilePublicScopeBackfill, - formatSysFilePublicScopeBackfillReport, -} from '@objectstack/service-storage'; - -const plan = await planSysFilePublicScopeBackfill(engine); // counts, writes nothing -console.log(formatSysFilePublicScopeBackfillReport(plan)); -const applied = await applySysFilePublicScopeBackfill(engine, plan); // one scope write per row -console.log(formatSysFilePublicScopeBackfillReport(applied)); // keep it: it is the rollback list -``` - -It counts before it writes (`scanned`), writes nothing where the count is zero, and is idempotent: every write moves its row out of `scope = 'public'`, so a second run scans and writes zero. A row whose write fails is reported, never retried, and is picked up by the next run. - -### Rollback - -The inverse is `user` back to `public` on exactly the ids the applied report lists. This release cannot write it through the engine, which refuses `public` like any undeclared option. So the inverse goes with a code rollback: on the previous release, which still declares the option, write `scope = 'public'` back to those ids, through the engine there or as one raw driver statement against `sys_file` filtered to them. - -### The step-18 entry says so too (`@objectstack/spec`) - -The D3 entry `storage-scope-public-retired`, which the protocol upgrade guide is built from, said that files already stored with scope `public` are not touched. That no longer holds for the `scope` column: after the sweep they read `user`. Its reason now names the `sys_file` option's retirement and the operator sweep above, and says that the rewrite changes no access: the storage key and the bytes stay as they are, and those files still download exactly as before. Its acceptance criteria add the sweep's end state: a dry run on each deployment scans zero `sys_file` records with scope `public`. diff --git a/.changeset/pre/22444-list-view-component-props-row.md b/.changeset/pre/22444-list-view-component-props-row.md deleted file mode 100644 index 9c41663c13d..00000000000 --- a/.changeset/pre/22444-list-view-component-props-row.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `ComponentPropsMap` gains a `list-view` row, so a list view embedded on a page is judged as a list view; stored page nodes in the old `objectName` / `viewType` spelling convert to `data` / `type` on read - -Clause-②: yes (widening) - -- **The row.** `ComponentPropsMap['list-view']` is the list view's own members by reference (`ListViewSchema.shape`), with both of its object-level checks attached (the calendar visualization and the chart dataset binding), plus the two members the list overlay member declares instead: `columns` is optional, and the legacy `options` bag is judged per kind (`options.kanban` by the kanban block's schema, `options.grid` refused because a grid has no per-kind block). Until this row a page `list-view` node's `properties` were judged by nothing, so a kanban block's `groupField` or an `options.grid` bag, refused at every list view door, saved green on a page. `os validate`, `os build`, `os lint` and the page save door now report them through the existing component-props rule (`component-props-unknown-key`), naming the key. No rule, tier or door is added, and `PageSchema`'s accept set does not move. -- **The page carrier's old spelling.** A page `list-view` node names its object in `properties.data` and its list kind in `properties.type`, the spelling every other list view door uses. `properties.objectName` and `properties.viewType` are refused by name, with the prescription the react tier gives the same two aliases on `ListView` (`REACT_RETIRED_OVERLAY_PROPS.ListView`, now declared once for both tiers). Fix: replace `objectName: 'deal'` with `data: { provider: 'object', object: 'deal' }` (or name the object on the node's `dataSource.object`), and `viewType: 'kanban'` with `type: 'kanban'`; the node's own `type` stays `list-view`. `os migrate meta --from 17 --write` makes the edit where it can prove it. -- **Stored pages.** The ADR-0087 D2 conversion `page-list-view-object-name-view-type-to-data-type` moves the two aliases to `data` and `type` on every page `list-view` node it reaches (regions, slots, nested containers), retired from the load path: stored `sys_metadata` page rows, compiled artifacts and installed packages replay it on read, while an authored source meets the refusal. An alias beside a spec key that says something different is left as stored and reported as a TODO (`OS_METADATA_CONVERSION_TODO`). Its D3 record is the semantic entry `page-list-view-object-name-view-type-retired`. -- **Also classified:** a `list-view` inside a print page is refused with its reason (it pages its rows), and the view-level API data source refusal names `list-view` among the page components whose `data` prop takes a view data source. diff --git a/.changeset/pre/22445-update-preview-stored-row.md b/.changeset/pre/22445-update-preview-stored-row.md deleted file mode 100644 index b0a1cb72449..00000000000 --- a/.changeset/pre/22445-update-preview-stored-row.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/core': minor -'@objectstack/spec': minor ---- - -An `update`-mode `validate()` preview judges the stored row merged with the patch, as the by-id update does, so an import dry run of a matched row admits the rows the import's update admits (#22445) - -Clause-②: yes - -The preview's accept set widens: rows it refused, and the write admits, are now admitted. No key, export or error code is added, removed or renamed. - -- **Before.** An `update`-mode preview (`engine.validate(object, rows, { mode: 'update' })`, `validateData`, and the import dry run of a row that matched a stored record) read no stored row, so the record its rules judged held only the patch. A `requiredWhen`, an option `visibleWhen` or a `validations[]` rule that reads a column the patch omits faulted there, and the preview refused the row with `rule_violation` / `unevaluable`, although the real update reads the stored row first and admits it. The refusal also said the omitted column was one "which this object does not declare", sending the author looking for a field that exists. -- **Now.** A row that carries its `id` (the address every update door already folds into the payload) is judged against the row that id names: the stored row is the rules' `previous`, and their record is the stored row merged with the patch, exactly as the by-id update judges it. A reference field a traversing rule reads is resolved from that merge too. The import dry run of a matched row sends the matched record's id this way, so nothing new crosses the protocol. -- **Read under the caller's access.** The engine's read door, under the caller's own context, decides whether there is a row to judge: a row the caller cannot read gets the verdict a missing row gets. The values judged are the row as stored, read the way the by-id update reads its prior row (so a formula column, which has no stored value, is judged as the update judges it). A stored column is judged only where the read door served this caller that very value. A column the read door hid, or served transformed (a partially masked field, a masked secret), is judged as empty, so the verdict never depends on a value the caller could not read in full. File fields are compared in their stored form, so a readable file reference is judged as the id it holds. Measured on `driver-sql` (SQLite) and `driver-memory`, a plain readable column (number, currency, percent, boolean, date, datetime, JSON, multiselect, select, text) reaches the rules as its stored value. -- **Unchanged.** A row with no `id`, or whose id names no row the caller can read, is judged on the patch alone, as before. `required` and value checks still look only at the supplied keys, matching a PATCH. A merge that really violates a rule is refused, as the write refuses it. The preview still runs no `readonlyWhen` or primary-key strip, so it can report fewer dropped fields than the update. -- **The refusal names the real cause.** Where a rule reads a column the object declares and the judged record does not hold it (a preview with no stored row), the refusal now says the value was not supplied and no stored row was read, instead of saying the object does not declare the column. A column the object really does not declare keeps the old sentence. - -`@objectstack/core`: the import runner's dry run passes the matched record's id with an update row. `@objectstack/spec`: the `ValidateDataRequest.mode` description and the `ValidateDataResponse` note say what an `update` preview now reads. - -The pending entry for the option-gate change in this release says an `update`-mode preview reads no stored row and refuses a cascade pick whose parent column the patch omits. That describes the preview before this change: with the row's `id`, the preview now reads the stored row and judges the pick as the write does. diff --git a/.changeset/pre/22450-flow-refusal-translation.md b/.changeset/pre/22450-flow-refusal-translation.md deleted file mode 100644 index 6522fe1a484..00000000000 --- a/.changeset/pre/22450-flow-refusal-translation.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-automation": minor -"@objectstack/runtime": minor -"@objectstack/lint": minor -"@objectstack/cli": minor ---- - -feat(i18n): a refused `end` node's message translates in the run's locale (`flows..refusals..message`) - -Clause-②: yes (widening) - -- **What is new.** An `end` node declaring `outcome: 'refused'` shows its `message` to the person who started the run. Translate it under `flows..refusals..message`, keyed by the flow's `name` and the `end` node's `id`, the same addressing the `screens` keys use. Before this key, a refusal rendered in the source language on every console. -- **Keep the holes.** The translation is a `{{ }}` template like the message it translates (`'已拒绝:{{ record.name }} 是重复记录'`). A single-brace `{token}` in a translation is refused when the bundle is parsed, with the same text-slot refusal the source message gets. -- **Where it is read.** The engine picks the translated template before it renders the holes, through the `i18n` service, in the run's locale, and the run stores and returns that text (`refusalMessage`). With no translation for that locale, the authored message renders. `flowRefusalMessageKey(flowName, nodeId)` (`@objectstack/spec/system`) spells the key. -- **The run's locale.** `AutomationContext.locale` (`@objectstack/spec/contracts`) is new and optional. The trigger door (`POST /api/v1/automation/:name/trigger`, the legacy trigger route, a `type: 'flow'` endpoint) and the action door (`POST /api/v1/actions/...`, the MCP `run_action` bridge) set it from the request's resolved locale: `Accept-Language` first, then the `localization` settings. It is persisted with a paused run, so a resumed leg renders in the locale the run was started in. A run no person started (a record-change or schedule trigger) has no locale and stores the authored message. -- **`objectstack validate`** refuses a refusal key over an unknown flow, an unknown node, a completed `end` node, or a node of another type (`translation-target-unknown`, error). -- **`os i18n extract` and the coverage gate** now scaffold and demand the refusal message of every refusing `end` node in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` and has a refusing `end` with no translation now gets one missing-key issue per locale for it. -- **`AutomationEngine.setI18nServiceSource()`** (`@objectstack/service-automation`) attaches the `i18n` reader. The automation plugin wires it, so a host using `AutomationServicePlugin` needs no change. A host constructing the engine directly gets the authored message until it attaches one. diff --git a/.changeset/pre/22455-cbp-parent-gates-judge-master.md b/.changeset/pre/22455-cbp-parent-gates-judge-master.md deleted file mode 100644 index e6db95bbf0c..00000000000 --- a/.changeset/pre/22455-cbp-parent-gates-judge-master.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/service-storage': minor -'@objectstack/plugin-audit': minor ---- - -fix(service-storage,plugin-audit)!: the `sys_attachment` and `sys_comment` parent gates judge a `controlled_by_parent` parent through its master, so a member can no longer attach files to, or delete other people's files or comments from, a child record they may not edit - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. It follows ADR-0055. - -**What stops being accepted.** - -- A `controlled_by_parent` record takes its access from its MASTER. Record sharing does not enforce on it, so the sharing service's edit verdict abstains on every such record, and both gates used to take that abstention as permission. A member holding the `sys_attachment` create or delete bit, or the `sys_comment` delete bit, could therefore attach files to, and delete other people's files and comments from, every master-detail child record of the organization. That included records whose own `PATCH` refuses the member, and records the member cannot read. -- Both gates now ask the sharing service's tri-state `checkEdit`. `allow` admits and `deny` refuses, as before. On an abstention they ask the security service's master-detail write check (`ISecurityService.checkControlledByParentWrite`), which is the answer a by-id update of the parent gets. Its `allow` and `not_applicable` admit. Its `deny` refuses, and so does an `unresolvable` outcome: a broken master-detail declaration, a record that does not exist, or an empty master reference. A datasource fault propagates with its `503`. -- The refusals keep today's envelopes. An attach or a re-point answers `403 ATTACHMENT_PARENT_ACCESS`. A delete of another user's file answers `403 ATTACHMENT_DELETE_DENIED`, and an update of it answers `403 RECORD_NOT_ACCESSIBLE`. A delete or update of another user's comment answers `403 RECORD_NOT_ACCESSIBLE`. A caller who cannot read the parent still gets the not-visible `403 PERMISSION_DENIED`. - -**What is unchanged.** An uploader still deletes their own file, and an author still deletes their own comment. Posting a comment still needs only read access to the record. A parent that is not `controlled_by_parent` is judged exactly as before, and a `public_read_write` parent still admits every member. A kernel whose security service does not serve the master-detail write check composes no master check, and there the gates admit on the abstention as before, which is also what the parent's own update gets. - -**What changes for you.** A member who was attaching or moderating on a child record through this gap now gets the refusal above. To give them that access, grant them edit access on the MASTER record: ownership of it, an `edit`-level record share, the `modifyAllRecords` bypass on the master object, or an app-authored `update` row-level security policy on the master that admits the row. Then their `PATCH` of the child and their attachment and moderation rights on it agree. The port types `AttachmentSharingLike` and `CommentSharingLike` now pick `checkEdit` rather than `canEdit`. The registered sharing service serves both, so a caller passing it is unaffected. `installAttachmentAccessHooks` and `installCommentAccessHooks` take an optional trailing resolver for the security service (new port types `AttachmentSecurityLike` and `CommentSecurityLike`). The two plugins pass it themselves. diff --git a/.changeset/pre/22455-security-serves-controlled-by-parent-write.md b/.changeset/pre/22455-security-serves-controlled-by-parent-write.md deleted file mode 100644 index cd18a4d27f3..00000000000 --- a/.changeset/pre/22455-security-serves-controlled-by-parent-write.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -The registered `security` service serves `checkControlledByParentWrite(object, recordId, context)`, the ADR-0055 master-detail write check that `ISecurityService` declares as optional. - -- **What it answers.** It answers what a by-id update of the record gets from the master-detail write check. It runs the write path's own composition and never re-derives it: the engine middleware's context prologue (a principal-less context, permission sets that cannot be resolved and a missing on-behalf-of delegator are refused with the same `403 PERMISSION_DENIED`), and then the check for the principal's permission sets and, on a delegated context, for the delegator's (ADR-0090 D10). The first refusal is the answer. A refusal resolves `deny` with the leg it came from, the three non-verdicts resolve `unresolvable` with their reason, and an object that is not `controlled_by_parent` resolves `not_applicable`. A system context resolves `allow`. A store fault rejects with the engine's own error, so a datasource outage keeps its `503`. -- **Where it differs from the write path's guard.** A principal whose permission sets resolve to none is judged over that empty list, so it answers `deny` on `object_permission` as the contract declares. A principal with positions or sets and no user id is also judged, where the write path skips the check. It answers `deny` when it holds no `update` grant on the master, and its own update is refused by the object-level gate anyway. -- **Who asks it.** The `sys_attachment` parent gate in `@objectstack/service-storage` and the `sys_comment` moderation gate in `@objectstack/plugin-audit` ask it, so they judge a `controlled_by_parent` parent through its master. -- The boot line that announces the `security` service now lists the members the registered service actually serves, read from the service object. The hand-kept list it replaced had fallen behind by five members. - -Nothing an author writes changes, and no by-id write is judged differently. The middleware's context prologue moved into one method that the middleware and the member both call. Its refusals, their order and their wording are unchanged. diff --git a/.changeset/pre/22459-hook-lowering-destructuring-alias.md b/.changeset/pre/22459-hook-lowering-destructuring-alias.md deleted file mode 100644 index 186311396a3..00000000000 --- a/.changeset/pre/22459-hook-lowering-destructuring-alias.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): a hook that destructures a renamed key off `ctx` is no longer refused as not-lowerable (#22459) - -Clause-②: no - -- **What changed.** The free-identifier scan behind `objectstack lint`'s `hook-body/not-lowerable` rule and `objectstack build`'s hook lowering counted a renamed destructuring key as a free identifier. For `const { event, input, previous: prev } = ctx`, it reported `previous`, a property read off `ctx`, although the only name the pattern binds is `prev`. So `objectstack lint` failed the hook with the advice "reach them through `ctx`", which the hook already did, and `objectstack build` shipped it as a bundled closure instead of a metadata-only body. A non-computed destructuring key is now a key, as the key of `{ key: value }` already was. Nested patterns (`{ a: { b: c } } = ctx`), patterns inside array patterns, the parameters of a callback inside the handler, and `catch` clauses are covered by the same rule. -- **What is still refused.** A destructuring default or a computed key that names something out of scope is still free: `const { key: alias = FALLBACK } = ctx` and `const { [KEY]: v } = ctx` report `FALLBACK` and `KEY`, as before. A genuinely free `previous` that is not read off `ctx` is still refused. -- **What to do.** Nothing. A hook written this way now lowers to a body on the next `objectstack build`, and `objectstack lint` stops reporting it. The un-renamed spelling (`const prev = ctx.previous`) keeps working as before. diff --git a/.changeset/pre/22463-sso-domain-verification-unknown-provider.md b/.changeset/pre/22463-sso-domain-verification-unknown-provider.md deleted file mode 100644 index 63c4ee1b8ea..00000000000 --- a/.changeset/pre/22463-sso-domain-verification-unknown-provider.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -With SSO domain verification on, `POST /api/v1/auth/admin/sso/request-domain-verification` and `POST /api/v1/auth/admin/sso/verify-domain` answer an unknown `providerId` with `404 RESOURCE_NOT_FOUND`, no longer with `400 DOMAIN_VERIFICATION_DISABLED` - -Clause-②: yes (widening) - -- **Before.** With `OS_SSO_DOMAIN_VERIFICATION` on, a platform admin who sent a `providerId` that does not exist got `400 DOMAIN_VERIFICATION_DISABLED`, "Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION)": an instruction to turn on a feature that was already on. `@better-auth/sso` answers both an unmounted route (feature off) and an unknown provider (feature on) with a `404` that carries no `code`, and both bridges mapped every such `404` to the feature-off answer. -- **Now.** The two mounts hand each bridge the environment's own setting (`AuthManager.isSsoDomainVerificationEnabled()`). Off: `400 DOMAIN_VERIFICATION_DISABLED`, as before, and the vendor is no longer called. On: a code-less vendor `404` is the provider lookup missing, answered `404 RESOURCE_NOT_FOUND` with a message naming the provider id. The decision never reads the vendor's `404` wording. -- **Unchanged.** The platform-admin gate answers first, on or off (an anonymous caller gets `401`). A missing `providerId` is still `400 INVALID_REQUEST`. With the feature on, an existing provider still gets the vendor's own answer: the DNS record, `NO_PENDING_VERIFICATION`, `DOMAIN_VERIFICATION_FAILED`, a `403` for a provider the caller may not manage. -- **The exported bridges.** `runRequestDomainVerification` and `runVerifyDomain` take an optional third argument, `{ domainVerificationEnabled?: boolean }`. A caller that omits it keeps the earlier mapping: a code-less `404` is answered `400 DOMAIN_VERIFICATION_DISABLED`, byte for byte. A host that mounts these helpers itself passes the environment's setting to get the not-found answer. diff --git a/.changeset/pre/22464-security-service-controlled-by-parent-write.md b/.changeset/pre/22464-security-service-controlled-by-parent-write.md deleted file mode 100644 index a04a97c325b..00000000000 --- a/.changeset/pre/22464-security-service-controlled-by-parent-write.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`ISecurityService` declares an optional, feature-detected member that answers the existing ADR-0055 master-detail write check: `checkControlledByParentWrite(object, recordId, context)`. - -Clause-②: yes (widening) - -- **What it answers.** It answers what the master-detail write check decides for an update of one record by the caller. A by-id write to a `controlled_by_parent` record requires edit access to its master, and the write path checks each master up the chain: the object-level `update` grant, the master's write row-level security, and record sharing. The answer equals what a by-id update of the same record gets from that check, the ADR-0090 D10 delegator leg included. It adds no verdict: it exposes the one the write path already reaches, so a gate on a record's attachments or comments can judge the record as its own update is judged. -- **What it resolves with.** `ControlledByParentWriteOutcome` is a discriminated union. `allow` means the check does not refuse. `deny` names the leg that refused (`ControlledByParentWriteDenialLeg`: `object_permission`, `row_level_security`, `record_sharing` or `master_chain`). `not_applicable` means the object is not `controlled_by_parent`. `unresolvable` names why the check reached no verdict (`ControlledByParentWriteUnresolvedReason`: a missing `master_detail` relation, a record that does not exist, or an empty master reference). A store fault is not an outcome: the member rejects with the engine's own error, so the fault keeps its declared status (a datasource outage is `503`). -- **Optional, and absence is typed.** A security service without the member still satisfies the contract, and an unguarded call does not compile. Absence means no master check is composed for a consumer to ask, which is not a policy that admits. - -Nothing an author writes changes, and no runtime behaviour changes. `@objectstack/plugin-security` does not serve the member yet. An implementation typed as `ISecurityService` that serves this name must serve it under the declared signature. diff --git a/.changeset/pre/22466-refused-attach-tombstone.md b/.changeset/pre/22466-refused-attach-tombstone.md deleted file mode 100644 index 66ad9a2fe5e..00000000000 --- a/.changeset/pre/22466-refused-attach-tombstone.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/service-storage': patch ---- - -fix(service-storage): an attach the attachment gate refuses no longer leaves the uploaded file stored forever — the caller's own never-attached file is tombstoned and reclaimed by the sweep - -Clause-②: no - -Attaching a file is two writes: the upload commits a `sys_file` (scope `attachments`), and then a `sys_attachment` insert attaches it to a record. When the attachment gate refused that insert with `403 ATTACHMENT_PARENT_ACCESS`, the file stayed `committed` with no attachment pointing at it. Nothing ever reclaimed it: files were tombstoned only when they lost their last attachment, and this one never had one. - -- **Now:** when the attachment gate refuses an attach, the file is tombstoned (`status: 'deleted'`, `deleted_at` set), the same way a file is tombstoned when its last attachment is removed. This covers every way the gate refuses: sharing denies edit on the parent, the parent's master record denies it (`controlled_by_parent`), or the parent cannot be read. From there the file follows the existing path. The lifecycle sweep reclaims the row and its bytes 30 days after `deleted_at`. At sweep time it checks again that nothing holds the file. -- **Only the caller's own unheld upload:** the file must be an `attachments`-scope, `committed` file. The refused caller must be its uploader (`sys_file.owner_id`). It must have no attachment and no field owner (`ref_*`). A refused attach that names someone else's file, a file another record still holds, or a field file changes nothing. -- **It survives a rollback:** the tombstone is written after the refusal, outside the refused write's transaction. If the attach ran inside a caller's own transaction, an `atomic` batch for example, rolling that transaction back does not undo the tombstone. -- **Unchanged:** the refusal itself (status, code, message), which does not depend on the file and says nothing about it; an admitted attach; and a retry. Retrying the same `file_id` within the 30 days is admitted or refused like any attach. An admitted retry attaches the file and brings it back to `committed`, as re-attaching a detached file always has. -- **Not covered:** an attach refused before the attachment gate runs still leaves the uploaded file `committed`. That is an attach with no `sys_attachment` create grant (`403 PERMISSION_DENIED`), or one to a parent with `enable.files` off (`403 FILES_DISABLED`). This is tracked in #22547. - -What changes for you: nothing to do. When the attachment gate refuses an attach, the uploaded file no longer uses storage indefinitely. diff --git a/.changeset/pre/22470-upload-scope-vocabulary.md b/.changeset/pre/22470-upload-scope-vocabulary.md deleted file mode 100644 index af4d3f2fda6..00000000000 --- a/.changeset/pre/22470-upload-scope-vocabulary.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-storage': patch -'@objectstack/client': minor ---- - -feat(spec,service-storage,client)!: one upload-scope list — the upload requests' `scope` and the SDK's `storage.upload` close to the new `UploadScope` enum, the `sys_file` scope select is built from it, and the upload doors answer any other scope with `400` naming the allowed values instead of `500 INTERNAL` (#22470) - -Clause-②: yes (narrowing) - - - -**BREAKING** — an accept-set narrowing on a published request contract and on the SDK method that sends it, shipped as `minor` under the launch-window convention for accept-set narrowings, plus one new export. - -The presigned and chunked upload requests declared `scope` an open string, while the stored file record (`sys_file.scope`, a closed select) only ever took `user`, `tenant`, `private`, `temp` and `attachments`. Any other value reached the `sys_file` insert, the data engine refused it as an invalid option, and the upload door answered that caller error as `500 INTERNAL`, with a message telling the operator to restore the data engine. - -### What changes - -- **`UploadScopeSchema` / `UploadScope`** (`@objectstack/spec`, new, exported from `@objectstack/spec/api`): the upload-scope vocabulary, declared once — `user`, `tenant`, `private`, `temp`, `attachments`. It is a different list from `StorageScopeSchema`, which classifies a storage configuration and is not read by any upload. -- **`GetPresignedUrlRequestSchema.scope` and `InitiateChunkedUploadRequestSchema.scope`** read it. The default stays `user`. A literal outside the list fails `tsc`, and a parse refuses it on the `scope` key. -- **`client.storage.upload(file, scope)`** (`@objectstack/client`): the `scope` parameter is typed `UploadScope` instead of `string`, default `user` unchanged, so a scope outside the list fails `tsc` at the SDK call. `client.storage.getPresignedUrl` and `client.storage.initChunkedUpload` take the request types above and narrow with them. -- **The `sys_file` scope select** (`@objectstack/service-storage`) takes its options from the enum, in its order, with the same labels. The stored values do not change. -- **The presigned upload door and the chunked upload door** answer a scope outside the list — any string, a case variant, `null`, a number — with `400 INVALID_REQUEST`, naming the allowed values, before a file record, a session record, an upload URL or a backend upload exists. `public` is refused by the same gate and keeps its remedy (`acl: 'public_read'` on the stored file record). An omitted scope is the default `user`, as before. A real data-engine fault still answers `500`. - -### FROM → TO - -| before | what to write instead | -| --- | --- | -| an upload naming a scope outside the list (a key prefix such as `avatars`, a folder, a record path) | one of `user`, `tenant`, `private`, `temp`, `attachments`, or no `scope` for the default `user` | -| `client.storage.upload(file, scope)` called with a `scope` typed `string` (`@objectstack/client`) | pass one of the five, or type the value `UploadScope` (`import type { UploadScope } from '@objectstack/spec/api'`) | -| a caller passing a scope typed `string` into either upload request | type it `UploadScope` | - -**The one-line fix: send one of the five upload scopes, or none.** `attachments` is for a file whose referrers are record attachment rows (it is what orphan tombstoning reads); `temp` for a scratch file; otherwise `user` or `tenant`. - -**No stored file record moves**: no upload naming another scope ever succeeded, so no stored record carries one. - -### The kit - -- **D3 entry `upload-request-scope-closed`** carries the judgement no rewrite can make: which scope a file that was sent under a free name really belongs under. No D2 conversion: no metadata type carries the upload request, so `os migrate meta` has no authored source to rewrite. -- **Liveness.** No ledger row: the liveness ledger walks metadata types, and no metadata type carries the upload request. diff --git a/.changeset/pre/22472-spec-record-approval-decision.md b/.changeset/pre/22472-spec-record-approval-decision.md deleted file mode 100644 index 27077185bca..00000000000 --- a/.changeset/pre/22472-spec-record-approval-decision.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`record:approval_decision` is a declared page component type — the approval decision panel of a `sys_approval_request` record page — with a `ComponentPropsMap` row that accepts no props - -Clause-②: yes (widening) - -- **New `PageComponentType` member:** `record:approval_decision`, in the reserved `record` namespace. One node shows an approval request's decision progress and its declared decision actions. It reads the record context (the request row the page binds, with its `viewer` and `decision_progress`) and `sys_approval_request`'s own declared actions, and nothing authored. Outside a `sys_approval_request` record page it draws nothing. -- **Its `ComponentPropsMap` row is an empty strict object.** A node with no `properties`, or `properties: {}`, passes. Any key inside `properties` is refused by name: `os validate` / `os build` / `os lint` report it as `component-props-unknown-key`, naming the component and the key. Node-level keys (`id`, `className`, `visibleWhen`, …) stay on the node as for every component. -- **A misspelling is refused.** Because `record` is a reserved namespace, `record:approval_decison` and other near spellings are `component-type-unknown` errors that offer `record:approval_decision`. -- **Not printable.** Inside a page that declares `print`, the type is refused with its own reason (decision controls that differ by viewer, with nothing to print), like the other action-control blocks. -- **Consumers.** objectui renders it (objectui#12045, the approvals request page), and `@objectstack/plugin-approvals` places it on the request page it ships (objectstack#22473). A consumer that derives coverage from `PageComponentType.options` — a designer palette, a renderer registry — sees one more member to classify. diff --git a/.changeset/pre/22473-approval-request-page.md b/.changeset/pre/22473-approval-request-page.md deleted file mode 100644 index 898cd54b8a2..00000000000 --- a/.changeset/pre/22473-approval-request-page.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/plugin-approvals': minor ---- - -feat(plugin-approvals): serve the approval request's record page, and declare the thread reply as `approval_comment` - -Clause-②: no - -**The request's record page is now metadata this plugin serves.** `ApprovalsServicePlugin` registers `sys_approval_request_detail` through its manifest `pages`: a slotted record page, default for every `sys_approval_request` record, that the metadata API serves like any other page. Its slots: - -- `actions`: one `record:approval_decision` node (the decision panel: the request's decision progress and its declared decision actions); -- `highlights`: the object's `highlightFields` without `record_id`, so `record_id` stays in the details grid, where the console draws the target record of the `record_id` / `object_name` pointer pair. The object's own `highlightFields` is unchanged; -- `tabs`: Details (`record:details`) and Timeline (`sys_approval_action` as a related list on `request_id`, newest first); -- `discussion: []`: no generic discussion thread. The request's thread is its timeline. - -The page label is translated in the plugin's bundle as `pages.sys_approval_request_detail.label` (en, zh-CN, ja-JP, es-ES). The page stays module-internal: nothing new is exported from the package entry. - -**`approval_comment` declares the thread reply.** `sys_approval_request` now declares a ninth action, `approval_comment` ("Reply"). It posts `comment` (a required textarea) and `attachments` (optional, several files) to `POST /api/v1/approvals/requests/{id}/comment`, the route the console drawer posted to by hand. The service stores the reply as a `sys_approval_action` row with action `comment`, which the Timeline lists. Its `visible` gate is the route's own admission: a pending approver (`record.viewer.can_act`), or the submitter while the request is `pending`. It has no admin-override arm, because the comment route admits none. diff --git a/.changeset/pre/22474-preview-binds-parent.md b/.changeset/pre/22474-preview-binds-parent.md deleted file mode 100644 index 6fcb244db2f..00000000000 --- a/.changeset/pre/22474-preview-binds-parent.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -A `validate()` preview binds the master-detail header the write binds, so an import dry run of a detail row whose field rule reads `parent` admits the rows the import admits (#22474) - -Clause-②: yes - -The preview's accept set widens: rows it refused, and the write admits, are now admitted. No key, export or error code is added, removed or renamed. - -- **Before.** A preview (`engine.validate(object, rows, { mode })`, `validateData`, and the import dry run) bound no master-detail header. A field `requiredWhen` that reads the header, such as `parent.status == 'sent'` on an invoice line, faulted there and the preview refused the row with `rule_violation` / `unevaluable`, in both modes, although the insert and the update resolve the header and admit it under a draft header. -- **Now.** The preview binds `parent` as the write binds it: from the row's master-detail reference on an insert, and on an update from the reference the patch carries, else from the stored row the row's `id` names. An update that moves the row to another header also binds the header the stored row hangs off, which the write reads to decide whether the stored row already violated the rule. So a row under a draft header is admitted and a row that leaves the field empty under a sent header is refused with `required`, as the write refuses it. Only an object that declares a field `requiredWhen` reading `parent` reads a header, as on the write. -- **Read under the caller's access.** The write reads the header with system access, kept to the caller's organization. The preview first reads it through the engine's read door, under the caller's own context: a header the caller cannot read gets the verdict a missing header gets. A header column is judged only where the read door served this caller that very value. A column the read door hid, or served transformed (a partially masked field, a masked secret), is judged as empty, so the verdict never depends on a header value the caller could not read in full. For such a caller the preview can therefore answer differently from the write, which judges the stored header. -- **Unchanged.** An object with no field `requiredWhen` reading `parent` reads no header and gets the verdict it got before. The preview still runs no `readonlyWhen` strip, so an update whose `readonlyWhen` would keep the row on its stored header is judged against the header the patch names. diff --git a/.changeset/pre/22477-flow-text-slot-dollar-root-refused.md b/.changeset/pre/22477-flow-text-slot-dollar-root-refused.md deleted file mode 100644 index 879ea748c7f..00000000000 --- a/.changeset/pre/22477-flow-text-slot-dollar-root-refused.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': patch ---- - -A `{{ }}` hole in a flow text slot (a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`) whose root is a `$` name the flow engine does not bind is refused. It is refused at the same doors, and by the same judge (`textSlotTemplateRefusal`), as a single-brace token: the node contracts, `registerFlow` and `objectstack validate`. `'By {{ $User.Id }}'` used to pass all three and send `'By '`. - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. - -**Why.** The hole grammar admits `$` in a name so that the engine's own variables have a spelling (`{{ $error.message }}`). That also makes `{{ $User.Id }}` a well-formed hole, but over a root no flow variable answers to. The text went out with the fragment missing, the run reported success, and nothing warned. Its single-brace spelling, `{$User.Id}`, was already refused with a remedy. The `$` names are reserved for the engine: a resume signal may not write one. - -**What is refused.** - -- A hole whose root is a `$` name other than the variables the engine binds: `$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`. - - `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` raise a `custom` issue at the slot's key. - - `registerFlow` refuses the flow, and a stored flow carrying such a hole is skipped at boot with a warn naming it. - - `objectstack validate` reports `expression-invalid` at `error`. -- The remedy for `{{ $User. }}` is the sentence `{$User.}` gets: for `$User.Id`, compute the run user's id into a variable with an `assignment` node's CEL value envelope (`current_user.id`), then write the variable as a hole; any other `$User` path never resolved, and the refusal says how to read the user record by that id. Any other root is named in the refusal, beside the variables the engine does bind. -- A single-brace path token over such a root (`'Failed: {$caught.message}'`) is no longer prescribed the `{{ }}` spelling, which would be refused in turn; it gets the same remedy. - -**Unchanged.** `{{ $error.message }}`, `{{ record.name }}`, a node output `{{ lookup.result }}` and every hole over an engine-bound `$` variable. The template engine binds no new variable. - -**`@objectstack/lint`.** In a text slot, `flow-bare-dollar-reference` prescribes the hole for a bare `$X.y` written outside the holes only when the judge admits that hole. A bare `$User.Id` gets the judge's refusal and remedy instead of a `{{ $User.Id }}` the judge refuses. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `message: 'By {{ $User.Id }}'` | an `assignment` node first, `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `message: 'By {{ by }}'` | -| `errorVariable: '$caught'` with `message: 'Failed: {{ $caught.message }}'` | `errorVariable: 'caught'` with `'Failed: {{ caught.message }}'`, or keep the default `$error` and write `{{ $error.message }}` | - -**The one-line fix: compute a run-user value into a variable first, and name a variable the flow binds itself without the `$`.** - -**Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), has no text-slot judge. Its `NotifyConfigSchema.title` / `.message`, `ScreenConfigSchema.title` / `.description` and `EndConfigSchema.message` are plain strings, so it accepts `'By {{ $User.Id }}'` in every one of these slots. Its single-brace interpolator substituted the inner `{ $User.Id }` token and left a literal brace on each side. This repository was measured with `git grep` over `examples`, `packages`, `skills`, `apps` and `content`: no flow text slot outside tests carries a `{{ $… }}` hole other than `{{ $error.… }}`. Deployed metadata and other repositories were not measured. - -### The kit - -- **The refusal.** `textSlotTemplateRefusal` in `automation/flow-text-slot-template.ts` reads one package-internal list of the `$` variables the engine binds. `@objectstack/service-automation`'s `text-slot-template.test.ts` scans that package's sources for every `$` variable they bind by name, and fails when the list misses one. -- **The ledger.** The D3 semantic entry `flow-text-slot-unbound-dollar-root-refused` (protocol 18). There is no D2 conversion: what the hole was meant to read is not in the flow. diff --git a/.changeset/pre/22481-attached-block-served-rows.md b/.changeset/pre/22481-attached-block-served-rows.md deleted file mode 100644 index 357e24beb13..00000000000 --- a/.changeset/pre/22481-attached-block-served-rows.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/lint": patch -"@objectstack/spec": patch ---- - -A declared read attachment resolves only where `record` is a row a service serves, so `os validate` refuses a flow condition that reads one - -Clause-②: no - -`ObjectSchema.attachedOnRead` declares the blocks a service attaches to the rows it serves, computed per caller and never stored. An earlier entry in this release adds each declared block to the names `record.` resolves to, at every expression site bound to the object. That covered the sites whose `record` is the stored row, which never carries a block. There `os build`, `os validate` and the object save door accepted a read such as `record.viewer.can_act`, and the expression then faulted with `No such key: viewer` on every row. A record-change flow on `sys_approval_request` whose start condition read `record.viewer.can_act == true` validated, then failed on every record it fired for. - -A declared block now resolves, and its leaves are judged, only at the sites whose `record` is a served row: an action's `visible` and `disabled` predicates. - -- **Refused now.** On an object that declares a block, `record.BLOCK` at any other site gets the refusal it gets on an object that declares none: ``unknown field `viewer` on `sys_approval_request` ``, at `error`. Those sites are a flow's node and edge conditions, a validation rule, a field's `requiredWhen`, `readonlyWhen` and `visibleWhen`, an option's `visibleWhen`, a field formula, a sharing-rule condition and a hook condition. The block is no longer offered as a "did you mean?" candidate there either. The object save door runs the same rule over an object's own slots, so an object write in publish mode that reads a block outside an action predicate is refused with an `expression-invalid` issue. -- **Unchanged.** At an action predicate a declared leaf is accepted, and a misspelt leaf is refused with a message that names the leaves the block declares. `sys_approval_request`'s eight action predicates pass. No refusal code is added, and no export or signature moves. -- **`@objectstack/spec`.** The description of `ObjectSchema.attachedOnRead`, which the JSON schema and the reference pages carry, now says where the validator reads the key: in an action's `visible` and `disabled` predicates `record.` resolves and its leaves are judged, and every other expression site binds the stored row and refuses `record.` as an unknown field. The schema and its parse verdicts are unchanged. -- **Reach.** The only shipped object that declares a block is `sys_approval_request`, and its action predicates are the only shipped expressions that read it. The earlier entry has not been released, so no released version accepted a block read at these sites. diff --git a/.changeset/pre/22482-spec-projections-generated-at-publish.md b/.changeset/pre/22482-spec-projections-generated-at-publish.md deleted file mode 100644 index 73bbbb8e4e2..00000000000 --- a/.changeset/pre/22482-spec-projections-generated-at-publish.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): the protocol upgrade guide ships inside `@objectstack/spec`, and both ADR-0087 projections are generated and verified at publish - -Clause-②: no - -`@objectstack/spec` now ships `protocol-upgrade-guide.md` beside `spec-changes.json`. An upgrading project reads the guide for the exact version it installed, offline: - -```bash -cat node_modules/@objectstack/spec/protocol-upgrade-guide.md -``` - -The same file is attached to the package's GitHub Release, next to `spec-changes.json`. Its content is unchanged: one section per protocol major from the chain's support floor on, generated from the ADR-0087 conversion table and migration chain. - -Both projections are now generated by the release lane, from the registries, right before the tarball is packed. The lane refuses to publish an artifact whose copies differ from a fresh generation. This covers the per-major records and aggregate of `spec-changes.json`, and the whole guide. The rc lane takes the same step, so an rc tarball no longer carries a copy that was committed earlier. The per-release `release` section of `spec-changes.json` is generated and verified exactly as before. - -Nothing changes for an author, and nothing is renamed or removed. diff --git a/.changeset/pre/22483-upgrade-guide-docs-address.md b/.changeset/pre/22483-upgrade-guide-docs-address.md deleted file mode 100644 index 63b2c70e3e6..00000000000 --- a/.changeset/pre/22483-upgrade-guide-docs-address.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -docs(spec): the protocol upgrade guide has a public address, one docs-site page per protocol major - -Clause-②: no - -The guide is published on the docs site, one page per protocol major, at `https://objectstack.ai/docs/protocol-upgrade/N`; `https://objectstack.ai/docs/protocol-upgrade` lists them all. A link to one major keeps resolving after the next major opens. The site builds from `main`, so each page says whether its major is released, in prerelease, or not released yet. - -`docs/protocol-upgrade-guide.md` in the repository, which earlier READMEs and changelog entries point at, is now a short pointer naming that address. - -What a release shipped is unchanged: `protocol-upgrade-guide.md` inside the package and attached to its GitHub Release. Its only change is the header comment, which now names the command that writes the file. - -Nothing changes for an author, and nothing is renamed or removed. diff --git a/.changeset/pre/22491-chart-list-view-binds-a-dataset.md b/.changeset/pre/22491-chart-list-view-binds-a-dataset.md deleted file mode 100644 index e0ca0c962d5..00000000000 --- a/.changeset/pre/22491-chart-list-view-binds-a-dataset.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/spec': major ---- - -A `type: 'chart'` list view must bind a dataset: a view whose effective chart binding names no `dataset` is refused at every list-view door, at `chart` (no binding at all) or at `options.chart.dataset` / `options.chart.values` (an incomplete legacy bag), with the binding to declare. - -Clause-②: yes (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface and on the view write door, graded `major` on `@objectstack/spec`: Changesets is in pre mode on `main` (tag `next`), where the launch-window `major` guard stands aside for the line's breaking changes. - -**Why.** A chart list view plots only the ADR-0021 `dataset` its binding names. The renderer reads that binding as the top-level `chart` block, else the legacy `options.chart` bag, the block replacing the bag whole. The `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema: the view write door (`PUT /api/v1/meta/view/:name`) saved `type: 'chart'` with no `chart` block, and an `options.chart` bag holding only `chartType`, and `defineStack` / `os validate` accepted the block-less view. Such a view renders a dead screen: the renderer either guessed a binding nobody wrote or, since objectui retired that guess, refuses on screen. - -**What is refused.** A list view whose `type` is `chart` and that: - -- declares no `chart` block and no `options.chart` bag: one `custom` issue at `chart`, whose message begins *This list view is `type: 'chart'` but declares no `chart` block, so it binds no dataset and there is nothing to plot.*; -- declares no `chart` block and an `options.chart` bag with no `dataset` or no `values` (the bag is legal on the flattened overlay only): one `custom` issue per missing key, at `options.chart.dataset` / `options.chart.values`. - -It is a check on the list-view schema itself, so it reaches every door that parses a list view: `defineView`, `defineStack`, `os validate` / `os build`, a view item's `config`, and the metadata write door, which answers `422 INVALID_METADATA`. **New export:** `checkListViewChartBinding`, published from `@objectstack/spec/ui`, is this refinement check itself, a `(view, ctx) => void` function; objectui's `ListViewSchema` mirror, which is built from `ListViewSchema.shape` and so drops the schema's object-level checks, attaches it with `.superRefine(checkListViewChartBinding)`. - -**What stays accepted, byte for byte.** A chart view whose `chart` block names a `dataset` and at least one measure in `values`; a chart overlay whose `options.chart` bag carries both and no `chart` block replaces it; an incomplete bag under a complete `chart` block, which replaces it whole; a flattened overlay patch that names no `type`; and every view of another type, including a grid that only offers a chart in `appearance.allowedVisualizations`. - -**What to do.** Bind the chart: declare a top-level `chart` block naming the dataset to plot and at least one of its measures, for example `chart: { dataset: 'lead_metrics', values: ['amount_sum'] }`, with `dimensions` (the X / group axis) optional and `chartType` defaulting to `bar`. A view that carries its binding in the legacy `options.chart` bag either completes the bag or, preferred, moves it to the top-level `chart` block. A view that is not meant to be a chart takes another `type`. No conversion can do this for you: only the author knows which dataset a chart plots. - -**Stored views.** A stored `view` row is neither rewritten nor refused on read: it is served as stored, carries the same issue in its read-side `_diagnostics`, and is refused on its next save. - -**Who is affected, measured.** No chart list view without a binding exists in this repository: the two chart list views in `examples/app-showcase` and the chart list views in the `@objectstack/lint` fixtures all bind a dataset and a measure. Deployed metadata was not measured. - -### The kit - -- **The refusal.** `checkListViewChartBinding` in `ui/view.zod.ts`, attached beside the calendar binding check at the three list-view doors: `ListViewSchema`, `ObjectListViewSchema` and the flattened list overlay member of the view write door. The `chart` slot's description now says a chart view must bind one, and the generated reference page carries it. -- **The ledger.** The D3 semantic entry `view-chart-binding-dataset-required` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion. diff --git a/.changeset/pre/22497-master-chain-docblock.md b/.changeset/pre/22497-master-chain-docblock.md deleted file mode 100644 index f065f6929ed..00000000000 --- a/.changeset/pre/22497-master-chain-docblock.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/plugin-sharing': patch ---- - -docs: four contract docblocks now describe what the code decides. No type, schema or runtime change. - -Clause-②: no - -- **`ControlledByParentWriteDenialLeg` (`@objectstack/spec`).** The `master_chain` leg has three resolution refusals: no relation to derive from, a row that is not present, and an empty master reference. Each names the master whose own master the walk could not derive. That master is the record's own master, when it is itself `controlled_by_parent`, or any master above it. The docblock said only a master above the record's own master. The next sentence now says "past the record itself" where it said "above the first hop", so the two agree. The `record_not_found` bullet of `ControlledByParentWriteUnresolvedReason` had the same first-hop gap for a missing master row: it now says an absent row of a master that is itself `controlled_by_parent` is the `master_chain` leg, on the first hop or above it, and the legs judge a missing row only where the master governs its own rows. -- **`ISharingService.canEdit` (`@objectstack/spec`) and `SharingService.canEdit` (`@objectstack/plugin-sharing`).** The caller lists no longer name the `sys_attachment` and `sys_comment` parent gates. Those gates read `checkEdit` and the master-detail write check. The `ISharingService.canEdit` sentence on how they reach the service now names `checkEdit`. - -`ControlledByParentWriteDenialLeg`, `ControlledByParentWriteOutcome`, `canEdit` and `checkEdit` keep their declared shapes, and no verdict, outcome or refusal changes. diff --git a/.changeset/pre/22502-flow-binding-variable-dollar-name-refused.md b/.changeset/pre/22502-flow-binding-variable-dollar-name-refused.md deleted file mode 100644 index 554708d637b..00000000000 --- a/.changeset/pre/22502-flow-binding-variable-dollar-name-refused.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/spec': major ---- - -A flow node's `outputVariable` (`get_record`, `create_record`, `map`, `script`, `subflow`) refuses a variable name that starts with `$`, and a `try_catch` node's `errorVariable` refuses every `$` name except the engine's own `$error`, its default. The refusal names the remedy: the same name without the `$`, read as `{{ name }}`. - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** The `$` names are the flow engine's own variables: it binds `$record`, `$runId`, `$flowName`, `$flowLabel` and `$error`, a flat-graph `loop` binds `$loopItems` and `$loopIndex`, and a resume signal may not write any `$` name. A flow text slot already refuses a `{{ }}` hole whose root is a `$` name the engine does not bind, and tells the author to drop the `$`. But the binding keys took any string, so a flow could bind `errorVariable: '$caught'` and then be refused `'Failed: {{ $caught.message }}'`. The two doors of one contract gave two answers. Now both give the text slot's answer. - -**What is refused.** - -- `outputVariable` on `GetRecordConfigSchema`, `CreateRecordConfigSchema`, `MapConfigSchema`, `ScriptConfigSchema` and `SubflowConfigSchema`: a name that starts with `$`. That includes the engine's own names, since a binding over `$record` would overwrite the trigger record for the rest of the run. -- `errorVariable` on `TryCatchConfigSchema`: a name that starts with `$`, other than `$error`. -- Each key states the rule as a JSON Schema `pattern`, so the published `json-schema/**` refuses what the parse refuses. The parse issue is an `invalid_format` (regex) issue at the key, and its message names the remedy. -- `FlowSchema.parse`, `registerFlow` and `objectstack validate` refuse the flow at `nodes.N.config.outputVariable` / `nodes.N.config.errorVariable`, inside a region body too (`nodes.N.config.try.nodes.M.config.outputVariable`). A stored flow carrying one is skipped at boot with a warn naming it, and each executor's own contract parse refuses the node at run time. - -**Unchanged.** `errorVariable` absent (it defaults to `$error`) or an explicit `errorVariable: '$error'`; any name that does not start with `$`, a `$` later in the name (`a$b`) included; an empty string, which every executor reads as no binding; and every hole the text-slot judge already admits. Non-string values keep the type refusal they had. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `errorVariable: '$caught'` with `message: 'Failed: {{ $caught.message }}'` | `errorVariable: 'caught'` with `'Failed: {{ caught.message }}'`, or drop `errorVariable` and write `{{ $error.message }}` | -| `outputVariable: '$lead'` with `title: '{{ $lead.name }}'` | `outputVariable: 'lead'` with `title: '{{ lead.name }}'` | - -**The one-line fix: name the variable without the `$`, and rename every read of it with it.** - -No D2 conversion rewrites the name: the bare name may already be bound in the flow, and the reads of the old name sit in every dialect a flow string speaks (a text-slot hole, a CEL expression), so the rename is the author's. - -**Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), types all six keys as plain strings and accepts any `$` name. This repository was measured with `git grep` over `packages`, `examples`, `skills`, `apps`, `content` and `docs`: the only `$`-named `errorVariable` / `outputVariable` other than `$error` were three test fixtures, all renamed here (`errorVariable: '$err'` in two `packages/spec` automation tests, `errorVariable: '$caught'` in a `service-automation` test). Every authored `errorVariable` in examples and docs is `$error`, which stays legal, and no example or doc binds a `$`-named `outputVariable`. The pinned `objectui` checkout uses `errorVariable: '$error'` only. Deployed metadata and other repositories were not measured. - -### The kit - -- **The rule.** `flowBoundVariableNameSchema` in `automation/flow-bound-variable-name.ts`, package-internal (no new public export), composed into the six keys. It reads the `$` prefix the engine's closed list implies, not a copy of the list. -- **The ledger.** The D3 semantic entry `flow-binding-variable-dollar-name-refused` (protocol 18), with no D2 conversion. diff --git a/.changeset/pre/22506-migrate-composes-boot.md b/.changeset/pre/22506-migrate-composes-boot.md deleted file mode 100644 index 997ce0a72cb..00000000000 --- a/.changeset/pre/22506-migrate-composes-boot.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/driver-sql': patch ---- - -fix(cli): `os migrate plan` / `apply` / `unmapped-columns` examine what `os serve` mounts around the stack, so the retired `sys_account.issuer` is finally a drop (#22506) - -`os migrate plan` and `os migrate apply` only examine the objects their own boot registers, and that boot stopped at the stack: its config, its metadata and the platform floor. Nothing `os serve` mounts around a stack was there. On an app declaring `requires: ['auth']`, the plan examined 9 of the 68 tables the serving boot had created. `sys_account` read as an undeclared platform table, so its retired `issuer` column (and that column's unique index) was never a destructive drop. `os migrate apply --allow-destructive` dropped nothing, while the boot's drift line and `os migrate account-issuer` kept prescribing exactly that command. - -The two commands now also compose, each through the rule `os serve` itself reads, and so does `os migrate unmapped-columns`, which reads the plan's own `unmapped_column` findings and therefore needs the plan's object set: - -- the auth family behind `serve`'s auth gate: plugin-auth's identity objects, the security plugin and the audit plugin, when the stack mounts no `AuthPlugin` of its own, the `auth` tier is on, and an auth secret resolves; -- the provider of every capability `serve` mounts: the stack's `requires` and the always-on slate, as `os serve` composes it with no `--preset`; -- the REST API plugin; -- every `plugins` entry by `serve`'s rule for one: a package name is loaded from the app, and a plain bundle is wrapped as an app; -- the pinyin-search decision from the config's locales, so the `__search` companion columns a boot provisions are no longer listed as destructive drops on a config-only project. - -`os migrate unmapped-columns --object sys_account` (or any platform object the plan now declares) resolves the object and reads its unmapped columns, where it used to answer `OBJECT_NOT_FOUND`. - -Each piece is composed for its declarations only. Its `init()` runs, and its `start()` and lifecycle hooks do not, so no dispatcher, scheduler or seed runs inside a dry run. - -**What an operator sees.** On the shape that looped, `os migrate plan` lists `sys_account.issuer` and `uniq_sys_account_issuer_account_id` as destructive drops. `os migrate apply --allow-destructive` drops both, `os migrate account-issuer` reads zero, and the next boot prints no drift line for them. When no auth secret is set and the boot is not a development one, the plan says it did not compose the auth family, and how to compose it: run with the deployment's `OS_AUTH_SECRET` exported. `os migrate` reads no `.env` file. - -**driver-sql.** The deferred-DDL preview answers a rotation-declared object (`lifecycle.storage.strategy: 'rotation'`, such as `sys_activity`) from the rotator's facts: when the current shard and the read view exist, nothing is pending. It used to list `create_table` for such an object on every plan, and `apply` then created nothing. - -**Known limit.** A development boot, or one with `OS_TELEMETRY_DB` set, keeps lifecycle-classed objects in a sibling `telemetry` database. The one-shot migration boot does not provision that database, so it examines those objects against the primary one. diff --git a/.changeset/pre/22507-flow-options-toasts-translation.md b/.changeset/pre/22507-flow-options-toasts-translation.md deleted file mode 100644 index d7f469a47b5..00000000000 --- a/.changeset/pre/22507-flow-options-toasts-translation.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/lint": minor -"@objectstack/cli": minor ---- - -feat(i18n): a screen field's option labels and a flow's terminal toasts have translation keys (`flows..screens..fields..options.`, `flows..successMessage` / `.errorMessage`) - -Clause-②: yes (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. Two options of one screen field whose values read the same as text — `1` and `"1"`, `true` and `"true"`, or a repeated value — are now refused when the flow is parsed (`FlowSchema`, `objectstack validate`, the metadata save door, `registerFlow`), at the second option's `value`. Neither the option's label translation (keyed by the value as text) nor the console's select can tell such a pair apart. To fix a refused flow, give each option of the field a value that stays distinct as text, or drop the duplicate. Measured reach in this repository: 0 — no flow in its examples carries such a pair. - -- **What is new.** A fully translated screen flow no longer has to keep its select options and its completion or failure toast in the source language. Translate an option's label under `flows..screens..fields..options.`, and the flow's own toasts under `flows..successMessage` and `flows..errorMessage`. -- **How an option is addressed.** By its value read as text: `String(value)`, so the option `{ value: 1, label: 'Tier 1' }` is translated under `options.1` and `{ value: true, … }` under `options.true`. `flowScreenFieldOptionKey(value)` (`@objectstack/spec/automation`) spells the key. Not by position (reordering options would re-point every translation), and not by an object field's options (a screen field declares no such binding). -- **One new refusal**, the narrowing the BREAKING note above describes: option values of one screen field must stay distinct as text, because the label translation is keyed by them. -- **The toasts translate only where the flow authors one.** A bundle cannot add a toast to a flow that declares none; `objectstack validate` refuses such a key (`translation-target-unknown`, error). -- **Where it is read.** `translateFlow` (`@objectstack/spec/system`) overlays both, and `resolveFlowScreenFieldOptions(bundle, flowName, nodeId, field)` resolves a served screen field's options. The console's flow runner reads them in a following release; until then a translation is stored and the authored text is drawn. -- **`objectstack validate`** warns on an option key that names no declared option of the field, or names it by its label (`translation-option-key-unknown`). -- **`os i18n extract` and the coverage gate** now scaffold and demand each screen field's option labels, the failure toast of every flow that declares one, and the completion toast of a flow with a screen, in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` gets one missing-key issue per locale for each until it translates them. -- **A screen's `description`** is keyed too, in the same release, and translated by the engine rather than the console: see the entry for `flows..screens..description`. diff --git a/.changeset/pre/22507-screen-copy-engine-pick-automation.md b/.changeset/pre/22507-screen-copy-engine-pick-automation.md deleted file mode 100644 index 7bf16d95f13..00000000000 --- a/.changeset/pre/22507-screen-copy-engine-pick-automation.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/service-automation": minor ---- - -feat(automation): a screen's heading and body text render in the run's locale - -Clause-②: yes (widening) - -- **What is new.** The `screen` executor serves a screen's `title` and `description` in the language of the person who started the run. For each, it asks the engine for the translated template at `flows..screens..title` / `.description` through the `i18n` service, in `AutomationContext.locale`, and fills the `{{ }}` holes of that template. This is the same pick a refusing `end` node's message already makes, through the same channel (`setI18nServiceSource`). -- **When the authored text renders.** With no locale on the run (a record-change, schedule or webhook trigger), no `i18n` service, no entry or an empty one, or a translation that does not compile, the authored template renders as before. A translation that does not compile also logs a `warn` naming the key. A resumed leg renders in the locale the run was started in. -- **The heading key covers the node label.** A screen with no `config.title` shows its node label, and a translated `title` replaces whichever heading the screen shows. The body text is translated only where the screen authors one. -- **`AutomationEngine.renderFlowTextSlot(slot, variables, context)`** is the one translated-template pick, now shared by the refusing `end` node and the `screen` executor. Its argument type, `FlowTextSlotTranslation`, is exported. diff --git a/.changeset/pre/22507-screen-copy-engine-pick-cli.md b/.changeset/pre/22507-screen-copy-engine-pick-cli.md deleted file mode 100644 index 61268086654..00000000000 --- a/.changeset/pre/22507-screen-copy-engine-pick-cli.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli): `os i18n extract` and the coverage gate scaffold and demand a screen's `description` - -Clause-②: yes (widening) - -- `os i18n extract` now writes `flows..screens..description` into the skeleton for every screen that authors body text, seeded with the authored `{{ }}` template so a translator keeps its holes. -- The coverage gate demands it in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` gets one missing-key issue per locale for each screen that authors a `description`, until it translates it. A screen with no `description` is never asked for one. diff --git a/.changeset/pre/22507-screen-copy-engine-pick-lint.md b/.changeset/pre/22507-screen-copy-engine-pick-lint.md deleted file mode 100644 index cdce13746d2..00000000000 --- a/.changeset/pre/22507-screen-copy-engine-pick-lint.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -feat(lint): `objectstack validate` judges a translated screen `description` - -Clause-②: yes (widening) - -- A translation of `flows..screens..description` over a screen that declares no `config.description` is reported as `translation-target-unknown` (error). The engine translates body text only where the screen authors one, so nothing would read the key. Declare the screen's `description`, or drop the key. diff --git a/.changeset/pre/22507-screen-copy-engine-pick-spec.md b/.changeset/pre/22507-screen-copy-engine-pick-spec.md deleted file mode 100644 index 241806aa66f..00000000000 --- a/.changeset/pre/22507-screen-copy-engine-pick-spec.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(i18n): a screen's body text has a translation key, and a screen's heading and body text are translated by the engine in the run's locale (`flows..screens..description`) - -Clause-②: yes (widening) - -- **What is new.** A fully translated screen flow no longer has to keep a screen's body text in the source language. Translate it under `flows..screens..description`, beside the heading's `flows..screens..title`, whose address does not change. -- **A translation is a template.** A screen's `title` and `description` are `{{ }}` templates rendered for each run, so a translation keeps the holes of the text it translates: `description: '任务“{{ subject }}”创建成功!'` for `'Task "{{ subject }}" created successfully!'`. The schema judges a translated `description` with the same text-slot rule the source text gets, so a single-brace `{subject}` is refused with the `{{ subject }}` spelling. An empty string is accepted: it is the untranslated slot `os i18n extract` writes. -- **The body text translates only where the screen authors one.** A bundle cannot add body text to a screen that declares no `config.description`. -- **Who translates it.** The automation engine, not the console. It picks the translated template in the locale of the person who started the run and then fills the holes, so the screen a paused run serves is already translated. `ScreenSpec.title` and `ScreenSpec.description` now document that a client draws them as served and never overlays a translation on them. -- **`flowScreenCopyKey(flowName, nodeId, key)`** (`@objectstack/spec/system`) spells the key the engine reads, beside `flowRefusalMessageKey`. `FLOW_SCREEN_COPY_KEYS` is now `['title', 'description']`, and `translateFlow` overlays a translated `description` on a flow document where the screen authors one. -- **`resolveFlowScreenTitle` is not for a served screen.** A served `ScreenSpec.title` is already translated and filled, so overlaying the resolver's answer on it would draw the template's holes as literal text. The console stops calling it in a following release, and the function retires after that. diff --git a/.changeset/pre/22508-page-header-title-keeps-authored.md b/.changeset/pre/22508-page-header-title-keeps-authored.md deleted file mode 100644 index 57065af7fde..00000000000 --- a/.changeset/pre/22508-page-header-title-keeps-authored.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -A locale bundle's page `label` no longer replaces a `page:header` title that says something else, such as a dynamic `'{name}'` - -Clause-②: no - -`translatePage` resolved a root-level `page:header` title as the bundle's `pages..title`, else the bundle's `pages..label`, and wrote it over whatever the header authored. A pack that translated the page label and left out the title therefore turned a record page's `'{name}'` heading into the page's static name in that locale, with no warning at build, lint or serve. - -The header title now resolves in this order, at a region root and a `slots.` root alike: - -1. the bundle's `pages..title`; -2. the title the header authors (a template, a plain string or an inline locale map), left as written; -3. the bundle's `pages..label`, only where the header authors no title, or where its title restates the page's own `label`. That second case is the one `os i18n extract` already covers by offering `label` alone, so those headers keep their translation. - -The doors that serve the translated page are `GET /api/v1/meta/page/:name` and `GET /api/v1/meta/page`, through `translateMetadataDocument('page', …)`, and the console reads them. What changes for an author: a header whose title differs from the page label, in a locale whose pack carries `label` but no `title`, now shows the authored title. To translate that header, add `pages..title` to the pack (`os i18n extract` offers that key for such a header). No schema, key, accepted value or export changes. The `pages..title` describe and the reference page now state this order. diff --git a/.changeset/pre/22510-activity-actor-name.md b/.changeset/pre/22510-activity-actor-name.md deleted file mode 100644 index 5183fe03632..00000000000 --- a/.changeset/pre/22510-activity-actor-name.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -`sys_activity.actor_name` is written: a user's create, update or delete records that user's display name on its activity row, so the record History tab names who acted instead of "Unknown user" - -Clause-②: no - -`sys_activity` declares `actor_name` and lists it among its highlight fields, because activity -entries are denormalized snapshots read in time order. The audit writer filled `actor_id` and never -`actor_name`, so every row carried the user's id and no name. A renderer that reads the name, such -as the record History tab, showed "Unknown user" for every entry, for every user. - -The writer now records the name when it writes the row. The name is the title of the user's -`sys_user` row (its `name` column), read once per user and kept for 30 seconds. A predicate write -over many rows costs one `sys_user` read for its actor, not one per row. - -- **A user write.** `actor_name` is the user's display name. `actor_id` is unchanged, and the name - is read for that same id. This includes a write the system authorized on behalf of a credited - user (`attributedUserId`). -- **A user whose name cannot be read** (no row, or a blank name). `actor_name` stays empty. The - writer never puts the id in it. -- **A write with no user** (boot, migration, a scheduled job, a flow running as the system). - `actor_name` stays empty, as before. Per ADR-0118 D1 the system actor is `null`, and the UI - renders the "System" label for it. -- **An object with `enable.activities: false`.** No activity row is written and no name is read, - as before. - -Rows written before this change keep `actor_id` and no name. This change does not backfill them. -Nothing you author changes: no key, option, export or type is added or removed. diff --git a/.changeset/pre/22514-explain-cbp-update-master-check.md b/.changeset/pre/22514-explain-cbp-update-master-check.md deleted file mode 100644 index 6b3a78958e0..00000000000 --- a/.changeset/pre/22514-explain-cbp-update-master-check.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -`security/explain` answers an update of a `controlled_by_parent` record the way that record's own by-id update is answered: its record verdict now comes from the master-detail write check (ADR-0055), which it never asked. - -- **What was wrong.** On such a record the sharing service's per-record gate abstains, because the effective model of `controlled_by_parent` is `public`, and explain read the abstention as "writable". It also kept the platform ownership floor (`owner_only_writes`) on the record, which the write path hands over to the master-detail write check. So `record.visible` for an update was decided by that floor (`decidedBy: 'rls'`) for every principal who did not create the record. That was refused, on the wrong layer and with no word about the master, for a principal who may not edit the master. It was also refused for a principal who may edit the master, beside a `PATCH` that answers `200`. -- **What it does now.** For every update of a record that exists, explain asks the security service's `checkControlledByParentWrite` with the context it is explaining (another user's, when `userId` names one). A `deny` or `unresolvable` outcome makes the record not writable, decided by the `sharing` layer, whose record attribution names the refusing leg (`object_permission`, `row_level_security`, `record_sharing` or `master_chain`) or the reason the check reached no verdict. A rejection of the check is reported fail-closed, as a dependency that throws already is. `allow` and `not_applicable` leave the report exactly as before. The update's row-level composition carries the write path's own master-gate coverage vouch, so the ownership floor is handed over to that check as the write path hands it, and never on behalf of a delegator. -- **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field, which answers whether the principal may update the object at all. `delete` is unchanged too: explain asks no master check for it and keeps its floor. -- **Public surface.** `ExplainEngineDeps` gains the optional `checkControlledByParentWrite` dependency. A deps bag wired without it explains an update as before, and claims nothing about a master. diff --git a/.changeset/pre/22519-unreadable-header-write.md b/.changeset/pre/22519-unreadable-header-write.md deleted file mode 100644 index 5b281a3b57a..00000000000 --- a/.changeset/pre/22519-unreadable-header-write.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -fix(objectql)!: a detail write asks the caller's read door for its master-detail header before judging it — a header the caller cannot read answers as a header that exists nowhere (#22519) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -A write of a master-detail DETAIL reads its header for two things: the reference check of the `master_detail` value the caller supplied, and the `parent` that a parent-scoped `requiredWhen` / `readonlyWhen` judges. - -**FROM.** Both read the header elevated (row-level access bypassed, the caller's tenant kept), on the premise that the caller's right to write the detail settled its right to see the header. ADR-0055 §3 guarantees that only for a `controlled_by_parent` detail (its master-edit gate). For any other detail, a non-system caller who could write the detail but could not read its header met: - -- a reference check that admitted the header id, where a header id that exists nowhere answers `reference_not_found`; -- a parent-scoped rule judged against the header's stored state, so the write's answer depended on a row the caller cannot read. - -**TO.** The header is read in two steps, the shape `validate()`'s preview already uses: - -1. The caller's read door, under the caller's own context, so every enforcement layer in the middleware chain answers it. A header it does not return (hidden by row scope, a refused read of the header object, or a failed read) binds as a missing header binds: the reference check answers `reference_not_found` (`400 VALIDATION_FAILED`), and a parent-scoped `requiredWhen` / `readonlyWhen` meets an unbound `parent` (the requirement is refused as unevaluable, the lock holds), exactly as for a header id that exists nowhere. -2. The elevated, tenant-kept read, only for the headers step 1 returned. A header the caller can read is still judged on its STORED values, a column field-level security hides or masks for the caller included. The preview's per-column masking stays the preview's own. - -**Bindings.** - -- Every detail, `controlled_by_parent` or not, on every write door: `insert` (single and batch), `insertMany`, update by id (a repoint included) and the predicate (bulk) update. -- System callers (`isSystem`: seed replay, boot, package install) ask no read door and resolve exactly as before. -- A plain `lookup`'s reference check keeps its elevated existence probe, unchanged. -- No new error code and no status change: the answers are the ones a header that exists nowhere already got. -- Cost: one id-projected read under the caller's context per header resolution (once per batch), and one per supplied `master_detail` value in the reference check. - -**Fix, if a write relied on the old answer:** give the writer read access to the header — an object permission or sharing rule on the master, or declare the detail `controlled_by_parent` so the header's access governs it. - -**Not measured.** Which deployed configurations let a caller write a detail whose header it cannot read was not measured: the rule only tightens, so it holds whatever that answer is. diff --git a/.changeset/pre/22521-config-boot-residual.md b/.changeset/pre/22521-config-boot-residual.md deleted file mode 100644 index c2a11c3208f..00000000000 --- a/.changeset/pre/22521-config-boot-residual.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/runtime': patch -'@objectstack/metadata': minor -'@objectstack/cli': patch ---- - -`os serve `: a multi-package config now serves the top-level metadata no package declares, under the stack's `manifest.id`, and the boot says so, as an artifact boot of the same project already did - -Clause-②: yes - -A config boot (`os serve objectstack.config.ts`, with or without `--dev`, and no compiled artifact) of a stack that carries `packages[]` registers each package body and never the stack's top level. A top-level item that no package body declares was served by no door, and the boot said nothing. Three shapes reached it: flat `src/docs/*.md` pages when the stack's `manifest.id` names no `packages[]` entry, inline `docs` spread onto a composed stack's top level, and any other top-level collection (objects, views, …) that no package carries. `os build` of the same project, booted as an artifact, registered those items under the stack's `manifest.id` and warned. - -- **What changes.** The config boot now runs the same residual rule as the artifact boot, `MetadataPlugin.registerUnclaimedTopLevel`. Every top-level item no package body declares is registered under the stack's `manifest.id`, and the boot prints one `warn` line with the count: `config stack '' carries N top-level metadata item(s) that none of its M package bodies declare`, followed by the remedy. `GET /api/v1/meta/` lists those items under that id, as on an artifact boot of the same project. This includes the flat `src/docs/` pages of a stack whose `manifest.id` names no package, which a config boot did not list before. This entry supersedes, in the same release, the note in the `os serve ` flat `src/docs/` entry that "A config boot of such a stack still does not list them": it now does, under the stack's `manifest.id`, with the warning. -- **A new refusal, in one corner.** A residual view container is judged like any other: a top-level `defineView` container that no package declares, and whose own `name` is not the object it binds to, now refuses the config boot with ``Invalid `views:` container from manifest '': the container's own `name` is …``. On the previous release that config booted, because its top level was never read; an artifact boot of the same project already refused it. `os validate` and `os build` refuse the same container first, in the same words, so the refusal is never a boot-time surprise. A divergent container that a package body declares and the top level repeats is refused once, as that package's. **The one-line fix:** drop the container's `name`, or set it to the object it binds to. -- **The remedy the line names.** Declare each such item inside the `packages[]` entry of the package that owns it. A flat `src/docs/` page moves under `src//docs/`, where `` is the package id or its last dot-separated segment. -- **What widens, in `@objectstack/metadata`.** Its root entry now exports `unclaimedTopLevel(stack)`, the pure residual decision both boot doors and `os validate` read, with its two types `UnclaimedTopLevel` and `UnclaimedTopLevelItem`, and `MetadataPlugin` gains the public static `registerUnclaimedTopLevel(sink, stack, source)`, the one residual rule both boot doors call. -- **What stays the same.** An artifact boot registers the same items and prints the same line, byte for byte. A single-package config (no `packages[]`) has no residual and is unchanged. A composed stack whose top level repeats its package bodies, as `composeStacks([…], { manifest: 'preserve' })` writes it, registers nothing extra and prints nothing. -- **What the residual is, on both doors.** It is metadata: a residual object is listed on `GET /api/v1/meta/object` under the stack's `manifest.id`, and `GET /api/v1/data/` still answers `404`. That was already true on an artifact boot, and the config boot now gives the same answer. diff --git a/.changeset/pre/22527-activity-actor-avatar.md b/.changeset/pre/22527-activity-actor-avatar.md deleted file mode 100644 index 0897b7b3553..00000000000 --- a/.changeset/pre/22527-activity-actor-avatar.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -`sys_activity.actor_avatar_url` is written: a user's create, update or delete records that user's profile image on its activity row, so the record History tab and the activity feed show the user's avatar instead of the generic one - -Clause-②: no - -`sys_activity` declares `actor_avatar_url` as one of its denormalized actor columns, and the record -History tab and the record activity feed both read it as an image source. The audit writer never -filled it, so every row carried a null avatar, even for a user with a profile image. - -The writer now copies the acting user's `sys_user.image` (the profile-image URL that -`POST /api/v1/auth/update-user` and the console's avatar uploader write) into `actor_avatar_url` -when it writes the row. The value is copied as stored. It comes from the same `sys_user` read that -already supplies `actor_name`, read once per user and kept for 30 seconds, so the avatar adds no -read: a predicate write over many rows still costs one `sys_user` read for its actor. - -- **A user with a profile image.** `actor_avatar_url` is that image's URL. -- **A user with no profile image.** `actor_avatar_url` stays empty, as before. -- **A write with no user** (boot, migration, a scheduled job, a flow running as the system). - `actor_avatar_url` stays empty, as before. Per ADR-0118 D1 the system actor is `null`, and how - the system is shown is the renderer's choice. - -Rows written before this change keep their empty avatar; a later change to a user's image shows on -rows written after it, as with the name. diff --git a/.changeset/pre/22530-explain-cbp-write-verb-parity.md b/.changeset/pre/22530-explain-cbp-write-verb-parity.md deleted file mode 100644 index ddc77a8d614..00000000000 --- a/.changeset/pre/22530-explain-cbp-write-verb-parity.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -`security/explain` answers a delete and a transfer of a `controlled_by_parent` record the way each verb's own door answers it: their record verdicts now come from the master-detail write check (ADR-0055), as the update's already did. - -- **What was wrong.** The write path runs the master-detail write check on every by-id write of such a record (an update, a delete, a transfer), and hands the record's platform ownership floor over to that check. explain asked the check for an update only. - - A delete kept its `owner_only_deletes` floor. A principal who may edit the master but did not create the record was explained as refused (`decidedBy: 'rls'`) beside a `DELETE` that answers `200`. A principal who may not edit the master was refused on that floor, with no word about the master, beside a `DELETE` whose `403` names the master. - - A transfer asked only the sharing service's per-record gate, which abstains on such a record. A principal who may not edit the master was explained as allowed to transfer it, beside the `PATCH` that writes `owner_id` and answers `403`. -- **What it does now.** For an update, a delete or a transfer of a record that exists (and a restore or a purge, which the object gate still refuses to everyone), explain asks the security service's `checkControlledByParentWrite` with the context it is explaining. The check judges edit access to the master whatever the record's own verb, so its answer is each verb's answer. A `deny` or `unresolvable` outcome makes the record not writable, decided by the `sharing` layer, whose record attribution names the verb and the refusing leg (or the reason no verdict was reached). A delete's row-level composition carries the write path's master-gate coverage vouch, as an update's does, so the ownership floor is handed over as the write path hands it, and never on behalf of a delegator. -- **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field. A `create` asks no master check: the write path reads an insert's master from the request body, which an explanation does not carry. The `ExplainEngineDeps` type: the `checkControlledByParentWrite` dependency keeps its signature and is now asked for every by-id write, not only an update. diff --git a/.changeset/pre/22536-record-page-header-title.md b/.changeset/pre/22536-record-page-header-title.md deleted file mode 100644 index 944fd43f70d..00000000000 --- a/.changeset/pre/22536-record-page-header-title.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -A locale pack's page `label` no longer replaces the record-derived heading on a record page whose header authors no title - -Clause-②: no - -On a record page, a `page:header` with no `title` is the sanctioned spelling for "the renderer derives the heading from the record" (`PageHeaderProps.title`). Studio's record-page seed writes exactly that header. `translatePage` used to fill the absent title with the pack's `pages..label`, so the record's name was replaced by the page's static label. This happened in every locale whose pack carries the label, including the `en` skeleton that `os i18n extract` writes. Measured at `GET /api/v1/meta/page/:name` before this change: the header gained `title: "客户记录页"` in zh-CN, and `title: "Account Record"` in en with the extracted skeleton. - -The label now does not fill an untitled root `page:header` when all three of these hold: - -- the page's `type` is `'record'` as served (a document with no `type` is not a record page); -- the page's `object` is a non-empty string; -- the header keeps its record chrome (`properties.recordChrome` is not `false`). - -Everywhere else the label still fills an absent header title, as before. An authored header title follows the existing order, including one that restates the page's `label`. A pack's `pages..title` and `subtitle` apply as before. diff --git a/.changeset/pre/22537-spec-record-approvals-attachments.md b/.changeset/pre/22537-spec-record-approvals-attachments.md deleted file mode 100644 index f6627ba01e3..00000000000 --- a/.changeset/pre/22537-spec-record-approvals-attachments.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`record:approvals` and `record:attachments` are declared page component types — the record page's Approvals and Attachments panels — each with a `ComponentPropsMap` row that accepts no props - -Clause-②: yes (widening) - -- **Two new `PageComponentType` members:** `record:approvals` (the record's approval timeline: the step its approval sits at, who it waits on, what has been decided) and `record:attachments` (the record's attached files, with upload, download and delete). objectui already renders both, and its default record page places them: the Attachments tab on every record page of an `enable.files: true` object, the Approvals tab on a record that has approval requests. -- **The page Studio's page create seeds now passes `os validate`.** Before, `record` was a reserved namespace with neither type declared, so `os validate` / `os build` / `os lint` refused the seeded page of any `enable.files` object with `component-type-unknown` on its `record:attachments` node. The metadata save door did not refuse it and still does not. -- **Both rows are strict and accept no key.** A node with no `properties`, or `properties: {}`, passes. Any key inside `properties` is reported as `component-props-unknown-key`, naming the component and the key. Node-level keys (`id`, `className`, `visibleWhen`, …) stay on the node as for every component. -- **`record:approvals` refuses the runtime channel it reads, with the fix.** Its renderer reads `approvals` (the approval requests the default record page fetched) and `currentUserId` (the signed-in user) from the node, and both are filled by the host at runtime. An authored `approvals` would show a static approval history that never updates, and an authored `currentUserId` would decide for every viewer who counts as the submitter. Both are refused with a message that says to omit them: the block fetches the record's own approval requests and reads the signed-in user itself. -- **A misspelling is refused.** `record:attachment`, `record:aprovals` and other near spellings are `component-type-unknown` errors that offer the declared spelling. -- **Not printable.** Inside a page that declares `print`, each type is refused with its own reason, in place of the generic "not in the printable block subset" one. -- **Consumers.** A `kind: 'react'` page that names `` or `` is now refused by `react-block-needs-record-context`, as every `record:*` block is: these blocks read the record context a record page mounts, and a react page mounts none. A consumer that derives coverage from `PageComponentType.options` or the keys of `ComponentPropsMap` — a designer palette, a renderer registry — sees two more members to classify. diff --git a/.changeset/pre/22550-explain-lifecycle-verb-rls-operation.md b/.changeset/pre/22550-explain-lifecycle-verb-rls-operation.md deleted file mode 100644 index f2ac4773e5c..00000000000 --- a/.changeset/pre/22550-explain-lifecycle-verb-rls-operation.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -`security/explain` composes a transfer's row-level security as the update its door is, and a restore's and a purge's as the write class the by-id write path composes them as. - -- **What was wrong.** The by-id write path composes a lifecycle verb's row-level security for its nearest write class: transfer and restore as `update`, purge as `delete`. explain composed it for the verb as asked, which the RLS compiler reads as a read (`select`). So no update-class policy and no ownership floor reached a transfer's verdict. On a row an app-authored update policy excludes, explain answered `record.visible: true`, with "No business RLS policy applies to this record", beside the `PATCH` that writes `owner_id` and answers `403 PERMISSION_DENIED`. -- **What it does now.** The mapping is declared once, in plugin-security, and both readers read it: the by-id write pre-image gate (byte-identical to the expression it replaces) and explain. explain composes both of its row-level readings for the mapped operation: the record's row story and the object-level `rls` layer. A transfer of a row an update policy excludes is now `visible: false`, decided by `rls`, as an update of that row is. The object-level `allowed` reads the `rls` layer's deny-all verdict, so for a transfer it now reads the update class's. Restore and purge are still refused at the object gate for every principal, so `object_crud` still decides their verdict first; the `rls` layer under it now reports the update and delete classes. -- **What is unchanged.** Every other operation: `read`, `create`, `update`, `delete` and `export` are composed exactly as before. The RLS compiler's own default for an unlisted operation. The read path's row-level filter injection, which never sees a lifecycle verb. No export is added to the package. diff --git a/.changeset/pre/22554-migration-registry-generated-at-build.md b/.changeset/pre/22554-migration-registry-generated-at-build.md deleted file mode 100644 index 8c044d43caf..00000000000 --- a/.changeset/pre/22554-migration-registry-generated-at-build.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The migration registry `src/migrations/registry.ts` is generated at build and no longer committed - -Clause-②: no - -`src/migrations/registry.ts` keeps its path and its exports, and the built package carries the same -`./migrations` entry as before. What changes is where the file comes from in this repository: it is -git-ignored and written whole by `gen:migration-registry` from two committed sources, -`src/migrations/registry.ts.template` (the hand-written step rationale, doc comments and table -skeleton) and `src/migrations/entries/` (one file per entry). The generator runs on `pnpm install` -(the package's new `prepare` script), as the first step of `build`, and before `typecheck`, `test` -and `test:repo`; its self-test runs inside every generation. `check:migration-registry` is removed, -since there is no committed copy left to compare. - -For a contributor: add a migration entry as a file under `src/migrations/entries/` and edit any other -registry prose in `registry.ts.template`. An edit to `registry.ts` itself is never committed, and the -next install or build overwrites it. - -Measured with `pnpm pack` against the previous build: 2,070 files each, and 4 differ. Those are -`package.json`'s `scripts` (`build`, `typecheck`, `test` and `test:repo` now start with the generation, -and `check:migration-registry` is gone; `pnpm pack` leaves `prepare` out of the packed manifest) and the -three `.build-input-hash` stamps that record the build's inputs. Every `dist` file, declaration, source -map and JSON Schema is byte-identical. No schema, type, export or runtime behaviour changes. diff --git a/.changeset/pre/22558-empty-approver-no-slot.md b/.changeset/pre/22558-empty-approver-no-slot.md deleted file mode 100644 index 33e352ebd1b..00000000000 --- a/.changeset/pre/22558-empty-approver-no-slot.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -fix(plugin-approvals): a `manager` approver whose record owner has no manager adds no slot to the request, instead of the literal `manager:undefined` - -Clause-②: no - -An approval request whose user-resolving approver resolves to nobody now opens on an empty `pending_approvers`, and the node's `onEmptyApprovers` policy applies, as `content/docs/automation/approvals.mdx` documents ("An entry that resolves to nobody is not an error: the request opens with an empty `pending_approvers`"). This covers `{ type: 'manager' }` when the record's owner has no `sys_user.manager_id`, or the manager is outside the request's organization, and `{ type: 'field' }` when there is no record to read. - -Before this fix the expansion stored a literal `manager:` slot: `manager:undefined` when the approver authors no `value`, which is the usual case. Both read doors (`GET /api/v1/approvals/requests` and `GET /api/v1/data/sys_approval_request`) served that slot. Nobody can ever act under it: a caller acts only under their user id, their own account's email, or the `position:` address of a position they hold. - -What changes, and for whom: - -- **A slate made only of such rungs** opens with `pending_approvers: []` under the default `onEmptyApprovers: 'admin_rescue'`, where it held `["manager:undefined"]` before. `fail`, `auto_approve` and `fallback` behave as before, because those policies already treated a slate of literals as empty. -- **A slate with other approvers** (for example `[{ type: 'manager' }, { type: 'user', value: 'u9' }]`) is carried by the others. Under `unanimous`, `quorum` or `per_group`, the dead literal used to be an unapprovable slot: it held the request open after every real approver had approved, until an admin overrode it. Now the real approvers' approvals finish the step. This is the "second approver entry that cannot resolve empty" escape that the docs name for an unset manager. -- **Group approvers are unchanged.** An unstaffed `position` still opens on its `position:` literal, which a holder staffed into the position later decides. `team`, `department` and `org_membership_level` still fall back to their `type:value` literal. -- The `manager` rung still logs a warning when it resolves to nobody. The warning now says that the rung adds no slot, and names the subject user. - -Stored requests are not rewritten. A pending request that already holds a `manager:…` or `field:…` slot keeps it, and it stays recoverable as before: a platform or tenant admin can approve, reject, reassign or recall it. To list such requests, filter `sys_approval_request` on `status = pending` and on `pending_approvers` containing `manager:` or `field:`. diff --git a/.changeset/pre/22559-approval-request-read-gate.md b/.changeset/pre/22559-approval-request-read-gate.md deleted file mode 100644 index 9fa42e24f85..00000000000 --- a/.changeset/pre/22559-approval-request-read-gate.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -fix(plugin-approvals): the generic data door serves `sys_approval_request` rows by the approvals door's visibility - -Clause-②: no - -A read of `sys_approval_request` through the generic data door (`find`, `findOne`, `count` and `aggregate`, so the REST data routes, the export route, the MCP data tool and every other engine read) now returns only the requests the approvals door (`/api/v1/approvals/*`) serves the same caller: - -- the requests the caller submitted, is a current approver of, or has acted on; -- every request, for an administrator holding the approval override; -- read-only, on an object named in `recordReaderVisibleObjects`, the requests of a record the caller can read, on a read that names that record (`object_name` and `record_id`, or the request's id). - -Before this, a deployment that granted read on the object served every request of the organization on that door, including the payload snapshots of records the caller cannot open. - -- **One rule for both doors.** The gate asks the approvals service for its own visibility (the same rule `listRequests` and `getRequest` apply), through an in-package handle: the published `ApprovalService` type is unchanged. It is not the activity stream's parent-record gate, which admits every reader of the record: the record-reader tier stays off unless the deployment names the object. -- **Absence, not refusal.** A request the caller may not see answers a by-id read exactly as a missing one does, `404 RECORD_NOT_FOUND`. A list's `total` and a grouped count are narrowed like its rows. A failed visibility answer denies the read and logs a warning. -- **Unchanged.** The approvals door, an administrator's view (Setup → Approvals → Requests), and the approval engine's own reads. A deployment that grants no read on the object still answers `403`. -- The record-reader tier no longer anchors on `sys_approval_request` itself, even when `recordReaderVisibleObjects` names it. diff --git a/.changeset/pre/22560-platform-objects-approvals-visible-to-readers-form-row.md b/.changeset/pre/22560-platform-objects-approvals-visible-to-readers-form-row.md deleted file mode 100644 index e4691934741..00000000000 --- a/.changeset/pre/22560-platform-objects-approvals-visible-to-readers-form-row.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -Studio's object form now offers the `approvalsVisibleToReaders` row in its capabilities panel, with its label and help text in the metadata-form translation catalogs for `en`, `zh-CN`, `ja-JP` and `es-ES`. - -Clause-②: no diff --git a/.changeset/pre/22560-plugin-approvals-declared-record-reader-tier.md b/.changeset/pre/22560-plugin-approvals-declared-record-reader-tier.md deleted file mode 100644 index 6679c1a4e48..00000000000 --- a/.changeset/pre/22560-plugin-approvals-declared-record-reader-tier.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-approvals': minor ---- - -feat(plugin-approvals): the record-reader approval tier honours an object's own `enable.approvalsVisibleToReaders` declaration - -Clause-②: yes (widening) - -- **Before this,** the read-only record-reader tier could be switched on only through the `recordReaderVisibleObjects` constructor option. A config-driven app, whose host builds the plugin with no options, had no way to reach it. -- **Now** the tier is on for an object when either source turns it on: the host's `recordReaderVisibleObjects`, or the object's own `enable.approvalsVisibleToReaders: true`. With neither, visibility is exactly as before. -- **Read where it is used.** The service reads the declaration from the object's live registered definition on each read that names a record, not once at start. An object registered after boot (an installed package, a Studio edit, a dev reload) takes effect at once. Removing the flag turns the tier off at once, with no restart. -- **Fails closed.** An engine that cannot answer an object definition, an unknown object, or any value other than `true` leaves the tier off. Default OFF costs one in-memory registry lookup on a read that names a record. The business record is never probed for an object that declares nothing. -- **Unchanged.** The rule itself: who counts as a reader, the read-only boundary, the request tables it covers on both doors, and the constructor option. diff --git a/.changeset/pre/22560-spec-approvals-visible-to-readers.md b/.changeset/pre/22560-spec-approvals-visible-to-readers.md deleted file mode 100644 index 7de714d046e..00000000000 --- a/.changeset/pre/22560-spec-approvals-visible-to-readers.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): `enable.approvalsVisibleToReaders`, the per-object opt-in for read-only approval visibility to a record's readers - -Clause-②: yes (widening) - -- **The key.** `ObjectCapabilities` (an object's `enable` block) gains `approvalsVisibleToReaders`, a boolean that defaults to `false`. An object that declares nothing is unchanged. -- **What `true` grants.** A caller who can read a record of the object sees that record's approval requests and their full action history, read-only. "Can read" is the object's own CRUD, sharing and RLS, asked as the caller. No new permission is granted. This holds on the approvals API and on the generic data API alike (`sys_approval_request`, `sys_approval_action` and `sys_approval_approver`), on a read that names the record. A list that names no record, such as the inbox, is not widened. No approval action is offered: approve, reject, reassign, recall and comment keep authorizing as before. -- **What becomes visible.** The request row, including its snapshot of the record at submission, and each action's actor, decision, time, comment text and attachments. Turn it on only for objects whose approval commentary the record's readers are meant to see. -- **Studio.** The object form's Capabilities section lists the new toggle. -- **Nothing to migrate.** To opt an object in, write `enable: { approvalsVisibleToReaders: true }` on it. diff --git a/.changeset/pre/22565-flow-cel-unbound-root-refused.md b/.changeset/pre/22565-flow-cel-unbound-root-refused.md deleted file mode 100644 index b4f04117ca0..00000000000 --- a/.changeset/pre/22565-flow-cel-unbound-root-refused.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -'@objectstack/lint': major -'@objectstack/spec': major ---- - -A flow CEL expression that reads a root the flow does not bind is refused at `objectstack validate`. The run fails such an expression with `Unknown variable: X` (X being the root) on every evaluation, and the build door used to pass it. The reachable case is the run user: `user.id == "u1"`, `ctx.user.id == "u1"` and `os.user.id == "u1"` — the spellings formulas, row-level security and the client accept — are refused, naming `current_user`, the one spelling flow CEL binds. - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface (`objectstack validate`'s `expression-invalid` rule). - -**Why.** Flow CEL evaluates in one scope per run: the flow's variables spread to top level, the trigger record's fields flattened beside them, `record` bound to the record the run was handed (where an entrance hands it one), and `previous`, `vars` and `current_user` bound by the engine. Formulas, row-level security and the client also bind the run's user as `user`, `ctx.user` and `os.user`; flow CEL does not, because a top-level `user` would collide with a variable or a lookup field of that name. So an author who wrote the alias by habit shipped a flow that faulted at its first evaluation, and nothing warned. - -**What is refused.** At every flow CEL site the build door reads — a node's `config.condition` (the start node's trigger gate included), an edge `condition`, a `decision` branch `expression`, a screen field `visibleWhen`, and the CEL value envelopes of an `assignment` and of a `create_record` / `update_record` `fields` map — a root that is none of: - -- the engine's roots: `previous`, `vars`, `current_user`; -- `record`, where an entrance the stack declares hands the flow a record — a record trigger (a `record-*` `triggerType`), a time-relative sweep, the inbound hook, a `type: 'flow'` action, a `map` node with an `itemObject`, or a `subflow` / `map` parent handed one — or where the flow binds a variable named `record`; -- a name the flow binds: a declared variable, an `outputVariable`, an `iteratorVariable` (a `loop`'s default `item` included), an `indexVariable`, an `errorVariable`, an `assignment` target, a node id, a screen field's `name`; -- a field (or a registry-injected column) of any object the stack declares — the run flattens whichever record its entrance hands it (an object-less flow action hands only the row `id`). - -The user spellings are told to write `current_user`, with the guard for a flow that can run without a user; any other root is named with what could have bound it, and the nearest in-scope name. The error is `expression-invalid`, `error`. Where the flattened-scope near-miss advisory ("`X` is not a field of … If `X` is a flow variable this is safe to ignore") named the same root, the refusal replaces it. - -**Not judged — the door stands down where the run can bind a name it cannot see.** - -- **A node:** a `script` or `connector_action` node (each hands its code the live variable map), a `wait`, `subflow` or `map` node (a resume folds the caller's `variables` bag in under their plain names), a screen with no field list, or a node type outside the builtin set. -- **An entrance the stack declares that hands the flow a record whose keys are not in hand:** - - a record trigger (`config.objectName`) or a time-relative sweep (`config.timeRelative.object`) on an object the stack does not declare; - - an `api` trigger, whose inbound hook hands the request body in as the record; - - a `type: 'flow'` action launching the flow on an undeclared object; - - a `map` node feeding the flow whose `itemObject` is undeclared or absent; - - a `subflow` or `map` parent that is itself such a flow. -- **The runtime publish gate:** a flow write there is not judged for unbound roots, by design (rulings `5791822697` item 3 and `6104584601`). Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged. - -A `$` name has no CEL spelling (`$runId == "x"` does not parse), so the `$` roots stay the text-slot rule's. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `condition: 'user.id == "u1"'` | `condition: 'current_user.id == "u1"'` (`current_user != null && current_user.id == "u1"` where the flow can run without a user) | -| `condition: 'ctx.user.id == record.owner'` | `condition: 'current_user.id == record.owner'` | -| `assignments: { who: { dialect: 'cel', source: 'os.user.id' } }` | `assignments: { who: { dialect: 'cel', source: 'current_user.id' } }` | -| `condition: 'deals.size() > 0'` with nothing binding `deals` | declare it (`variables: [{ name: 'deals', type: 'list' }]`) or bind it with a node (`outputVariable: 'deals'`) | - -**The one-line fix: write `current_user` for the run user, and bind or correct every other root the refusal names.** - -**Who is affected, measured.** The last published spec line, 17.x, judged a flow expression's syntax only, so it accepts every refused source. Measured with the judge over every flow CEL site: the example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`: 35 flows, 45 sites), `packages/platform-objects` (no flows) and `objectstack-ai/hotcrm` at `f0afcbda07` (32 flows, 65 sites) — 0 refusals; every root those flows read is an engine root, `record` on a flow an entrance hands a record, a binding of the flow's own or a field of its trigger object. One flow of the 67 is left unjudged by an entrance (`showcase_inbound_task_webhook`, an `api` trigger). Deployed metadata and other repositories were not measured. - -### The kit - -- **The judge.** `flow-cel-root-scope.ts` in `@objectstack/lint`, called by `validateStackExpressions` at each flow CEL site. One bound set per flow, read once like the shadowing pass's variable set, and one entrance index per stack (`flowCelEntrances`). -- **The ledger.** The D3 semantic entry `flow-cel-unbound-root-refused` (protocol 18). There is no D2 conversion: none of these roots ever evaluated in a flow, so there is no behaviour to carry over. diff --git a/.changeset/pre/22566-comment-reaction-record.md b/.changeset/pre/22566-comment-reaction-record.md deleted file mode 100644 index d3e7069cd5c..00000000000 --- a/.changeset/pre/22566-comment-reaction-record.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/plugin-audit': minor -'@objectstack/spec': minor ---- - -`sys_comment_reaction`: an emoji reaction to a comment is now the reacting user's own record, one row per comment, emoji and user - -Clause-②: yes (widening) - -- **The object.** `@objectstack/plugin-audit` registers `sys_comment_reaction` beside `sys_comment`. A row holds `comment_id` (the reacted comment's id), `emoji` (the character sequence, at most 64 characters) and `user_id` (the user who reacted). The data API offers `get`, `list`, `create`, `delete` and `bulk` on it, so `createMany`, `deleteMany` and `/batch` creates and deletes work too. It offers no `update`, so `updateMany` stays refused: a reaction is removed and made again, not edited. Every row of a batch meets the same rules as a single write. -- **Who may read it.** A reaction is readable exactly when its comment is. Every read of the object (`find`, `findOne`, `count`, `aggregate`, and so the list total) is narrowed to the rows whose comment the caller can read, decided by a read of those comments under the caller's own context. A reaction whose comment was deleted is read by nobody. -- **Who may write it.** A caller reacts only to a comment it can read; on any other comment, including one that does not exist, the create is refused with `403 RECORD_NOT_ACCESSIBLE`. `user_id` is set from the signed-in user, and a value the client sends is replaced; a session with no user is refused. A member deletes their own reaction, and not another member's, by the platform's own-record rule (`owner_only_deletes`). The author-only edit rule on `sys_comment` is unchanged: reacting no longer writes the comment's row. -- **One row per reaction.** `(comment_id, emoji, user_id)` is unique within an organization. Two members reacting at once write two rows. The same reaction a second time is refused, and the data API answers `409 UNIQUE_VIOLATION`. -- **Reading reactions for a feed.** Read the reactions of the comments on screen in one request, `comment_id` `$in` those ids, and group the rows by comment and emoji. Nothing is stored or computed on the comment row. -- **Granting it.** The object's create, read and delete bits come from a permission set, as `sys_comment`'s do. The `everyone`-bound baseline (`member_default`) grants nothing on it. -- **`@objectstack/spec`: the platform object registry lists it.** `PLATFORM_OBJECTS_BY_PACKAGE['plugin-audit']` now includes `sys_comment_reaction`, and so do `PLATFORM_PROVIDED_OBJECT_NAMES` and `isPlatformProvidedObjectName`. A stack that references the object is no longer reported as naming an unknown platform object. This is the only change in the package. -- **`sys_comment.reactions` is unchanged in this release.** The column is still declared and still written by clients that write it. It is retired separately, and `os migrate comment-reactions` converts the reactions stored in it into records. diff --git a/.changeset/pre/22568-page-slots-details-beside-tabs-refused.md b/.changeset/pre/22568-page-slots-details-beside-tabs-refused.md deleted file mode 100644 index 588eb69a814..00000000000 --- a/.changeset/pre/22568-page-slots-details-beside-tabs-refused.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/platform-objects': patch ---- - -feat(spec)!: `PageSchema` refuses a page that authors both `slots.details` and `slots.tabs`; the `sys_user` record page carries its details grid as its first tab - -Clause-②: yes (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. - -**Why.** The slot map read as seven independent slots, and two of them are not. On a slotted record page `details` replaces the body of the Details tab, that tab lives inside the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console's default-page synthesizer therefore reads `tabs` and never reads `details` when both are authored. A page authoring both passed `PageSchema.parse`, `objectstack validate` and the metadata save door, and its details body (its `sections`, its `hideFields`) silently never applied. The platform's own `sys_user` record page was one: its Identity and Audit sections never showed, and the ban columns it hides were never hidden by it. - -**What is refused.** A page whose `slots` map carries both a `details` and a `tabs` key, at `slots.details`, with a `custom` issue that names both slots and the fix. Either slot may be one component or an array; an empty `details: []` is refused like a full one, and the page's `kind` does not matter. That covers `PageSchema`, `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `os validate` and the metadata save door (`422 INVALID_METADATA`). The check is exported as `checkPageSlotPair`, for a mirror built from `PageSchema.shape` to re-attach. - -**What is still accepted, byte for byte.** A page authoring only `details`, only `tabs`, or neither. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `slots: { details: { type: 'record:details', … }, tabs: { type: 'page:tabs', properties: { items: [ … ] } } }` | `slots: { tabs: { type: 'page:tabs', properties: { items: [ { label: 'Details', children: [ { type: 'record:details', … } ] }, … ] } } }` | -| both slots, wanting the synthesized tabs with your details body | `slots: { details: { type: 'record:details', … } }`, with no `tabs` slot | - -**The one-line fix: move the `record:details` component into the `tabs` items (the first item's `children`, by convention) and delete `slots.details`.** Its `sections` and `hideFields` move unchanged. The page then shows the details body it always declared, which is a visible change on every page that authored the pair. - -**Who is affected, measured.** In this repository, only the `sys_user` record page (`sys_user_detail` in `@objectstack/platform-objects`) authored both slots; no example app page does. hotcrm's one slotted page authors `header` and `discussion` only. Deployed metadata was not measured. A page row already stored with the pair is replayed unchanged at load, so it renders as before (the authored tabs, without the details body); its read diagnostics name the pair, and saving it again is refused until the details body moves. - -### The kit - -- **The refusal.** `checkPageSlotPair`, attached to `PageSchema` by identifier beside its three other object-level checks. No new error code. -- **The producer.** `sys_user_detail` moves its `record:details`, with its `sections` and `hideFields` unchanged, in as the first `tabs` item. -- **The ledger.** The D3 semantic entry `page-slots-details-beside-tabs-refused` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: which tab item carries the details body, and under which label, is the author's decision. diff --git a/.changeset/pre/22571-explain-transfer-unreadable-row-not-found.md b/.changeset/pre/22571-explain-transfer-unreadable-row-not-found.md deleted file mode 100644 index 9ad04faa4be..00000000000 --- a/.changeset/pre/22571-explain-transfer-unreadable-row-not-found.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -`security/explain` answers a transfer of a row the caller cannot read the way the transfer door does: as a record that does not exist. - -- **What was wrong.** On the write doors, a row the caller cannot read answers what a nonexistent id answers: the by-id update or delete the caller addresses gets `404 RECORD_NOT_FOUND`. A transfer's door is the `PATCH` that writes `owner_id`, an update, so a transfer of an unreadable row gets that 404 too. explain's matching rewrite (the missing-record shape, `record: { visible: false }` with no `decidedBy`) keyed on the verb as asked, `update` or `delete`, so it never ran for `transfer`. Where the caller's update policy admitted the hidden row, explain answered `visible: true`, decided by `rls`. Where the caller declared no write policy, it answered `visible: false` decided by `rls`, which is the shape of a 403 refusal, not of the door's 404. -- **What it does now.** The rewrite asks which operation the verb's door is through the same mapping the by-id write path reads (`transfer` and `restore` as update, `purge` as delete). So every write verb whose door is a by-id update or delete is asked the read question, and a transfer of a row the caller cannot read is `{ visible: false }` with no decider, beside the door's 404. -- **What is unchanged.** `update` and `delete` are answered exactly as before. `restore` and `purge` are still refused at the object gate for every principal, which answers first. A readable row keeps its verdict for every verb. The object-level `allowed` answers the object question and is not touched. No door changes, and no export is added to the package. diff --git a/.changeset/pre/22572-flow-binding-name-dollar-refused.md b/.changeset/pre/22572-flow-binding-name-dollar-refused.md deleted file mode 100644 index 9262f9bd743..00000000000 --- a/.changeset/pre/22572-flow-binding-name-dollar-refused.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/spec': major ---- - -Every remaining place a flow binds a variable by name refuses a name that starts with `$`: a `loop` or `map` node's `iteratorVariable` and `indexVariable`, an object-form `screen` node's `idVariable`, a `screen` field's `name`, a declared flow variable's `name`, and an `assignment` node's targets. The refusal names the remedy: the same name without the `$`, read as `{{ name }}`. - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** The `$` names are the flow engine's own variables: it binds `$record`, `$runId`, `$flowName`, `$flowLabel` and `$error`, a flat-graph `loop` binds `$loopItems` and `$loopIndex`, and a resume signal may not write any `$` name. A flow text slot refuses a `{{ }}` hole over a `$` name the engine does not bind, and `outputVariable` / `errorVariable` already refuse one. Every other binding took any string, and on the run a `$` binding was worse than unreadable (measured with the engine on this release's `main`): - -- `iteratorVariable: '$record'` on a `loop` or `map` left `$record` holding the last item for the rest of the run, and `indexVariable: '$runId'` left `$runId` holding an index; -- `assignments: { $record: … }` overwrote the trigger record; -- a declared variable named `$record` was overwritten by the engine at run start, so its `defaultValue` never reached the run; -- a `screen` whose `idVariable` or field `name` was a `$` name paused, and then could never be submitted: the resume carrying the value was refused with `INVALID_SIGNAL`; -- a leading blank hid the `$` from a first-character rule, while the `screen` and `script` executors trim a name before they bind it: under that rule `idVariable: ' $id'` registered, the paused screen named `$id`, and its resume was refused the same way; a `script`'s `outputVariable: ' $record'` passes the protocol-18 rule too and is trimmed to `$record` by its executor (read from the executor, not run). - -**What is refused.** - -- `iteratorVariable` and `indexVariable` on `LoopConfigSchema` and `MapConfigSchema`, `idVariable` on `ScreenConfigSchema`, `name` on `ScreenFieldConfigSchema` and on `FlowVariableSchema`: a name that starts with `$`. Each key states the rule as a JSON Schema `pattern`, so the published `json-schema/**` refuses what the parse refuses. The parse issue is an `invalid_format` (regex) issue at the key, and its message names the remedy. -- Every binding key — `outputVariable` and `errorVariable` included — now judges the first NON-BLANK character, so `' $x'` is refused like `'$x'`. The pattern's `\s` is exactly the set `String.prototype.trim` removes. -- An `assignment` node's targets, in each shape its executor binds: a key of the `assignments` map (`AssignmentConfigSchema` states it as `propertyNames.pattern`), a top-level key of the bare legacy config, and the `variable` (or `name`, `key`) of a legacy `assignments: [{ variable, value }]` item. -- `FlowSchema.parse`, `registerFlow` and `objectstack validate` refuse the flow where the name was written — `nodes.N.config.iteratorVariable`, `nodes.N.config.fields.M.name`, `nodes.N.config.assignments.NAME`, `variables.N.name` — inside a region body too. A stored flow carrying one is skipped at boot with a warn naming it, and the `loop`, `map` and `screen` executors' own contract parse refuses the node at run time. - -**Unchanged.** Any name whose first non-blank character is not `$`, a `$` later in the name (`a$b`) included; the defaults (`iteratorVariable` is still `item`); an empty string where the key took one; a body-less legacy `loop`, whose `iteratorVariable` nothing reads; and every hole the text-slot judge already admits. Non-string values keep the type refusal they had. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `iteratorVariable: '$row'` with `title: '{{ $row.name }}'` | `iteratorVariable: 'row'` with `title: '{{ row.name }}'` | -| `idVariable: '$account'` | `idVariable: 'account_id'`, read as `{{ account_id }}` | -| `variables: [{ name: '$total', type: 'number' }]` | `variables: [{ name: 'total', type: 'number' }]`, read as `{{ total }}` | -| `assignments: { $total: … }` | `assignments: { total: … }` | - -**The one-line fix: name the variable without the `$`, and rename every read of it with it.** - -No D2 conversion rewrites the name: the bare name may already be bound in the flow, and the reads of the old name sit in every dialect a flow string speaks (a text-slot hole, a CEL expression), so the rename is the author's. - -**Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), types every one of these keys as a plain string. Measured on `main` at this change's base: the 35 flows `examples/app-crm`, `examples/app-todo` and `examples/app-showcase` ship all parse clean under the new rule, and a TypeScript-AST scan of `examples` (234 files), `packages/platform-objects` (167, which ships no flow), `packages/qa/dogfood` (280), the rest of `packages` and the `hotcrm` app found no `$`-led binding on any of these positions. The pinned `objectui` checkout binds none either. Deployed metadata and other repositories were not measured. - -### The kit - -- **The rule.** `flowBoundVariableNameSchema` in `automation/flow-bound-variable-name.ts`, package-internal (no new public export), now composed into every binding position. A `$` binding is refused with one sentence whichever door judges it. -- **The ledger.** The D3 semantic entry `flow-binding-name-dollar-refused` (protocol 18), with no D2 conversion. diff --git a/.changeset/pre/22573-sys-comment-reactions-retired.md b/.changeset/pre/22573-sys-comment-reactions-retired.md deleted file mode 100644 index 91274da5340..00000000000 --- a/.changeset/pre/22573-sys-comment-reactions-retired.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/plugin-audit': minor -'@objectstack/spec': minor ---- - -feat(plugin-audit)!: `sys_comment.reactions` retires; an emoji reaction lives only in `sys_comment_reaction`, and reactions stored in the old column are converted into `sys_comment_reaction` records by `os migrate comment-reactions` - -Clause-②: no (narrowing) - - - -**BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. - -**Existing emoji reactions stored in `sys_comment.reactions` survive the upgrade.** They stay in the column, which no screen or API returns, until `os migrate comment-reactions` converts them into `sys_comment_reaction` records, and `os migrate apply --allow-destructive` drops the column only after that conversion is verified (see *Existing databases* below). Reactions now live in `sys_comment_reaction`, one row per comment, emoji and user, the reacting user's own record. Where the entry that introduces `sys_comment_reaction` says the column is unchanged, this entry supersedes it: the column retires. - -- **`sys_comment`** (`@objectstack/plugin-audit`) no longer declares `reactions`. Nothing takes its place on the comment row: no stored aggregate, and no block computed on read. Its label leaves the four generated translation bundles. -- **The data door refuses the field.** A create or an update of a comment that still carries `reactions`, and a list filter on it, answer `400 INVALID_FIELD` naming the field, and nothing is written. The rest of the payload is not applied either. A read of a comment no longer returns `reactions`. -- **`@objectstack/spec`**: the protocol-18 step of the ADR-0087 migration chain gains the semantic entry `sys-comment-reactions-column-retired`, so `os migrate meta` and the upgrade guide carry the prescription below. - -**What moves for consumers.** - -| You wrote | Write instead | -|:---|:---| -| `sys_comment.reactions` read as a map of emoji to user ids | one read of `sys_comment_reaction` for the comments on screen (`comment_id` `$in` their ids), grouped by comment and emoji | -| an update of `sys_comment` that rewrites `reactions` to add or remove a user | create the user's own `sys_comment_reaction` row (`comment_id`, `emoji`) to react; delete that row to un-react | -| `reactions` in a seed row, filter or list-view column on `sys_comment` | delete it | - -An application that shows reactions grants `sys_comment_reaction` read, create and delete beside its `sys_comment` grant; without it a member cannot react. The console's record chatter already reads and writes `sys_comment_reaction` rows wherever the deployment declares that object, as this framework does. - -**Existing databases — nothing moves automatically.** Schema sync is additive: the physical `reactions` column stays with the values it holds, the boot drift report names it orphaned (`sys_comment.reactions: column exists in the database but not in metadata`), and the boot is otherwise clean. The data door neither returns nor accepts the column. Convert first, then drop. `os migrate comment-reactions` previews the conversion and writes nothing; `os migrate comment-reactions --apply` turns each stored (comment, emoji, user) into that user's own `sys_comment_reaction` record, in the comment's organization, lists every stored value it cannot read instead of guessing at it, and records the run verified once every stored value has read and converted. A re-run converts nothing twice. Only then does `os migrate apply --allow-destructive`, run with the audit plugin composed (an auth secret resolves), drop the column; before a verified run is recorded it refuses the drop and exits `1`, because the drop would delete the reactions. diff --git a/.changeset/pre/22575-spec-approvals-action-page-member.md b/.changeset/pre/22575-spec-approvals-action-page-member.md deleted file mode 100644 index d40dc96c0f2..00000000000 --- a/.changeset/pre/22575-spec-approvals-action-page-member.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): the approvals service contract declares an optional, transport-neutral action-page member (`IApprovalService.handleActionPage`) - -Clause-②: yes (widening) - -- **What is new.** `IApprovalService` in `@objectstack/spec/contracts` gains one optional member, `handleActionPage?(request: Request): Promise`. It serves the ADR-0043 action page, the session-less page an approver reaches from the approve or reject link in an e-mail or IM message. `GET` renders the confirm page from the `token` query parameter and never decides. `POST` redeems the `token` form field and answers the result page. -- **The token is the only credential.** The member takes no `ExecutionContext` and reads no session, cookie or `Authorization` header. The decision is audited as the approver the token binds. -- **Transport-neutral.** A web-standard `Request` in, a `Response` out: no Hono context, no raw app and no `http.server` type. A hosted tenant kernel has no raw app to mount pages on, and the runtime dispatcher's `/approvals/act` domain is the member's one caller. -- **Absence.** The member is optional, so an approvals implementation without action pages still satisfies the contract. Its caller answers a typed 404 or 501 when the `approvals` service or this member is absent, never `ROUTE_NOT_FOUND`. -- **Nothing changes for existing implementers.** No existing member moved. Nothing implements or calls the member yet: the plugin implementation and the dispatcher domain land separately, and the plugin's self-hosted pages stay as they are. diff --git a/.changeset/pre/22576-approvals-act-domain.md b/.changeset/pre/22576-approvals-act-domain.md deleted file mode 100644 index eb898afb0aa..00000000000 --- a/.changeset/pre/22576-approvals-act-domain.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/runtime': patch -'@objectstack/hono': patch ---- - -fix(runtime,hono): approval e-mail links work on a kernel with no raw HTTP app — the dispatcher serves `/approvals/act`, and the Hono catch-all no longer consumes request bodies - -Clause-②: no - -On a self-hosted server, `@objectstack/plugin-approvals` serves the ADR-0043 action page (the approve / reject link in an approval e-mail or IM message) by mounting `GET` and `POST /api/v1/approvals/act` on the raw Hono app. A hosted tenant kernel has no raw app, so on that shape the path answered `404 ROUTE_NOT_FOUND` and every action link was dead. - -**`@objectstack/runtime`.** `HttpDispatcher` now serves exactly `/approvals/act` (no sub-paths) for `GET`, `HEAD` and `POST`. It forwards the request, unread, to the request kernel's `approvals` service (`IApprovalService.handleActionPage`) and returns that `Response` as it is. - -- `HEAD` answers as the self-hosted mount does: the `GET` page's status and headers, with no body. It renders and never decides. -- No `approvals` service registered, or one without `handleActionPage`: `501 NOT_IMPLEMENTED`, naming which. Never `ROUTE_NOT_FOUND`. -- A member that throws: a sanitised `500 INTERNAL_ERROR`, with the original error in the server log. The same answer, logged, when a transport hands over a `POST` whose body was already read, instead of an "invalid link" page for a live link. -- The token is the only credential, as on the self-hosted mount. The project-membership gate now skips the exact act path (unscoped or `/environments/:id`-scoped), so a signed-in caller who is not a member of the environment reaches the page like an anonymous one instead of getting `403 PROJECT_MEMBERSHIP_REQUIRED`. Every other path, `/approvals/act/...` and `/approvals/requests/...` included, is checked as before. - -**`@objectstack/hono`.** The `createHonoApp` catch-all (and the `/auth/*` fallback) read the JSON body with `c.req.json()`, which consumed the raw request for every `Content-Type`. A domain that forwarded the request got it empty. The body is now parsed from a clone, so `context.request` reaches the dispatcher unread, byte for byte. The parsed `body` the domains receive is unchanged for every input: JSON values as parsed, and `{}` for an empty, form, text or invalid body. The cost is that a request body is buffered twice for the life of the request. - -Nothing changes on a self-hosted server: the plugin's raw-app mount still serves the path there and never reaches the dispatcher. diff --git a/.changeset/pre/22577-approvals-act-allowlist.md b/.changeset/pre/22577-approvals-act-allowlist.md deleted file mode 100644 index 291d7eb5e60..00000000000 --- a/.changeset/pre/22577-approvals-act-allowlist.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/core": minor ---- - -feat(core): the ADR-0069 auth-gate allow-list admits the ADR-0043 approval action route, `approvals/act`, as one exact route - -Clause-②: yes - -- **What the allow-list admits now.** `isAuthGateAllowlisted` answers `true` for the approval action route `approvals/act`, at the mount bases the gate already recognises (`/api/v1/approvals/act`, `/api/approvals/act`, and the dispatcher's prefix-stripped `/approvals/act`), unscoped or under one `/environments/` (or legacy `/projects/`) scope, with the query string and trailing slashes stripped as for every other entry. Nothing else moves: `/approvals/act/`, `/approvals/actx`, the approvals base and every `/approvals/requests/…` route stay gated. -- **Why this is not a loosening.** ADR-0043 authenticates the action page by its single-use token alone, and `plugin-approvals`' self-hosted mount (`GET` renders, `POST` redeems on the host app) has never passed through this gate. The entry gives the dispatcher's spelling of the same route the same token-only authentication, so a session held by an authentication policy (expired password, required MFA) is no longer refused there with that policy's `403` before the token is read. -- **Effect on a kernel whose dispatcher serves no `/approvals/act` domain.** A held session that reaches the route gets the dispatcher's `404 ROUTE_NOT_FOUND`, the answer every other caller already gets there, instead of the policy's `403`. Nothing on the REST surface is affected: no REST route serves `approvals/act`, and no shipped seam passes a path to `shouldDenyAnonymous`. diff --git a/.changeset/pre/22578-approvals-action-page-member.md b/.changeset/pre/22578-approvals-action-page-member.md deleted file mode 100644 index 80ebe197128..00000000000 --- a/.changeset/pre/22578-approvals-action-page-member.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/plugin-approvals": minor ---- - -feat(plugin-approvals): `ApprovalService.handleActionPage` serves the ADR-0043 action page from a web-standard `Request` (#22578) - -Clause-②: yes - -- **What is new.** `ApprovalService` implements the optional `IApprovalService.handleActionPage(request)` member: the session-less page an approver reaches from the approve or reject link in an e-mail or IM message, from a `Request` to a `Response`, with no raw app, no Hono context and no `http.server`. `GET` renders the confirm page from the `token` query parameter and never decides. `POST` redeems the `token` field of the form body as the approver the token binds, and answers the result page. A dead link (unknown, expired, consumed, decided or reassigned) is told on the page with `200`. Any other method answers `405` with `Allow: GET, POST`. -- **The same pages, the same checks.** The member answers what the plugin's self-hosted pages answer for the same token and method: the same status, `Content-Type: text/html; charset=utf-8` and bytes. It goes through the same `peekActionToken` / `redeemActionToken` and the same page renderers, and both doors share one token store, so a link used at one is dead at the other. The token is the only credential: no session, cookie or `Authorization` header is read. -- **Its one caller** is the runtime HTTP dispatcher's `/approvals/act` domain (#22576), which reaches a hosted tenant kernel that owns no socket. -- **The self-hosted mount is unchanged.** Where `http.server` exposes a raw app, the plugin mounts `GET` and `POST /api/v1/approvals/act` exactly as before, and the path is never mounted twice. Where there is none, the plugin now logs one `info` line at boot saying the pages are served through the dispatcher's `/approvals/act` domain; it was silent. diff --git a/.changeset/pre/22579-migrate-telemetry-sibling.md b/.changeset/pre/22579-migrate-telemetry-sibling.md deleted file mode 100644 index 644e0df03dd..00000000000 --- a/.changeset/pre/22579-migrate-telemetry-sibling.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os migrate plan` / `apply` plan each object in the database it lives in, the `telemetry` sibling included (#22579) - -A development `os serve` boot on a file-backed SQLite database, and any boot with `OS_TELEMETRY_DB=`, keeps lifecycle-classed system data (audit, telemetry and event objects such as `sys_audit_log`, `sys_activity`, `sys_metadata_audit`, `sys_job_run`, `sys_notification`) in a sibling `telemetry` database. The one-shot boot `os migrate` runs never opened that sibling, so every one of those objects resolved to the primary database: after a development boot, `os migrate plan` listed each as a table to create, and `os migrate apply` created each in the primary — empty tables beside the ones the served boot uses. - -The migrate boot now provisions the sibling exactly when the serving boot would, through the same helper and under the same rule: `--dev` or `NODE_ENV=development` on a file-backed SQLite primary, or `OS_TELEMETRY_DB=`, and never with `OS_TELEMETRY_DB=0`. `plan` and `apply` diff and apply every object against the database it lives in, and print the sibling under the database line (`Telemetry database: …`); in `--json` it is the new `telemetryDatabase` field, present only when a sibling is planned. `apply`'s confirmation names both databases. A `plan` against a sibling that does not exist yet lists its tables to create and creates no file. `os migrate unmapped-columns` reads a lifecycle-classed object from the sibling, and names it as the database. - -A deployment with no sibling — production without `OS_TELEMETRY_DB`, or `OS_TELEMETRY_DB=0` — is unchanged. Run the migration with the `NODE_ENV` the deployment is served with: without `NODE_ENV=development` the plan describes a production `os serve`. Tables an earlier `os migrate apply` created in the primary for these objects are not removed. - -This supersedes the "Known limit" in this release's `os migrate plan` / `apply` composition entry: the migration boot now provisions the `telemetry` database. - -`os serve` loads the project's `.env*` files through the same function `os migrate` does; which files it reads, and in which mode, is unchanged. diff --git a/.changeset/pre/22580-unmanaged-tables-served-boot.md b/.changeset/pre/22580-unmanaged-tables-served-boot.md deleted file mode 100644 index 186aa4ba0b4..00000000000 --- a/.changeset/pre/22580-unmanaged-tables-served-boot.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os migrate plan`'s unmanaged-tables sweep runs whenever the plan's object set mirrors what `os serve` registers, covers every database the plan diffs, and states only a reason that holds when it does not run (#22580) - -On a project with a compiled artifact and no `objectstack.config.ts`, `os migrate plan` composes what `os serve` registers, platform included. The sweep for platform-prefixed tables that no object declares was still withheld there (`unmanagedTables.status: "unreadable"` in `--json`), with a reason claiming the plan's object set was only the artifact plus the platform floor. On a project with neither a config nor a compiled artifact, the same untrue reason was given. - -The plan's composition now records whether its object set mirrors the served boot, and why when it does not. The sweep runs exactly when it does, the compiled-artifact project included. When it does not run, the `detail` names the composition's own reason: the host config that did not load, or a project with neither a config nor a compiled artifact. - -The sweep also reads every database the plan diffs. Where the serving boot keeps lifecycle-classed objects in the `telemetry` sibling database, a table stranded there is now reported too, where before the sweep answered `read` over the primary database alone. A sibling whose catalog cannot be read makes the whole sweep `unreadable`, and the `detail` names that database. The `--json` shape of `unmanagedTables` is unchanged. `physicalTables` counts the tables of every database swept, and the human line says "in the database(s) this plan covers". diff --git a/.changeset/pre/22581-migrate-reads-env-files.md b/.changeset/pre/22581-migrate-reads-env-files.md deleted file mode 100644 index 50724f395d2..00000000000 --- a/.changeset/pre/22581-migrate-reads-env-files.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os migrate` reads the project's `.env` files as `os serve` does, and names the database it opened and who named it (#22581) - -`os serve`, `os start` and `os dev` load the project's `.env*` files before they read any variable. `os migrate` read only the process environment. So a project keeping `OS_DATABASE_URL` in `.env` served one database and migrated another: `os migrate plan` reported `.objectstack/data/objectstack.db` while `os serve` opened the `.env` database. An `OS_AUTH_SECRET` kept in `.env` left the auth family (`sys_user`, `sys_account`, …) out of the plan. - -Every command that boots the one-shot schema stack now loads the same files first, through the same `dotenv-flow` call, with `os serve`'s mode rule: `NODE_ENV`, else `production`, and `development` for `os migrate security-catalog-overlays --dev`, which composes as `os serve --dev`. That covers every `os migrate` subcommand, and also `os secret rewrap`, `os secret orphans` and `os storage orphans`. A variable exported in the shell still wins over `.env`, as it does for `os serve`. The SQLite occupancy check that `os migrate apply` and the other write commands run before booting reads the same files, so it checks the database the command is about to migrate. - -This supersedes two phrasings in this release's `os migrate plan` / `apply` auth-family entry: `os migrate` now does read the project's `.env` files, and the deployment's `OS_AUTH_SECRET` composes the auth family whether it is exported or kept in `.env`. - -`os migrate plan` and `os migrate apply` print the database with its source: `--database-url`, a variable from the process environment, a variable from a named `.env` file, the config's default datasource, or the default. In `--json` the source is the new `databaseSource` field (`{ kind: 'flag' | 'process-env' | 'env-file' | 'config-datasource' | 'default', variable?, file?, datasource? }`). `apply` now carries `database` and `databaseSource` on every payload after the boot, including `in_sync`, `nothing_safe_to_apply` and `confirmation_required`. diff --git a/.changeset/pre/22585-automation-skill-value-slots.md b/.changeset/pre/22585-automation-skill-value-slots.md deleted file mode 100644 index 84d7ba2bedc..00000000000 --- a/.changeset/pre/22585-automation-skill-value-slots.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -fix(skills): the automation skill teaches flow value slots as a CEL envelope or a literal and text slots as `{{ }}` holes; the single-brace `{…}` dialect leaves the catalog text - -Clause-②: no - -The `objectstack-automation` skill no longer lists `{$User.}`, `{NOW()}` / `{TODAY() ± N}` or any other single-brace `{…}` token as a spelling a flow value slot still reads, and the remedies it gives no longer pass through a refused spelling. What the skill now teaches is what the platform accepts: - -- A value slot — `assignments.*`, `fields.*`, a `subflow` / `map` `input.*`, a `script` `inputs.*`, a `loop` / `map` `collection`, a notify node's `recipients` / `templateData.*` / `sourceId`, an `http` node's `body`, each CRUD `filter` operand — takes a CEL value envelope `{ dialect: 'cel', source: '…' }` or a literal. A `{…}` token there is refused at `objectstack validate` (`expression-invalid`), `registerFlow` and the executor, naming its CEL spelling. -- A text slot — notify `title` / `message` / `actionUrl`, screen `title` / `description`, end `message`, `http` `url` and `headers` — renders `{{ }}` holes. -- The run user is `current_user.id`, guarded as `current_user != null ? current_user.id : null` in a flow that can run without a user; the date macros are `isoDate(today())`, `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, `isoDatetime(now())` and `isoDatetime(addDays(now(), N))`. -- A CRUD `filter` operand that is a whole-string query placeholder (`{current_user_id}`, `{today}`) passes through to the query engine untouched; an operand that evaluates to `null` refuses the node. -- A notify `recipients` value is one id, a list, or an envelope that evaluates to one, a list or `null` — a comma-separated string is one recipient. - -The `objectstack-query` skill's filters rule names the value-slot judge (`expression-invalid`) in place of the retired lint rule `flow-filter-token-unknown`, and the automation eval set's two expected outputs that spelled a retired token move with the text. diff --git a/.changeset/pre/22588-nested-refusal-sentence-zero-row-total.md b/.changeset/pre/22588-nested-refusal-sentence-zero-row-total.md deleted file mode 100644 index c980dae20c5..00000000000 --- a/.changeset/pre/22588-nested-refusal-sentence-zero-row-total.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/runtime": patch -"@objectstack/metadata-protocol": patch ---- - -fix: a hook's refusal reached through an action or another hook answers in the hook's own words, and `$top=0` reports the real `total` (#22588) - -Clause-②: no - -- **A nested sandboxed refusal keeps its sentence (`@objectstack/runtime`).** A script action whose body writes through `ctx.api` and is refused by a sandboxed hook used to answer with the hook's debug wrapper in front of the sentence: `POST /api/v1/actions/:object/:action/:id` → `400 VALIDATION_ERROR` "`hook 'guard' threw: Error: A finished task cannot be reopened.`". It now answers the hook's sentence alone, as `PATCH /api/v1/data/:object/:id` and a declarative `operation: 'update'` action already did. The same holds for a hook that writes another object through `ctx.api` and is refused there: `/data` answers the inner hook's sentence. Status and `code` do not change. The cause was the sandbox's VM hop: a host error crosses into a body's VM as `name` and `message` only, so the refused write's sentence was left behind and the wrapper became the outer error's caller-facing text. The sentence now travels beside the wrapper and is used when the body lets that error escape unchanged. A body that catches the error and throws its own (or rewrites its message first) is still answered in its own words, a nested crash is still a `500`, and the server log still names every frame. -- **The `/actions` door reads its sentence through `sandboxBusinessMessage`** (`@objectstack/types`), the same read `/data`, the bulk doors and `/analytics/dataset/query` make, instead of its own copy. The wire answer is unchanged. -- **`$top=0` reports the filtered `total` (`@objectstack/metadata-protocol`).** `limit: 0` asks for no rows, and `findData` treated it as "no limit", so the zero-length page was reported as the total: `GET /api/v1/data/:object?$top=0` answered `total: 0` while `$top=1` answered the real count. The Console reads its list footer's record count from exactly that `$top: 0` request. A zero-row request is now a paged request: it returns no rows and the same `total` as any other page size, filters included. `$count=false` still skips the count and now omits `total` for `$top=0` too, instead of reporting `0`. A `search` query still reports its page-local estimate, as it does at every page size. diff --git a/.changeset/pre/22589-approval-sibling-read-gates.md b/.changeset/pre/22589-approval-sibling-read-gates.md deleted file mode 100644 index 516bcea1b9a..00000000000 --- a/.changeset/pre/22589-approval-sibling-read-gates.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -fix(plugin-approvals): the generic data door serves `sys_approval_action` and `sys_approval_approver` rows by their request's visibility - -Clause-②: no - -A read of a request's two child tables through the generic data door (`find`, `findOne`, `count` and `aggregate`, so the REST data routes, the export route, the MCP data tool and every other engine read) now returns only the rows whose request the approvals door (`/api/v1/approvals/*`) serves the same caller: - -- `sys_approval_action`, the decision log (actor, decision, comment text); -- `sys_approval_approver`, the pending-approver index. - -Before this, a deployment that granted read on either object served every row of the organization on that door, including the decision log and comment text of requests the caller does not take part in. - -- **One rule for both doors, and for all three tables.** The gates ask the approvals service the same question the `sys_approval_request` gate asks, keyed by the row's `request_id`: the request's submitter, its current approvers and those who acted on it see its rows; an administrator holding the approval override sees every row; and, read-only, on an object named in `recordReaderVisibleObjects`, a reader of the record sees its request's rows on a read that names the request (`request_id`) or the row (its id). The published `ApprovalService` type is unchanged. -- **The record page's Timeline keeps its readers.** A request's Timeline (a list of `sys_approval_action` keyed on `request_id`) serves the rows `listActions` serves the same caller; a row read by id is served exactly when a decision attachment on it would be (`authorizeFileRead`, which now shares the gate's read of the row's request). -- **Absence, not refusal.** A row the caller may not see answers a by-id read exactly as a missing one does, `404 RECORD_NOT_FOUND`. A list's `total` and a grouped count are narrowed like its rows. A failed visibility answer denies the read and logs a warning. -- **Unchanged.** The approvals door, an administrator's view, and the approval engine's own reads. A deployment that grants no read on either object still answers `403`. -- The record-reader tier no longer anchors on a row of either table, even when `recordReaderVisibleObjects` names it, as it already never anchored on `sys_approval_request`. diff --git a/.changeset/pre/22590-run-summary-acted.md b/.changeset/pre/22590-run-summary-acted.md deleted file mode 100644 index 703557ee916..00000000000 --- a/.changeset/pre/22590-run-summary-acted.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -fix(service-automation): the `sys_automation_run` row's highlight set now shows `unmeasured_count` beside `selected_count` and `acted_count`. That puts all three operands of the broken-sweep first filter (`selected_count > 0 AND acted_count = 0 AND unmeasured_count = 0`) on the run row itself. The field's description now also lists a notification queued for delivery as an uncountable effect. - -Why: on a stack composed as `serve` composes it, reliable delivery is on, so a `notify` step's in-app message is enqueued. The outbox dispatcher writes it after the run settles, and the node reports `unmeasuredEffect`. A notify sweep that did its job therefore reads `acted 0, unmeasured 1`. With only `selected` and `acted` on the row, it read the same as a sweep with nothing to do. No counter changes: an enqueued delivery stays uncountable, never a fabricated `acted`. - -For operators: read the triple (`selected`, `acted`, `unmeasured`), never `acted` alone. `acted 0` with `unmeasured` above 0 means "cannot tell", not "did nothing". diff --git a/.changeset/pre/22591-sealed-remedy-kind.md b/.changeset/pre/22591-sealed-remedy-kind.md deleted file mode 100644 index 2ac3649db73..00000000000 --- a/.changeset/pre/22591-sealed-remedy-kind.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/plugin-security': minor ---- - -fix(metadata-protocol): a create under a name a managed package or a built-in holds is told to choose another name, not to edit the source artifact (#22591) - -Clause-②: yes - -The public input surface widens: `packagedBaseRefusal` accepts `operation: 'create'`, and `tenantAuthoredWriteRefusal` takes an optional operation; no verdict, code or status moves. - -A write onto an item a managed package ships, on a type with no environment overlay, is refused with `403 NOT_OVERRIDABLE`. The refusal's remedy used to assume the write was an edit of that item: "Edit the source artifact and redeploy", or, for a flow, an action or a permission set, its clone or on/off switch. That remedy is wrong for a create. An administrator who creates a position of their own under a name a package or a built-in already holds (`manager`, `everyone`, `guest`, …), or renames one into such a name, cannot edit a source artifact they did not write, and did not ask to customize the package's item. - -The remedy now follows what the write did: - -- **A create, or a rename into the name**, is told: the name is taken; to author your own item, choose a name no package or built-in holds, and `GET /api/v1/meta/:type` lists the names in use. This is one sentence for every metadata type. -- **An edit** keeps the sentence it had, byte for byte. - -Which writes are refused does not change, and neither do the code and the status. - -Where the remedy is a create's: - -- **Setup** (`@objectstack/plugin-security`): a create of a `sys_position` row, or a rename into a name a package or a built-in holds, under the `single` posture. The data door relays the metadata door's refusal for a create. -- **The metadata door** (`PUT /api/v1/meta/:type/:name`): a save sent with `If-None-Match: *`, the first-write precondition (`parentVersion: null` in `saveMetaItem`). Any other save is an edit, as before. -- **`ObjectStackProtocolImplementation.packagedBaseRefusal`** takes `operation: 'create'` beside `'save'` and `'delete'`, for a door that knows its write takes a name. The verdict is the `'save'` verdict. `tenantAuthoredWriteRefusal` takes an optional `operation: 'create'` the same way. diff --git a/.changeset/pre/22593-file-field-hydration-refused.md b/.changeset/pre/22593-file-field-hydration-refused.md deleted file mode 100644 index 29833c57874..00000000000 --- a/.changeset/pre/22593-file-field-hydration-refused.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/spec': minor ---- - -A file field whose `sys_file` read was refused to the reader reads as refused, not as "no file" - -Clause-②: yes - -The engine expands a stored file id (`file` / `image` / `avatar` / `video` / `audio`) by reading `sys_file` as the caller. A reader who may read the record but not `sys_file` (the default for any member whose application grants no `sys_file` read) is refused that read, and the field used to come back as the bare id: the same value an id with no committed file row produces. A refused file and an absent one were one answer, and the server logged a `warn` per read telling the operator to check storage availability. - -- **From.** `{ "attachment": "f_7cQx2Lm90a" }` for a reader refused `sys_file`, plus `warn: sys_file lookup failed; file fields keep their raw ids and will render as "no file"`. -- **To.** `{ "attachment": { "id": "f_7cQx2Lm90a", "metadataRefused": true } }`. Multi-value fields mark each id. Nothing else is served, because nothing else was read: no `name`, `size`, `mimeType` or `url`. A consumer that needs the download link derives the stable `/api/v1/storage/files/:fileId` endpoint from the id, as it does for a bare id; the download door judges access by the record that owns the file. The refusal is logged at `debug` only, because the reader is told in the value. -- **Unchanged.** A reader allowed to read `sys_file` gets the hydrated `{ id, name, size, mimeType, url }`. An empty field reads empty. A missing `sys_file` table still leaves the bare id, silently. Any other read failure (connection, timeout, a database-level ACL fault such as `42501`) still leaves the bare id with the `warn`. Only the security layer's `PERMISSION_DENIED` refusal takes the new form. - -`@objectstack/spec` declares the shape: `FileRefusedValueSchema` / `FileRefusedValue` (`{ id, metadataRefused: true }`, closed), and `valueSchemaFor(def, 'expanded')` for the file types admits it beside the bare id and `FileValueSchema`. It is a read form only. The stored form stays the bare id. - -**For consumers.** Code that reads a file field's expanded value as `string | FileValue` should handle the third form: an object with `metadataRefused: true` and no `url` is a file this reader may not see the details of, not an empty field. diff --git a/.changeset/pre/22599-verify-hooks-run-delete-answer.md b/.changeset/pre/22599-verify-hooks-run-delete-answer.md deleted file mode 100644 index 6aeed026fee..00000000000 --- a/.changeset/pre/22599-verify-hooks-run-delete-answer.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/verify': minor ---- - -fix(verify)!: `hooks.run(object, 'delete', …)` resolves with what the engine's `delete` answers, not with a row - -Clause-②: no (narrowing) - - - -**BREAKING** for TypeScript code that reads the answer of a `hooks.run` delete. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes. - -`hooks.run(object, 'delete', { id }, opts)`, for a person (`{ as }`) and for the system (`{ system: true }`), was declared to resolve with a row (`Promise`). The engine's `delete` answers `true` for the row it removed. So a test that read a field of the answer type-checked, and then read `undefined`. - -**What `hooks.run` declares now.** Both overloads are generic over the operation. - -- An `insert` or an `update` resolves with the row, as before. -- A `delete` resolves with the engine's own declared delete answer, `boolean | number`. It is read off `ObjectQL['delete']` rather than re-declared, and a `hooks.run` delete, which is always by id, answers `true`. -- A call whose operation is the union `'insert' | 'update' | 'delete'` still type-checks, and resolves with the union of the answers. - -**If your code read a field of a delete's answer.** That read was always `undefined` at run time, and it is now a compile error. Assert the answer itself, `expect(await stack.hooks.run(object, 'delete', { id }, opts)).toBe(true)`. If the test needs the deleted row, read it with `stack.rows(object, { id })` before the delete. diff --git a/.changeset/pre/22605-spec-list-view-edit-inline-default-on.md b/.changeset/pre/22605-spec-list-view-edit-inline-default-on.md deleted file mode 100644 index ca9a8e7a6cc..00000000000 --- a/.changeset/pre/22605-spec-list-view-edit-inline-default-on.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`ListView.userActions.editInline` defaults to `true` on the v18 line: a list view is editable in place by default, under the permission gate that already exists, and `userActions: { editInline: false }` is the opt-out. - -Clause-②: no - - - -**BREAKING** — a declared default moves, shipped as `minor` under the launch-window convention. The author who relied on the documented default (`false`, "the list is read-only unless the author opts in") for a list that is read-only by nature — a log, an audit trail, a history, a report roll-up — now gets an inline-edit toggle on it, for every user who may `update` the object. The remedy is one key on that view: `userActions: { editInline: false }`. - -**The ruling.** Maintainer, 2026-10-10, verbatim: 「乙 v18 把 spec 默认翻成 true,editInline: false 变成关法。」 The rule of objectui#5144 stands — one vocabulary (`editInline`), a boolean view-level `inlineEdit` folds into it, an explicit `editInline` wins, declared = enforced; only the default's value changes. Not taken: declaring `editInline: true` on every existing view, and an app-level default between the spec default and the view. - -**What moves.** `UserActionsConfigSchema.editInline` is `z.boolean().default(true)` where it was `.default(false)`, with the `describe` rewritten: on by default; a user who may update the object edits a cell in place with the field's type-aware widget; declare `editInline: false` to make the list read-only in place. A list view's `userActions` block, and a page's `interfaceConfig.userActions` block (the same schema), that omits `editInline` now parses to `true` where it parsed to `false`; the published JSON Schema and the reference pages (`content/docs/references/ui/view.mdx`, `page.mdx`) say so. A view with no `userActions` block at all parses with none on either side; the renderer reads an absent key as this default. The accept set is unchanged: `editInline` is still a boolean, and every authored value parses exactly as before. The permission gate is untouched: the renderer offers the toggle only where the object is editable in place and the current principal may `update` it. - -**The consumer half** is objectui's (objectstack-ai/objectui#12086): `ListView.tsx` and `InterfaceListPage.tsx` read an absent key as `editInline !== false`. Until that lands, the console at the pinned objectui still reads an absent key as off, so nothing regresses against today in the window. - -## FROM → TO - -| you wrote | what it means now | write instead, to keep the old behaviour | -|:--|:--|:--| -| nothing (no `editInline`) on a list view or a page list | editable in place by a user who may `update` the object | `userActions: { editInline: false }` | -| `editInline: false` | unchanged: read-only in place | nothing | -| `editInline: true` | unchanged: editable in place | nothing | -| a view-level `inlineEdit: true` | unchanged: folds to on and opens the grid in edit mode | nothing | - -**The one-line fix: on a list that must stay read-only in place, write `userActions: { editInline: false }`.** An `os compile` artifact that serialised an earlier parse carries a written `editInline: false` and stays read-only in place: recompile it, or delete the key. - -**Who is affected, measured** at `e11ef3a7bd`. No metadata in this repository declares `editInline` at all (0 `editInline: false`, 0 `editInline: true` across `examples/**` and `packages/**`, tests and changelogs excluded). Every list view that declares nothing gains the toggle: 38 list-kind views declared with an explicit `type` across 10 example `*.view.ts` files (entries with no `type` are grid by default and count the same way), 12 `interfaceConfig` page lists across 5 showcase page files (11 declare a `userActions` block, none naming `editInline`), and 105 list-kind views across 40 platform `*.object.ts` files in `packages/**`. One list view declares the view-level `inlineEdit: true` (the showcase `task` grid saved view) and keeps opening in edit mode. The card's readings outside this repository: cloud's `sys-package.page.ts` already declares `editInline: false` explicitly; HotCRM's 41 undeclared views regain cell editing after both packages upgrade, with no metadata change there. - -### The kit - -- **The default and its describe.** `packages/spec/src/ui/view.zod.ts`, `UserActionsConfigSchema.editInline`; the schema docblock records the ruling beside the group/hideFields/rowColor asymmetry it already explains. -- **The declared default change.** `DEFAULT_CHANGES_BY_MAJOR[17]` (`packages/spec/scripts/lib/default-changes.ts`) carries `ui/UserActionsConfig:editInline` `false` → `true`, which is what lets `check:authorable-surface` authorise the moved fingerprint in `authorable-defaults/ui.json`; `CURRENT_MAJOR` is the package major (17) until the version pass takes the group to 18. -- **The ledger.** The D3 semantic entry `list-view-edit-inline-default-on` (protocol 18), one file under `src/migrations/entries/semantic/`, concatenated into `registry.ts` by `gen:migration-registry`, projected into `spec-changes.json` and `docs/protocol-upgrade-guide.md`. No key is removed, so there is no tombstone and no `RETIRED_KEYS_BY_MAJOR` row; no D2 conversion exists, because a mechanical pass writing `editInline: false` into every silent view would preserve the old posture and defeat the ruling, and one writing `true` would add nothing the default does not already do. The same class as `view-pagination-page-size-default-50` and protocol 12's `rest-requireauth-default-flip`. -- **The pins.** `packages/spec/src/ui/view.test.ts`: an absent key parses to `true` (on `UserActionsConfigSchema` and through `ListViewSchema`), an explicit `false` stays `false`, an explicit `true` stays `true`, and the view-level `inlineEdit: true` still parses as before with no `userActions` block materialised. -- **The skill.** `skills/objectstack-ui/rules/list-views.md` gains its paragraph in a separate pull request, because `skills/**` is Tier H. diff --git a/.changeset/pre/22607-approval-opened-topic.md b/.changeset/pre/22607-approval-opened-topic.md deleted file mode 100644 index df06aea5fd0..00000000000 --- a/.changeset/pre/22607-approval-opened-topic.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-approvals": minor ---- - -feat(plugin-approvals): opening an approval step tells each resolved approver (`approval.requested`) - -Clause-②: yes (widening) - -- **What is new.** When an approval node opens a request, every concrete approver on the slate it opened on gets one notification on the new topic `approval.requested`: title "Approval requested", body "A decision on OBJECT/RECORD is waiting on you.", linked to that request in the Approvals Inbox, with the person whose run opened the step as its actor. It goes through the installed `messaging` service exactly as `approval.reminder`, `approval.reassigned`, `approval.escalated` and `approval.returned` do, so the same channels and delivery preferences apply. Before this, the opening was the one lifecycle step that published nothing: an approver learned that a step was waiting only by opening the inbox. -- **Who is told.** A named user, every holder of a staffed position or member of a team, and, when `onEmptyApprovers: 'fallback'` replaced an empty slate, the declared `fallbackApprovers`. Each person is told once, even when the slate lists them under two groups. -- **Who is not.** A `type:value` slot no one holds (`position:NAME` with no holders) names no person, so a step that resolves to nobody notifies no one, and `onEmptyApprovers` decides that case: `admin_rescue` opens on the literal slot and tells nobody, `auto_approve` and `fail` open no request. An out-of-office delegate gets one message, the existing `approval.ooo_substituted`, not a second one for the opening. Without a `messaging` service nothing is delivered, as before. -- **Not included.** The opening carries no one-tap Approve / Reject links. Those links are still minted only by a reminder. -- **If your flow already tells the approvers itself, remove that.** The approvals guide used to say that opening a request notifies nobody, and suggested a `notify` node next to the approval node as the workaround. A flow that followed that advice now tells the same approvers twice. Remove the `notify` node, or narrow its recipients to people the approval does not address. Keep `notify` nodes that tell the submitter or a watcher, or that report the decision. No flow in this repository's examples, `objectstack-ai/hotcrm` or `objectstack-ai/hotclm` has that workaround: every `notify` node in their approval flows runs after the decision. diff --git a/.changeset/pre/22611-list-view-dashboard-required-permissions.md b/.changeset/pre/22611-list-view-dashboard-required-permissions.md deleted file mode 100644 index c5923114a3f..00000000000 --- a/.changeset/pre/22611-list-view-dashboard-required-permissions.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): a list view and a dashboard take `requiredPermissions`, the audience gate, declared now and not enforced yet - -Clause-②: yes (widening) - -- **What is new.** `ListViewSchema` and `DashboardSchema` each accept `requiredPermissions`: a list of capability names (the names permission sets grant through `systemPermissions`) a user must ALL hold. It is the same key, with the same shape (`string[]`, optional, no default) and the same meaning, as `requiredPermissions` on an app, a navigation item and an action. Before this release both schemas refused the key as an unrecognized key. Because a list view's shape is shared, the key reaches every list-view door: a view container's `list` and `listViews` entries, an object's `listViews`, and a view item's `config`. -- **Not enforced yet.** No server reads either key in this release. A gated list view or dashboard is still served to every user who can read it, so do not rely on the key to keep a view or a dashboard from an audience yet. The key's description says so, and `os lint` / `os validate` warn (`liveness-planned-property`) when a dashboard or a view container's `list` sets it. Enforcement on the `/meta` reads of `view` and `dashboard` (the list read and the by-name read) is the separate enforcing change (#22639). -- **"Or" is a capability, not a new shape.** There is no any-of form: an object such as `{ anyOf: [...] }` is refused. To serve one view or dashboard to two audiences, declare one capability (for example `clm_legal_workbench.view`), grant it to both permission sets, and list that one name. `content/docs/permissions/capabilities.mdx` shows the pattern. -- **Nothing to migrate.** Every document that parsed before parses the same way; only a document that sets the new key, which was refused before, now parses. diff --git a/.changeset/pre/22614-meta-view-container-read.md b/.changeset/pre/22614-meta-view-container-read.md deleted file mode 100644 index 522d728ffce..00000000000 --- a/.changeset/pre/22614-meta-view-container-read.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -`GET /api/v1/meta/view/`, the by-name read of a `defineView` container, answers `200` on an artifact boot, and translates the views inside the container on every boot - -Clause-②: no - -The registry files a `defineView({ object, list, listViews, … })` container under the object it binds, and serves it on the by-name read of that key. The read handed the container to the translator for one view, which reads the view's own `name`. - -- **Artifact boot** (`os build`, then `os serve` of the artifact): a compiled container carries no `name`, so the read answered `500 INTERNAL_ERROR` and the log read `Cannot read properties of undefined (reading 'startsWith')`. It now answers `200`. -- **Config boot** (`os serve objectstack.config.ts`): its registrar stamps `name: ` onto the container, so the read answered `200`. But it served a `label` equal to the object name, which no author wrote, and it left every view inside the container in the authored language. That label is no longer served. -- **What is translated now, on both boots.** Each view inside the container (`list`, `form`, each `listViews` and `formViews` entry) carries the same `label`, `description` and bulk-action copy as the by-name read of the view it expands to (`.`). That copy is keyed by the same `objects.._views.` entry and judged against the same packaged base, so an organization's explicit override still beats the catalog. The container's own `label` and `description` are read from `objects.._views.`, and are left unchanged when the catalog has no entry there. -- **What stays the same.** The by-name reads of the expanded views and the `GET /api/v1/meta/view` list are unchanged. The list still never includes a container. -- **Packaging.** `@objectstack/rest` now lists `@objectstack/metadata` under `dependencies` (it was a dev dependency). The read imports `deriveViewContainerObject` from `@objectstack/metadata/view-container`, the registry's own derivation of the object a container binds. diff --git a/.changeset/pre/22615-objectql-residual-objects-served.md b/.changeset/pre/22615-objectql-residual-objects-served.md deleted file mode 100644 index 95dcfdd11f9..00000000000 --- a/.changeset/pre/22615-objectql-residual-objects-served.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -A top-level object that no package body of a multi-package stack declares is now served by the data door, under the stack's `manifest.id`, the id the metadata door already lists it under - -Clause-②: no - -- **What was wrong.** A stack that carries `packages[]` registers each package body under that package's id. A top-level object that no body declares is the stack's residual. The metadata door registered it under the stack's `manifest.id` and the boot warned that every door would report that id as its owner. The engine's `manifest` service registered the bodies only. So `GET /api/v1/meta/object` listed the object while `GET /api/v1/data/` answered `404`, on the artifact boot (`os serve` of a built artifact) and on the config boot (`os serve objectstack.config.ts`) alike. -- **What changes.** After the bodies, the `manifest` service registers the residual's objects under the stack's `manifest.id`. It reads which objects are residual from `unclaimedTopLevel` (`@objectstack/metadata`), the same answer the metadata door and `os validate` read. No package record is created for that id: the residual is not a package. When the id names one of the bodies (a composed stack keeps one member's manifest), that body's record is unchanged. A residual object that arrives after boot, with a late artifact, is copied to the metadata service as a body's objects are. -- **Listed and not served, by name.** Registering two classes of residual object would refuse a boot that the stack passes today, so they are not registered. They stay listed by the metadata door and unserved by the data door, and the boot prints one warning naming each object and why: - - an object whose name another package already owns: the data door keeps serving that package's object; - - an object with a field that names a picklist no package body declares: the stack's top-level picklists are metadata only, so the boot's picklist check would fail. - - The remedy in the warning is the residual warning's own: declare the object inside the `packages[]` entry of the package that owns it, with every picklist its fields name. To add fields to an object another package owns, use `objectExtensions`. -- **Unchanged.** A stack without `packages[]`, and a normally composed one whose top level repeats its bodies, has no residual and registers exactly as before. diff --git a/.changeset/pre/22616-approval-token-generic-door.md b/.changeset/pre/22616-approval-token-generic-door.md deleted file mode 100644 index 697cc883456..00000000000 --- a/.changeset/pre/22616-approval-token-generic-door.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/plugin-approvals': minor ---- - -fix(plugin-approvals)!: `sys_approval_token`, the action-link tokens, is no longer exposed through the automatic API - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing on a runtime door), shipped as `minor` under the launch-window convention for breaking changes. - -The object's own declaration already said its tokens are minted and consumed by the approval engine, never through the data API. It now says so in the form every door enforces, so the generic doors serve token rows to no caller, as the approvals door (`/api/v1/approvals/*`) serves them to nobody. - -**What narrows, from → to.** - -- The declaration: `enable.apiMethods: ['get', 'list']` becomes `enable.apiEnabled: false` with `enable.apiMethods: []`. `apiEnabled: false` is the automatic API's off switch, judged first for every operation; `apiMethods: []` is the declared fully-closed whitelist, so the whitelist no longer advertises the read methods either (an absent whitelist would read as fully open). -- The REST data routes (list, query, by id, export, and every write): a caller granted read on the object was served its rows, which name the request, the identity a link is bound to, the action, the expiry and the consumption state, and so was an administrator. Every caller, an administrator included, now receives `404 OBJECT_API_DISABLED`. The dispatcher and the MCP data tools judge the same switch first. -- Without a grant, the answer moves from `403 PERMISSION_DENIED` to `404 OBJECT_API_DISABLED`: the exposure gate is judged before the permission check. -- The cross-object search (`GET /api/v1/search`) no longer sweeps the object, for any caller. - -**Unchanged.** The approval engine's own token path, which runs as the system: a reminder still mints one approve and one reject link per concrete pending approver, the link's confirm page still validates without consuming, one POST still decides as the bound approver, and a replay is still refused as already used. The digest is still stored, indexed and withheld from every generic exit (`internal: true`). - -**What changes for you.** Nothing in the platform, the console or Setup reads this object through a generic door, so no shipped surface changes. A script or integration that read token rows through the data API now receives `404 OBJECT_API_DISABLED`. There is no replacement read, by design: a link's state is what its confirm page answers, and a decision taken through a link is recorded in the request's action history on the approvals door. diff --git a/.changeset/pre/22617-cli-organization-ownership-plan.md b/.changeset/pre/22617-cli-organization-ownership-plan.md deleted file mode 100644 index 16451ce7619..00000000000 --- a/.changeset/pre/22617-cli-organization-ownership-plan.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -feat(cli): `os migrate organization-ownership` — the read-only plan of the v18 organization-ownership ceremony (ADR-0131 D10, ceremony item 1) - -Clause-②: yes (widening) - -A new command beside the `os migrate` family. It reads the database and writes a plan file the operator keeps; it never changes a row or a column, and it has no writing mode yet. - -- **Per table:** the object's fate from the ADR-0131 D10 inventory (column drop, mirror deletion, attribution through a parent anchor, or report) with its citation, the row counts that fate touches, the rows whose owner cannot be derived (listed by id, with the reason), and whether the table will receive the `NOT NULL` constraint. -- **Ruled populations are counted by name:** the organization-scoped `sys_metadata` rows that the ceremony promotes, with their conflict list; the customized email templates; and the global settings rung that moves to `sys_platform_setting`. -- **Refuses, naming the table:** a table it cannot enumerate. That covers an unsupported driver, a read that fails, a column the fate needs that the table lacks, a platform table the inventory gives no fate, a name it cannot quote, and a database with no `sys_organization`. ⛔ It never reports such a table as empty. An inventoried object with no table on the database is listed as `absent`. -- **Under the `single` posture,** a NULL row that no anchor derives is attributed to the Default Organization (slug `default`). Under a walled posture it is reported. - -```bash -os migrate organization-ownership # writes organization-ownership-plan-TIMESTAMP.json -os migrate organization-ownership --out plan.json --json -``` - -The plan file is never overwritten. Nothing about bare `os migrate` (the schema-drift plan) or any other subcommand changes. diff --git a/.changeset/pre/22627-flow-screen-title-translation-text-slot.md b/.changeset/pre/22627-flow-screen-title-translation-text-slot.md deleted file mode 100644 index 7ba79f770a4..00000000000 --- a/.changeset/pre/22627-flow-screen-title-translation-text-slot.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -A translated flow screen heading (`flows..screens..title`) is judged by the same text-slot rule as the body text and the refusal message beside it. A single-brace `{token}` in it is refused when the bundle is parsed, with the `{{ }}` spelling of the token. An empty string is still accepted. - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. - -**Why.** The flow engine picks three translated templates in the run's locale and renders each through the one text-slot renderer, the `{{ }}` dialect: a screen's heading (`title`), its body text (`description`) and a refusing `end` node's `message`. The body text and the message refused a single-brace token. The heading accepted it and drew it as literal text, so `title: 'Welcome, {name}'` showed `Welcome, {name}`, and nothing was reported when the bundle was validated, saved or parsed. - -**What is refused.** A translated screen heading that the text-slot judge (`textSlotTemplateRefusal`) refuses: a single-brace `{token}`, and a `{{ }}` hole over a `$` name the flow engine does not bind. `TranslationDataSchema` and the `translation` metadata item refuse it at the key's path, with the words the source heading gets from `ScreenConfigSchema`. - -**Unchanged.** A heading that keeps the `{{ }}` holes of the text it translates (`'欢迎,{{ record.name }}'`), plain text, and the empty string `os i18n extract` writes into a skeleton, which the engine still reads as no translation. The heading's address does not change, and it still covers the node label a screen with no `config.title` shows. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `title: 'Welcome, {name}'` | `title: 'Welcome, {{ name }}'` where the heading should show the value; `title: 'Welcome'` where the braces were never meant as a hole | - -**The one-line fix: write each single-brace token in a translated screen heading as the `{{ }}` hole the refusal names.** - -**Who is affected, measured.** In this repository at `86da194919`, four bundles carry a `screens` group (`examples/app-crm`, `examples/app-showcase`, and `examples/app-todo`'s `ja-JP` and `zh-CN`). They hold 8 translated screen headings, and the judge refuses none of them. The same instrument finds the single-brace `{count}` in `examples/app-crm`'s `crm.activity.due_today` message, which is its control. Deployed bundles and other repositories were not measured. - -**The ledger.** The D3 semantic entry `translation-flow-screen-title-text-slot-refused` (protocol 18). There is no D2 conversion: a single-brace token in a translated heading has always drawn as literal text, so only the translator can say whether it was meant as a hole. diff --git a/.changeset/pre/22634-analytics-exposure-gate.md b/.changeset/pre/22634-analytics-exposure-gate.md deleted file mode 100644 index 03885dc138d..00000000000 --- a/.changeset/pre/22634-analytics-exposure-gate.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/service-analytics': minor ---- - -fix(service-analytics)!: the analytics door judges the two generic-exit declarations every other door judges — an object's `enable` block and a field's `internal: true` (#22634) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -Every generic exit judges an object's `enable` block through the spec's one exposure decision, `apiExposureDenialReason`: the REST data routes, the runtime dispatcher, the MCP data tools, and the cross-object search for the off switch. Every generic exit also withholds a field declared `internal: true`: the data door omits it from rows, the write responses strip it, and the engine's `aggregate()` refuses it as a group key. The analytics door read neither declaration. - -**FROM.** On both strategies (native SQL and ObjectQL), and for a member holding read and an administrator alike: - -- The ad-hoc query, the SQL face, the configured-cube door and the dataset door served the rows of an object declaring `apiEnabled: false`, and of an object whose `apiMethods` whitelist does not grant `list`. -- On the native-SQL strategy, a field declared `internal: true` was served as a group key, as an aggregate input and as a filter operand. -- On the ObjectQL strategy, the engine refused the same field as a group key or an aggregate input with an undeclared `500`. It still admitted the field as a filter operand. - -**TO.** Ahead of strategy selection, the door asks the spec's decision for the `aggregate` operation over every object a query reads: the base object, declared joins and relationship hops. It answers with the data door's codes for the same declaration: - -- `404 OBJECT_API_DISABLED` for `apiEnabled: false`; -- `405 OBJECT_API_METHOD_NOT_ALLOWED` for a whitelist that does not grant the verb. - -On the ad-hoc doors this happens before any cube is inferred, so a refused request mints nothing. A member that reads a field declared `internal: true` is refused `400 INVALID_FIELD`, naming the object and the field. This holds in every position: dimension, measure input, filter or sort key, dataset filter, and relationship hop. - -- **Refused, not withheld.** On the data door, the row path omits such a field because a row is a projection. Every analytics member is evaluated instead: a group key, an aggregate input, a predicate. There is nothing to omit, and the data door's own aggregate face already refuses the field as a group key. -- **Every caller.** Neither declaration takes a user, so administrators are refused exactly as members are. There is no system carve-out. -- **Fails closed.** A declaration lookup that throws refuses the query with `403 PERMISSION_DENIED`, logged at `error`. `AnalyticsServicePlugin` wires the lookup from the data engine and refuses when no engine can answer. A host constructing `AnalyticsService` by hand with no `getObjectDeclaration` hook gets no gate, and is told so once. -- **Unchanged.** An ordinary object and an ordinary field are served as before. An object that merely has an `internal` field is still served for every other field, and so is a bare count over it. - -**Measured producers.** On `origin/main` `f368b7e980`, no authored cube, dataset, dashboard or example in this repository names an `apiEnabled: false` object, an object whose whitelist omits `list`, or an `internal` field. The platform's System Overview datasets count `sys_user`, `sys_organization` and `sys_session`, and group `sys_audit_log` by two ordinary fields. Each of those objects grants `list`, and none of those members is `internal`. Deployed and cloud-held analytics definitions were NOT MEASURED. diff --git a/.changeset/pre/22634-spec-ledger-analytics-exposure-codes.md b/.changeset/pre/22634-spec-ledger-analytics-exposure-codes.md deleted file mode 100644 index ce8eaa36c4c..00000000000 --- a/.changeset/pre/22634-spec-ledger-analytics-exposure-codes.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the error-code ledger records `@objectstack/service-analytics` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22634) - -The analytics door now answers the data door's two exposure codes for an object whose `enable` block refuses the aggregate operation, so `ERROR_CODE_LEDGER['@objectstack/service-analytics']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered under `@objectstack/rest`. No export, schema or generated artifact moves. diff --git a/.changeset/pre/22636-flow-cel-record-trigger-no-object-opens.md b/.changeset/pre/22636-flow-cel-record-trigger-no-object-opens.md deleted file mode 100644 index 74f10ee5cf7..00000000000 --- a/.changeset/pre/22636-flow-cel-record-trigger-no-object-opens.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -A flow whose `record-*` trigger names no start `config.objectName` is no longer judged for unbound flow CEL roots by `objectstack validate`. With no object named, the record-change trigger registers its hook with no object filter, so it fires on a write to any object, one the stack does not declare included, and hands that row to the run as `record`, its fields flattened beside it. The build door cannot know that record's keys, so it leaves the flow unjudged, as it already does for a record trigger on an object the stack does not declare. The trigger shape itself is not refused. - -Clause-②: yes (widening: a flow whose `record-*` trigger names no start `config.objectName` opens, so `validate` no longer reports an unbound flow CEL root there) - -**What changes.** On such a flow, `expression-invalid` no longer reports a root the flow does not bind (`user.id`, `foo.bar`, a field name no declared object has). The same flow with a declared `config.objectName` is judged exactly as before. - -**The runtime publish gate, by design.** That gate does not judge unbound flow CEL roots on a flow write, and is not going to: who launches a flow and what record it is handed are read from other items of the stack, which makes this a whole-stack judgment that belongs to `objectstack validate`, and the gate's per-write snapshot is not widened to carry flows and actions. The stand-down there is the intended boundary, not a gap waiting on a wider snapshot. - -**Who is affected.** Every shipped `record-*` trigger names its object, so no example app changes: `objectstack validate` over `app-crm`, `app-todo`, `app-multi-package` and `app-showcase` reports the same findings before and after. diff --git a/.changeset/pre/22637-file-field-metadata-parent-derived.md b/.changeset/pre/22637-file-field-metadata-parent-derived.md deleted file mode 100644 index bcc12afeb1d..00000000000 --- a/.changeset/pre/22637-file-field-metadata-parent-derived.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/service-storage': minor ---- - -A reader refused `sys_file` read now sees the name, size and type of a file the record they are reading owns, whenever the download door would serve them its bytes - -Clause-②: yes - -The download door already judges a field-owned file by the record that owns it (ADR-0104 D3): a declared `fileAccessDelegate` decides where the owner object names one, otherwise the caller's read of that record. Record file-field hydration judged the same file by `sys_file` object read instead, which the default member grants do not include. So one reader could download a contract's signed PDF but saw the field as `{ id, metadataRefused: true }`: the bytes yes, the name no. - -- **From.** A reader with read on `contract` and no `sys_file` read gets `{ "signed_pdf": { "id": "f_7cQx2Lm90a", "metadataRefused": true } }` for every file id the record holds. -- **To.** For a file whose `ref_object` / `ref_id` name the record being read, that reader gets the hydrated `{ "id": "f_7cQx2Lm90a", "name": "signed.pdf", "size": 2048, "mimeType": "application/pdf", "url": "/api/v1/storage/files/f_7cQx2Lm90a" }`, exactly as a `sys_file` reader does, when the door's field-owned verdict allows it. A delegate-governed object (for example `sys_approval_action`, delegate `approvals`) gets its delegate's answer, not the raw record read. -- **Unchanged.** Every other id keeps `{ id, metadataRefused: true }`: an id copied in from another record, an attachment-only or unclaimed file, a `public_read` file the record does not own, and an owned file the delegate or the record read refuses. `sys_file`'s own read rule is unchanged, so a direct `sys_file` query by the same reader is still refused. A reader with `sys_file` read is served by their own read, as before. The download door's verdict for every request is unchanged. - -**One verdict, two callers.** `@objectstack/service-storage` moves the download door's field-owned arm into one module-private function (not exported) and hands it to the engine through a new seam, `ObjectQL.registerFieldOwnedFileReadAuthorizer` (type `FieldOwnedFileReadAuthorizer`, exported from `@objectstack/objectql`), the same handover `registerHeldFileResolver` already makes. The engine never re-derives it. An engine with nothing registered keeps the refused marker for every id. On a refused hydration the engine makes one more `sys_file` read under the caller's tenant (it reads only ids whose ownership pair names a record in the result) and asks the verdict once per read. - -**For consumers.** Nothing to change. A refused reader may now receive the full file object where it used to receive the marker, and code that already handles all three `expanded` read forms handles this. diff --git a/.changeset/pre/22639-lint-capability-references-list-views.md b/.changeset/pre/22639-lint-capability-references-list-views.md deleted file mode 100644 index d363f34d2b0..00000000000 --- a/.changeset/pre/22639-lint-capability-references-list-views.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -feat(lint): `validateCapabilityReferences` resolves the capabilities a list view or a dashboard requires (#22639) - -Clause-②: no - -- The `/meta` read gate now serves a list view or a dashboard only to a user who holds every capability its `requiredPermissions` names, so a misspelled name hides the item from everyone. `validateCapabilityReferences` resolves those names like every other carrier's: a dashboard's, a view container's `list` and each `listViews` entry, a view item's `config`, a flattened list view's, and an object's own `listViews` entries. An unresolved name warns `capability-reference-unknown` at its own path (for example `views[0].listViews.legal_review.requiredPermissions`). -- It is a warning, as for objects, fields, apps and actions: a single package's lint cannot see a capability another installed package declares. diff --git a/.changeset/pre/22639-rest-meta-read-gate-audience.md b/.changeset/pre/22639-rest-meta-read-gate-audience.md deleted file mode 100644 index fb6a0d73ce2..00000000000 --- a/.changeset/pre/22639-rest-meta-read-gate-audience.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/rest": minor ---- - -feat(rest): the `/meta` read gate enforces a list view's and a dashboard's `requiredPermissions` (#22639) - -Clause-②: yes (widening) - -- **What the server now does.** `requiredPermissions` on a list view and on a dashboard is a list of capabilities a user must ALL hold, the same key and meaning as on an app, a navigation item and an action. The `/meta` read gate applies it through the one predicate the app arm uses (`holdsRequiredPermissions`), on both transports (`RestServer` and the runtime dispatcher's `/meta` domain): - - `GET /api/v1/meta/view` and `GET /api/v1/meta/dashboard` list only what the caller may open. A view the caller does not hold is left out, so the console's view switcher (`?object=`) lists only the views the caller may open. A view container is listed minus the `list` / `listViews` entries the caller does not hold. - - `GET /api/v1/meta/view/:name` and `GET /api/v1/meta/dashboard/:name` refuse a view or a dashboard the caller does not hold with `403 PERMISSION_DENIED`, the same refusal and envelope an app the caller may not open gets. Every alternate door of the read refuses it too (`/published`, `/layers`, `?layers=`, `?state=draft`, `/diff`, `/history`, `/audit`), to an author as to anyone. - - `GET /api/v1/meta/object` and `/meta/object/:name` serve an object without the `listViews` entries the caller does not hold. A caller who may edit the object (the ADR-0106 D4 exemption the field mask already honours) reads every list view, so a save of what they loaded keeps them. - - A view container is served whole to a caller who may write it on the stored-version doors (`/layers`, `?layers=`, `/diff`, `?state=draft`), as an app is, so a designer's save keeps the entries withheld from its author elsewhere. -- **Caching.** The plain read's cached arm runs the same gate for `view` and `object`. Its `ETag` folds in what the gate withheld, and the conditional request is judged against that validator, so a caller who no longer holds a capability is never answered a `304` for the body that carried the gated view. When nothing is withheld the `ETag` is the same as before. -- **Type surface.** `MetaItemReadRefusal` gains the `audience-permission` reason (`403 PERMISSION_DENIED`). A consumer that switches on `reason` writes it the way it writes `app-permission`. -- **Nothing to migrate.** Only a list view or a dashboard that sets `requiredPermissions` is affected. The key was not accepted on either before the release that declared it. diff --git a/.changeset/pre/22639-runtime-meta-legacy-object-exit.md b/.changeset/pre/22639-runtime-meta-legacy-object-exit.md deleted file mode 100644 index de0c62c56ba..00000000000 --- a/.changeset/pre/22639-runtime-meta-legacy-object-exit.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(runtime): the `/meta/` legacy object exit asks the read gate before it serves the schema (#22639) - -Clause-②: no - -- The runtime dispatcher's one-segment `/meta/` exit, reached when a host's protocol has no type catalogue and no list for the segment, served the object schema through the field mask alone. It now asks the shared `/meta` read gate first, as every other object exit does, so an object's own `listViews` entries whose `requiredPermissions` the caller does not hold are not served there either. A caller who may edit the object still reads them all. diff --git a/.changeset/pre/22639-spec-audience-gate-enforced.md b/.changeset/pre/22639-spec-audience-gate-enforced.md deleted file mode 100644 index 4af41e493d4..00000000000 --- a/.changeset/pre/22639-spec-audience-gate-enforced.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): a list view's and a dashboard's `requiredPermissions` is enforced — the describe and the liveness ledger say so (#22639) - -Clause-②: no - -- The shared description of `ListViewSchema.requiredPermissions` and `DashboardSchema.requiredPermissions` drops its "not enforced yet" clause and says what the server does: a user who does not hold every capability is not listed the item and is refused it by name, a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. -- The liveness ledger rows (`liveness/view.json` `list.requiredPermissions`, `liveness/dashboard.json` `requiredPermissions`) are `live`, citing the `/meta` read gate, and no longer carry `authorWarn`, so `os lint` / `os validate` stop warning `liveness-planned-property` when a view or a dashboard sets the key. -- The schemas themselves do not change: same shape, same accept set. diff --git a/.changeset/pre/22640-search-api-exposure.md b/.changeset/pre/22640-search-api-exposure.md deleted file mode 100644 index d8e6e60bfcf..00000000000 --- a/.changeset/pre/22640-search-api-exposure.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol)!: the cross-object search asks the spec's exposure decision for `search` on each object, so an `apiMethods` whitelist that withholds `list` keeps the object out of the sweep (#22640) - -Clause-②: no - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The REST data routes, the runtime dispatcher, the MCP data tools and the analytics door judge an object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`). In the derivation table, `search` derives from `list`. The cross-object search (`GET /search`, `searchAll`) spelled its own skip instead, and that skip read only `apiEnabled: false` and `searchable: false`. - -**FROM.** The search swept an object whose `apiMethods` whitelist does not grant `list` (for example `['get']`, or the deny-all `[]`), unless the object also declared `searchable: false` or `apiEnabled: false`. It served that object's rows while every data route refused them. - -**TO.** Before an object is queried, the search asks `canServeApiOperation(enable, 'search')`, and an object it refuses is skipped. A skipped object is never queried, named or counted. An explicit `objects=` naming it answers like one naming no object at all. - -- **Every caller.** The decision takes no user, so administrators and context-less in-process calls skip the same objects. -- **`searchable: false` keeps its own skip.** The decision answers an object with no whitelist without reading that flag. -- **Unchanged.** An object with no whitelist, or with one that grants `list`, is swept exactly as before. - -**Measured producers.** Read on `origin/main` `cb3bb933`: every object in this repository whose whitelist withholds `list` also declares `searchable: false` or `apiEnabled: false`, and no example app declares `apiMethods`. So no shipped object's search results change. Deployed and cloud-held object definitions were NOT MEASURED. diff --git a/.changeset/pre/22642-flow-cel-record-binding.md b/.changeset/pre/22642-flow-cel-record-binding.md deleted file mode 100644 index 715ec5ec02e..00000000000 --- a/.changeset/pre/22642-flow-cel-record-binding.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/service-automation': minor ---- - -fix(service-automation)!: in flow CEL, `record` is the record the run was handed, or unbound — never the run's variables - -Clause-②: no (narrowing) - - - -**BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. - -**What changed.** A flow CEL expression — a node's or an edge's `condition`, a `decision` branch `expression`, a screen field's `visibleWhen`, and the CEL value envelopes of an `assignment` and of a `create_record` / `update_record` `fields` map — evaluates in the scope `AutomationEngine.celScope` builds. That scope used to bind `record` to the run's variables map whenever the run held no record, so `record.assignee` read a flow variable named `assignee` instead of failing. Now `record` is bound only when: - -- an entrance handed the run a record: a record-change trigger's row, a time-relative sweep's row, the inbound hook's request body, a `type: 'flow'` action's record (the loaded row, or an empty record carrying at most the id it was given), a `subflow` or `map` parent's record, or a `map` item that carries a string `id`; -- or the flow binds a variable named `record` itself (a declared variable with a value, or an assignment target). - -With neither — a run started through the REST trigger route or a declared endpoint, a cron schedule, or a child of such a run — `record.X` fails the run with `Unknown variable: record` and the expression's source, as every other unbound root does. - -**What does not change.** A run that was handed a record reads `record.X` from that record, as before, over any variable of the same name. A bare variable name (`assignee`) and the `vars` namespace (`vars.assignee`) resolve as before. `$record`, `previous`, `vars` and `current_user` are untouched. - -## Migration: FROM → TO - -In a flow whose run holds no record, read a variable by its name: - -| you wrote | write instead | -|:--|:--| -| `record.assignee` (a flow variable `assignee`) | `assignee`, or `vars.assignee` | -| `has(record.assignee) ? record.assignee : null` | `has(vars.assignee) ? vars.assignee : null` | - -**The one-line fix: read a flow variable by its name or through `vars`; `record` is only ever the record the run was handed.** A flow that is also launched with a record (an action on a row, a record trigger) keeps reading that record's fields as `record.X`. - -**Who is affected, measured.** Read statically over every flow CEL slot — 0 flows read `record` with no record entrance: - -- this repository at `0ec4268972` (`examples/**`, `packages/platform-objects`, `packages/qa/dogfood`): 64 flows, 51 CEL slots, no flow in `packages/platform-objects`; the 2 flows that read `record` are an `api` hook flow and a record-change flow; -- `objectstack-ai/hotcrm` at `f0afcbda07` (`src/`, `test/`): 45 flows, 57 CEL slots; the 12 flows that read `record` are all record-change flows. - -Deployed metadata and other repositories were not measured. `objectstack validate` refuses such a `record` read too, naming the root (see `@objectstack/lint`'s note). diff --git a/.changeset/pre/22646-internal-field-data-door-positions.md b/.changeset/pre/22646-internal-field-data-door-positions.md deleted file mode 100644 index 79495c302c4..00000000000 --- a/.changeset/pre/22646-internal-field-data-door-positions.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/metadata-protocol': minor ---- - -fix(objectql,metadata-protocol)!: the data door honours a field's `internal: true` in its filter (related-object conditions included), sort, group-by, aggregate, per-aggregation-filter, `$search` and `$expand` positions at every depth, not only the row (#22646) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -A field declared `internal: true` is withheld from every generic exit (#21197): the engine omits it from every row, select-named included. The ROW position honoured that; the generic data door's EVALUATE positions did not, so a value the platform withholds from rows could still be learned through a position that answers by its stored value. - -**FROM.** On `origin/main`, for a member holding read and for an administrator alike, on the list and query data routes (`GET /api/v1/data/:object`, `POST /api/v1/data/:object/query`): - -- A **filter** naming such a field was admitted — the predicate reached the driver, so the row came back only when the guess matched: a confirmation oracle over a withheld value. -- A **sort** by such a field was admitted — the order leaks the comparative stored value. -- A **group-by** of, or an **aggregate operand** over, such a field was refused by the engine, but as an undeclared `500 INTERNAL_ERROR` (a bare `Error` with no `code` or `status`), not a declared refusal. -- A **per-aggregation filter** (`aggregations[].filter`) naming it, and a **filter beside an aggregation**, were admitted — the same oracle at a second filter position. -- A **`$search`** whose resolved set included such a field (a non-hidden `internal` text column enters the auto-default) scanned it — a cross-field substring match that confirms a guessed prefix. -- A **nested-relation condition** (a condition on a lookup's related object) naming the related object's `internal` field was evaluated on that object — the parent row came back only when the related value matched, the same oracle one hop away. -- An **`$expand`** entry's own `where` / `orderBy` on its TARGET object's `internal` field was admitted into the expansion sub-read, at the first level and at every deeper level — the expanded record's presence is an oracle on the related row. - -**TO.** Each evaluate position is refused `400 INVALID_FIELD`, a declared ADR-0112 envelope naming the object and the field, before the engine is asked: - -- Filter, sort, group-by, aggregate operand and per-aggregation filter are refused at the generic data door (`@objectstack/metadata-protocol`), after the field-existence gates, in the shape and envelope of the stored-metadata body/hash family one axis over. A nested-relation condition inside any filter is judged against the RELATED object's own `internal` fields, and every level of an `$expand` tree has its own filter / sort judged against that level's target object; the refusal names the object that declares the field. -- The engine's `aggregate()` refusal (`rejectCredentialAggregation`) now carries the `INVALID_FIELD` / 400 envelope instead of the bare `Error`, so the group-by and aggregate-operand positions answer a declared `400` rather than a `500` — and the `secret` / `password` case that shared that bare `Error` is upgraded with it. -- A `$search` never scans an `internal: true` column: the field is dropped from the set the engine's search expansion resolves over (a withhold, like the auto-default's `hidden` / credential-type exclusions), and an explicit `$searchFields` naming one is refused `400 INVALID_FIELD` at the door rather than silently widened back to the default set. - -**Bindings.** - -- **Every caller.** The refusal is at the generic data door and takes no user, so an administrator is refused exactly as a member is (#21197). There is no system carve-out at the door. -- **The engine's privileged consumers are untouched.** The API-key verifier's `where: { key: }` lookup, the share-link / SCIM / approval-token lookups — all call the engine directly under a system context and never pass the generic data door, so authentication keeps working, exactly as #7823 kept the engine's write results whole while the generic-data-path ingress stripped them. -- **One flag reader.** Every position judges `collectInternalReadFields` (engine) or its byte-identical `@objectstack/core` twin `collectInternalWriteResponseFields` (the door, which sits below objectql), never a second list. -- **Unchanged.** An ordinary field in every position is served as before; an object that merely has an `internal` field is served for every other field. - -**Scope.** Target-OBJECT exposure through `$expand` is a separate axis (#22661); the cross-object search sweep (`searchAll`) is #22640's region and inherits the engine's search-set withholding through `engine.find` without a change to its own code. diff --git a/.changeset/pre/22655-submit-redirect-docblock.md b/.changeset/pre/22655-submit-redirect-docblock.md deleted file mode 100644 index 12958dab83b..00000000000 --- a/.changeset/pre/22655-submit-redirect-docblock.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The `submitBehavior.url` docblock in `src/ui/view.zod.ts` no longer names a navigation mechanism - -Clause-②: no - -The docblock above the redirect `url` check said objectui's `FormPage` performs a verbatim -redirect on this value, and that the value reaching `window.location.assign` is the string with -its tokens substituted. Neither is how the Console behaves at the objectui pin this package -builds against (`20c6d351ad74`): it substitutes each `{{record.…}}` token, URL-escapes the -value, and navigates in-app through its router. The docblock now states the ruled contract -only: relative paths only, interpolation only from declared record fields with every value -URL-escaped, and the resolved relative path is the destination. It says that the navigation -mechanism belongs to each consumer, and marks the pre-ruling `FormPage` behaviour as history. -Comment text only: no schema, key, type, export or `.describe()` string changes. diff --git a/.changeset/pre/22658-skills-package.md b/.changeset/pre/22658-skills-package.md deleted file mode 100644 index 3e6d0e05582..00000000000 --- a/.changeset/pre/22658-skills-package.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -"@objectstack/skills": minor -"@objectstack/spec": patch ---- - -New published package `@objectstack/skills`: the ObjectStack skills catalog (`skills/**` of the repository), shipped in the changeset `fixed` group so it always carries the version of the `@objectstack/*` packages it teaches. Its build copies the catalog into `dist/skills//…` byte for byte (the layout the skills CLI's `experimental_sync` reads from `node_modules`), and `files` lists only that tree. The repository's `skills/**` stays the one source of truth and the documented `next` channel; nothing a consumer writes changes. - -`@objectstack/spec`: `llms.txt`'s package-ecosystem count reads 69 with `@objectstack/skills` joining the scope. diff --git a/.changeset/pre/22659-scaffold-skills-from-package.md b/.changeset/pre/22659-scaffold-skills-from-package.md deleted file mode 100644 index 26e8e20083e..00000000000 --- a/.changeset/pre/22659-scaffold-skills-from-package.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"create-objectstack": minor ---- - -feat(create-objectstack): a scaffolded project installs its AI skills from the `@objectstack/skills` package at its own `@objectstack/spec` version, never from the repository's `main` branch - -Clause-②: no - -- **The project depends on the catalog.** The blank template's `devDependencies` gain `@objectstack/skills`, at the same `^` as every other `@objectstack/*` dependency, so a new project's dependency set changes: the skills are the ones written for the `@objectstack/spec` it installs. -- **The install reads `node_modules`.** After ` install`, the scaffolder runs `npx -y skills@1.7.2 experimental_sync --agent claude-code --copy -y`, which copies the installed package's catalog into `.claude/skills/` as real files, once, beside a `skills-lock.json` that names the package as every skill's source. The GitHub `npx skills add objectstack-ai/objectstack/skills …` command is gone from the run, its summary and the `AGENTS.md` and `README.md` it writes into the project. -- **The summary says what happened.** It names the package version installed and prints the same command for re-installing after an upgrade of the `@objectstack/*` packages, and for any other agent runtime. When the install leaves no catalog to read, or a skill did not come from the package, the run says so instead of reporting the skills installed. -- **The flags keep their meaning.** `--skip-skills` skips the skills step. `--skip-install` installs no skills, because nothing is in `node_modules` yet; the summary names ` install` and then the sync command. -- **To move a project created earlier onto the package:** add `@objectstack/skills` to `devDependencies` at your `@objectstack/spec` range and run your package manager's install. Remove the skills `skills add` installed — `npx skills remove objectstack-data -y` and so on for each `objectstack-*` skill, or `npx skills remove --all` if you installed no others — because the sync skips a skill it did not install and leaves the old copy in place. Then run `npx skills@1.7.2 experimental_sync --agent claude-code --copy -y` (name your own runtime after `--agent`). diff --git a/.changeset/pre/22661-second-object-exposure.md b/.changeset/pre/22661-second-object-exposure.md deleted file mode 100644 index 63ed91b9acb..00000000000 --- a/.changeset/pre/22661-second-object-exposure.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/service-analytics': minor ---- - -fix(metadata-protocol,service-analytics)!: a read that follows a lookup asks the TARGET object its declared exposure — the data door's `$expand` at every level, and the dataset door's dimension-label passes (#22661) - -Clause-②: no - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The data routes judge the ADDRESSED object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`): `enable.apiEnabled: false` answers `404 OBJECT_API_DISABLED`, and an `enable.apiMethods` whitelist that does not grant the verb answers `405 OBJECT_API_METHOD_NOT_ALLOWED`. A read that FOLLOWS a lookup reaches a second object nobody addressed, and two such reads never asked it. - -**FROM.** For an administrator and a member alike: - -- **`$expand`** — `GET /api/v1/data/:object?$expand=`, `GET /api/v1/data/:object/:id?expand=`, the relation map on `POST /api/v1/data/:object/query` (every level of the tree), and the export door (which expands every lookup to name it) served the row fields of a lookup's target whose declaration refuses it on every data route. -- **Dataset dimension labels** — `POST /api/v1/analytics/dataset/query` rendered a reference-class dimension's grouped id as the target record's display name, and sorted an `order` on that dimension by those names, for the same targets. - -**TO.** Each read asks the spec's one decision of the TARGET object, for `get` — the read both perform is "turn an id the caller holds into the record it names", the read `GET /api/v1/data/:target/:id` performs: - -- An `$expand` entry whose target the decision does not serve is withheld at the generic data door (`@objectstack/metadata-protocol`), at every level: the field answers as an UNEXPANDED lookup, its stored id — the answer the door already gives for a related record the caller may not read. Nothing below a withheld entry is read. -- A dimension label is not read for such a target (`@objectstack/service-analytics`): the stored id renders, and an `order` on the dimension sorts by it — the answer a target the reader's row scope hides already gets. A declaration that cannot be read withholds the labels too (fail-closed, at `warn`); the query itself still answers. - -Concretely, a target declaring `apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `get` (for example `['list']` or `['create']`) is withheld from both; a target with no `enable` block, or a whitelist that grants `get`, is served exactly as before. - -**Bindings.** - -- **Every caller.** The decision takes no user, so an administrator, a member and a system context get the same tree and the same labels. -- **The engine's privileged callers are untouched.** A flow's `config.expand` and a hook's own reads call the engine directly and never pass the generic data door, the same split the write results and the `internal: true` evaluate positions keep. -- **No new error code.** Both reads withhold rather than refuse, so no response status changes. - -**Measured producers.** Read on `origin/main` `eae3368a` over every `.ts` source under `packages/` and `examples/`: ten objects refuse `get` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false`. No example declares `enable.apiEnabled` or `enable.apiMethods`. So no shipped object's served answer changes. Deployed and cloud-held object definitions were NOT MEASURED. diff --git a/.changeset/pre/22663-analytics-unexposed-cube-registration.md b/.changeset/pre/22663-analytics-unexposed-cube-registration.md deleted file mode 100644 index 706cd50f3dc..00000000000 --- a/.changeset/pre/22663-analytics-unexposed-cube-registration.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/service-analytics': minor ---- - -fix(service-analytics)!: a configured cube, or a dataset `registerDataset` registers, over an object the API does not serve is refused at registration, and `GET /analytics/meta` no longer lists it (#22663) - -Clause-②: yes (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -Since the analytics door started judging an object's `enable` block, every query over an object whose `enable` the spec's `apiExposureDenialReason` denies for the `aggregate` operation is refused: `404 OBJECT_API_DISABLED` for `apiEnabled: false`, `405 OBJECT_API_METHOD_NOT_ALLOWED` for an `apiMethods` whitelist that does not grant it. The registration doors did not ask the same question. - -**FROM.** A configured cube (`analyticsCubes`, or `AnalyticsServicePlugin({ cubes })`), or a dataset registered through `registerDataset` (or the service's `datasets` option), whose base object or declared join is such an object registered silently. `GET /analytics/meta` listed it, and every query of it then answered 404 or 405. Nothing told the author at registration time. - -**TO.** Registration asks the same decision, for the same operation, over the same objects the query door's first check reads: the base object and the declared joins. - -- **A configured cube** over such an object is not registered. The service logs one warning per cube that names the cube, the object and the declaration (`enable.apiEnabled: false`, or the `enable.apiMethods` whitelist and the operations it grants). Every other cube still registers, and the boot continues. This is the channel a pre-registered dataset that cannot compile already takes. -- **`registerDataset`** throws, registering nothing. The error names the dataset, the object and the declaration, and carries the query door's code and status for that declaration (`OBJECT_API_DISABLED` / 404 or `OBJECT_API_METHOD_NOT_ALLOWED` / 405) with the object. -- **A cube written straight to the service's public `cubeRegistry`** is refused by the registry's own admission in the same words. -- **`GET /analytics/meta`** never lists a refused definition. - -**Unchanged.** - -- A cube or dataset over an exposed object registers and lists exactly as before. -- A host that wires no `getObjectDeclaration` hook registers every definition as before. `AnalyticsServicePlugin` always wires it. -- Registration judges only what is known when it runs, and does not block otherwise. An object that is not registered yet when the analytics plugin's `init()` registers the configured cubes is not refused, and neither is one whose lookup throws. Objects declared by a plugin that inits later, or in a `start()`, fall in this class. Such a cube registers and lists as before, and the query door still refuses every query of it. -- A relationship hop that one member walks is judged at query time, per member, and does not refuse the whole cube. - -**Measured producers.** On `origin/main` `ee3ae0360d`, this repository has one authored cube, `showcase_delivery` (`showcase_task` joined to `showcase_project`), and no `registerDataset` producer outside tests. Neither object declares an `enable` block. The eleven example and platform datasets (six in `examples/**`, five in `platform-objects`) are queried through the request-scoped dataset door and never enter the registry. Deployed and cloud-held definitions were NOT MEASURED. diff --git a/.changeset/pre/22664-spec-ledger-mcp-exposure-codes.md b/.changeset/pre/22664-spec-ledger-mcp-exposure-codes.md deleted file mode 100644 index 1069e3e7a07..00000000000 --- a/.changeset/pre/22664-spec-ledger-mcp-exposure-codes.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec): the error-code ledger records `@objectstack/mcp` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22664) - -Clause-②: yes - -- **`ERROR_CODE_LEDGER` gains the `@objectstack/mcp` owner key.** The MCP stdio data bridge stamps both exposure codes onto the error it throws when an object's `enable` block refuses a data verb: `OBJECT_API_DISABLED` (404) for `apiEnabled: false`, and `OBJECT_API_METHOD_NOT_ALLOWED` (405) for an `apiMethods` whitelist that does not grant the verb. The published type of `ERROR_CODE_LEDGER` gains the key. `ErrorCode` and `REGISTERED_ERROR_CODES` are unchanged, because both codes were already registered under `@objectstack/rest`. -- **`PROVENANCE_WAIVERS` gains one entry:** `@objectstack/types` → `UNIQUE_SCOPE_CONFIRMATION_REQUIRED`, registered under `@objectstack/cloud-connection`. `@objectstack/types` defines the shared constant `GLOBAL_UNIQUE_CONFIRMATION_REQUIRED`. The one stamp is the marketplace install seam's 409 in `@objectstack/cloud-connection`, whose owner key already lists the code. - -No code is added, renamed or removed, and no schema moves. diff --git a/.changeset/pre/22668-skills-readme-install.md b/.changeset/pre/22668-skills-readme-install.md deleted file mode 100644 index 50130190805..00000000000 --- a/.changeset/pre/22668-skills-readme-install.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/skills": patch ---- - -docs(skills): both READMEs the package ships install the catalog from `@objectstack/skills` at the installed `@objectstack/spec`'s version, and name the GitHub path only as the `next` channel (#22668) - -Clause-②: no - -`README.md` (the npm page) and `dist/skills/README.md` (the catalog's front page, copied from the repository's `skills/README.md`) are the two READMEs this package ships. Both now give the same two-step install the docs give: `npm install --save-dev "@objectstack/skills@$(node -p "require('@objectstack/spec/package.json').version")"`, then `npx skills experimental_sync --agent claude-code -y` — the catalog's version is then the installed spec's. - -Before: the npm page taught a bare `npm install --save-dev @objectstack/skills`, which on a project depending on `@objectstack/spec` 17.x installs `18.0.0-next.0`, the next major's catalog (the registry's `latest`); its example named `@objectstack/skills@17.7.0`, a version that was never published; and it did not say that the sync skips every skill a `skills add` installed, which is every project `create-objectstack` scaffolded through `18.0.0-next.0`. The catalog's front page still taught `npx skills add objectstack-ai/objectstack/skills …` as the install command for existing projects. - -Both pages now state which versions exist (published from `18.0.0-next.0` on; no 17.x, so step 1 answers `ETARGET` there), the update story (bump `@objectstack/skills` with the other `@objectstack/*` packages, then re-run the sync), and the GitHub path once, as the `next` channel: the unreleased `main`, for trying the next version's skills, not for a released project. Nothing under `dist/skills/*/` changes. diff --git a/.changeset/pre/22677-flow-cel-record-entrance.md b/.changeset/pre/22677-flow-cel-record-entrance.md deleted file mode 100644 index cc0df6456a6..00000000000 --- a/.changeset/pre/22677-flow-cel-record-entrance.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/lint': major ---- - -A flow CEL expression that reads `record` on a flow no entrance hands a record is refused at `objectstack validate`. Since #22642 the run binds `record` only to the record it was handed, so such a read fails the run with `Unknown variable: record`, and the build door still counted `record` as bound on every run and passed it. - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface (`objectstack validate`'s `expression-invalid` rule). - -**What is refused.** `record` is no longer one of the roots the build door binds on every run (`previous`, `vars` and `current_user` still are). It is bound where: - -- an entrance the stack declares hands the flow a record: a record trigger (start `config.triggerType` is a `record-*` token), a time-relative sweep (start `config.timeRelative`), the inbound hook (trigger kind `api`), a `type: 'flow'` action that targets the flow (on any object, or on none), a `map` node with a `config.itemObject` that names the flow, or a `subflow` / `map` parent that is itself handed a record; -- or the flow binds a variable named `record` itself (a declared variable or an `assignment` target). - -A start `config.objectName` alone is no entrance: with no `record-*` trigger nothing hands the run a row. A parent's own `record` variable is not handed on either: a child gets its parent's context, not its variables. Everywhere else `record.X` is refused as `expression-invalid`, `error`, with the remedy below. - -**Not judged.** The stand-downs of the unbound-root rule are unchanged. At the runtime publish gate this judgment still stands down: its per-write snapshot carries no actions and no other flows, so it cannot see an entrance, and a flow write there is not refused for `record`, by design (rulings `5791822697` item 3 and `6104584601`). - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `record.assignee` on a flow no entrance hands a record, meaning a flow variable `assignee` | `assignee`, or `vars.assignee` | -| `record.status == "open"` on a flow meant to run when a row is written | the trigger that hands it the row: start `config: { objectName: 'acct', triggerType: 'record-after-update' }` | - -**The one-line fix: read a flow variable by its name or through `vars`, and read `record` only on a flow an entrance hands one.** - -**Who is affected, measured.** With this judge over every flow CEL site: the example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`: 35 flows, 14 that read `record`), `packages/platform-objects` (no flows) and `objectstack-ai/hotcrm` at `1d7148b` (32 flows, 14 that read `record`): 0 newly refused. Every flow that reads `record` there is a record-change, time-relative or inbound-hook flow. Deployed metadata and other repositories were not measured. diff --git a/.changeset/pre/22682-position-form-permission-sets-picker.md b/.changeset/pre/22682-position-form-permission-sets-picker.md deleted file mode 100644 index 9350216a605..00000000000 --- a/.changeset/pre/22682-position-form-permission-sets-picker.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -`positionForm`: the `permissionSets` row now declares `widget: 'ref-multi:permission'` in place of `type: 'tags'`. ADR-0131 D4 resolves a reference to a declared item by machine name, registry-first, so the row names the permission-set registry (metadata type `permission`) for the designer to offer the declared sets and commit their names, instead of a free-text tag box where a name that does not exist can be typed. - -Clause-②: no - -- **The key.** `ref-multi:` followed by a metadata type is the list form of the forms' `ref:` pickers (`ref:object`, `ref:dataset`, `ref:component`, all single-valued). A distinct key, as `field-ref` / `field-multi` already are, because a single `ref:` picker writes one string and the widget registry declares one labelling per key. -- **Which renderer honours it.** A console whose metadata-admin widget registry has no `ref-multi:permission` entry renders this row as its announced fallback: a JSON editor with the note that no custom renderer is registered. The stored value is the same list of names either way. -- ⛔ Only this one form row changes. No schema, parse, export or accept-set change: `PositionSchema.permissionSets` stays a list of snake_case names, and `FormField.widget` stays a free string. diff --git a/.changeset/pre/22682-spec-position-bindings-by-name.md b/.changeset/pre/22682-spec-position-bindings-by-name.md deleted file mode 100644 index e45294c296c..00000000000 --- a/.changeset/pre/22682-spec-position-bindings-by-name.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec)!: an approver `position` and a decision-output `position` are catalog names, listed from the metadata registry (ADR-0131 D4) - -Clause-②: yes - - - -**BREAKING** for a renderer that derives its position picker from `APPROVER_VALUE_SOURCES`, shipped as `minor` under the repo's launch-window convention for breaking changes. Authored and stored metadata is untouched. - -- **`APPROVER_VALUE_BINDINGS.position`** is a registry binding, `{ source: 'registry', type: 'position' }`. It was a record binding on `sys_position` rows that committed the row's `name`. `ApproverValueBinding` gains the `registry` member: the value is the machine name of an item of metadata `type`, and a catalog item has no other reference, so the member carries no `valueField`. -- **`APPROVER_VALUE_SOURCES.position`**, the same binding on the wire (the `xRef.sources` of the approval node schema's `approvers[].value`), reads `{ source: 'registry', type: 'position' }`. It read `{ source: 'data', object: 'sys_position', valueField: 'name' }`, and its type gains the `{ source: 'registry'; type: string }` member. A picker for a `registry` source lists `GET /api/v1/meta/position`, which serves the code-declared and the environment-authored positions alike, and commits the item's `name`. `user`, `team` and `department` still read `data`. -- **A `position` decision output collects position machine names**, the value the approver type of the same name takes. `DecisionOutputDef` said every picker collected record ids. The one reader that interprets a position output, an `expression` approver with `resolveAs: 'position'`, filters `sys_user_position.position` by name, so a `sys_position` id there routed the request to nobody. The `type` and `multiple` descriptions now say what each picker commits. -- **The runtime does not change.** The approvals engine routes a position approver, an escalation target and a `resolveAs: 'position'` value by name, as it did. - -Why: positions are declared metadata (ADR-0131 D2), and every reference to one names it and resolves through the registry (D4). The `sys_position` table is a mirror that the ADR-0131 cutover retires, so a picker that lists its rows lists the mirror, not the catalog. diff --git a/.changeset/pre/22683-approver-remedy-bulk-import.md b/.changeset/pre/22683-approver-remedy-bulk-import.md deleted file mode 100644 index b74466275bb..00000000000 --- a/.changeset/pre/22683-approver-remedy-bulk-import.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -`os lint`'s `approval-approvers-may-resolve-empty` hint no longer tells an author that the admin bulk import does not write `sys_user.manager_id`; it names the import's `manager_id` column as a working route, beside Set Manager and the admin endpoint - -Clause-②: no - -On a node whose approvers are all `{ type: 'manager' }`, the hint listed the routes that populate `sys_user.manager_id` and said of one of them: "its admin bulk import does not write it either". That has been false since the import admitted the column. `POST /api/v1/auth/admin/import-users` reads a `manager_id` column whose cell holds the manager's email address, or phone number where phone sign-in is enabled. The manager can be someone in the same file or an existing user. The import links each row once every row in the file exists, through the same derivation as `POST /api/v1/auth/admin/set-user-manager`, and reports each outcome on the row's `manager` result. A link it cannot make never fails the user it imported. The old clause sent an author with a whole file of reports away from that route. - -The hint now names the bulk import's `manager_id` column among the routes that work on this platform, ahead of the clause that hands SCIM and directory sync to the deployment, and names Set Manager (Setup → Users) beside the endpoint it posts to. Nothing else about the rule moves: it fires on the same shape, at `info`, and a seeded manager chain remains the one silencer. diff --git a/.changeset/pre/22689-registry-package-version-stamp.md b/.changeset/pre/22689-registry-package-version-stamp.md deleted file mode 100644 index 79d5f350f02..00000000000 --- a/.changeset/pre/22689-registry-package-version-stamp.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -An app, object or other item registered through `ObjectQL.registerApp` is now served with its package's `_packageVersion`, as the artifact loader already serves it - -Clause-②: no - -`_packageVersion` ("Owning package version") is declared on the protection envelope, and the metadata plugin's artifact loader stamps it. The registry load path stamped only `_packageId`: `ObjectQL.registerApp(manifest)` called `SchemaRegistry.registerItem` and `registerObject` with the package id alone. So `GET /api/v1/meta/app` served such an app with no version, and a console waiting for an upgrade could not tell the new app from the old one that a stale kernel still serves. - -`registerApp` now stamps `manifest.version` on every item it registers: owned objects, object extensions, apps, every metadata collection, seed datasets, and the objects, app and collections of a nested plugin, which carry their owning package's version. A multi-package stack's residual top-level objects, which the engine registers under the stack's own id, now carry the stack's version too, as the metadata door stamps them. Each item is stamped before it reaches the registry, with the artifact loader's own call, `applyProtection(item, { packageId, packageVersion })`. The registry's stamp knows the id only, and it keeps a version that is already set. No signature changes: `SchemaRegistry.registerItem`, `registerObject` and `registerApp` keep theirs. - -A caller that passes only a package id still stamps the id alone. No version is looked up or invented. Those callers are the metadata-service re-sync and the facade, which pass along the stamp the artifact loader already put on the item; the stored-row hydration, whose rows are tenant-authored; and the security catalog's built-ins. diff --git a/.changeset/pre/22694-import-row-hook-sentence.md b/.changeset/pre/22694-import-row-hook-sentence.md deleted file mode 100644 index 6d66f84a8f7..00000000000 --- a/.changeset/pre/22694-import-row-hook-sentence.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/core': patch ---- - -An import row answers a sandboxed hook's refusal in the hook's own words, not the sandbox debug wrapper - -Clause-②: no - -When a hook body refused a row during `POST /api/v1/data/:object/import` (or the async `/import/jobs` job), the row's `error` read `hook 'NAME' threw: Error: SENTENCE`, while `POST /api/v1/data/:object` and `/createMany` answered the same refusal as `SENTENCE`. The import runner now reads the row's sentence the way those routes do: the hook's sentence, unchanged, with any `code` the body declared still on the row. A hook body that crashes (for example with a `TypeError`) is not a refusal, and its row reads as it did before. diff --git a/.changeset/pre/22695-install-local-hotloaded.md b/.changeset/pre/22695-install-local-hotloaded.md deleted file mode 100644 index b3d58a86020..00000000000 --- a/.changeset/pre/22695-install-local-hotloaded.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cloud-connection': patch ---- - -fix(cloud-connection): install-local's `hotLoaded` reports whether the running kernel loaded the package (#22695) - -Clause-②: no - -- **What was wrong.** `POST /api/v1/marketplace/install-local` always answered `hotLoaded: true`. When the manifest came from the cloud catalog and `manifest.register` threw, the install still wrote the package to its ledger and answered `200` (the lenient path). The package loads at the next restart, but the answer said the running kernel held it already, and its `note` said the app was "now available in this runtime". -- **What it answers now.** On that path the answer carries `hotLoaded: false` and a new string key, `hotLoadError`, holding the register error's message. The `note` says the package is installed and cached, that the running kernel could not load it, and that the runtime registers it again at its next restart. A reader can then say "installed, loads at the next restart" instead of reporting a false success. -- **What did not change.** An install whose register succeeds answers exactly as before: `hotLoaded: true`, and no `hotLoadError` key. An inline (file-import) manifest whose register throws is still refused with `422 PLUGIN_REGISTER_FAILED`, and nothing is written. The lenient path still installs: the ledger entry is written, and the package loads at the next restart. diff --git a/.changeset/pre/22698-file-delegate-metadata-text.md b/.changeset/pre/22698-file-delegate-metadata-text.md deleted file mode 100644 index b0ab6301d66..00000000000 --- a/.changeset/pre/22698-file-delegate-metadata-text.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/platform-objects': patch ---- - -`fileAccessDelegate` and the refused file marker now describe what the delegate decides on a record read, beside the download - -Clause-②: no - -Wording only: no key, type, schema shape or runtime behaviour changes. A record read now follows the download door's field-owned verdict for a file the record owns, so the texts an author reads understated what a declared delegate decides. - -- **`fileAccessDelegate`.** This covers the object schema's description and TSDoc, the object form's help text in `en`, `zh-CN`, `ja-JP` and `es-ES`, and the generated reference pages. The named service authorizes downloads of files owned by the object's media fields. It also decides whether a reader who may not read `sys_file` sees those files' name, size and type in a record read of the object. It is asked once per owning record on each such read, so an implementation runs on reads, not only on downloads. A reader who may read `sys_file` never reaches it on a record read. It fails closed on both paths. -- **`FileRefusedValueSchema` (`{ id, metadataRefused: true }`).** The TSDoc and the `metadataRefused` description now give two cases for a reader who may not read `sys_file`: the record being read does not own the file, or the download verdict on the owning record did not allow it. A file the record owns and the verdict allows is served as the full file object. -- **`IFileAccessDelegate`.** The interface's TSDoc and its `authorizeFileRead` doc now name both questions one verdict answers: the download, and whether a reader refused `sys_file` sees the file's name, size and type in a record read (asked once per owning record per such read). The warning now covers metadata too: a permissive implementation leaks the metadata as well as the bytes. - -**For authors.** Nothing to change in metadata. An existing delegate now also decides a refused reader's file metadata on every read. It should keep applying the same rule its service uses to read the record. diff --git a/.changeset/pre/22718-import-row-sandbox-fault.md b/.changeset/pre/22718-import-row-sandbox-fault.md deleted file mode 100644 index 967c142e591..00000000000 --- a/.changeset/pre/22718-import-row-sandbox-fault.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/core': patch ---- - -An import row for a sandboxed hook body that crashed, or that refused with a declared 5xx status, reads as a server fault instead of relaying the fault's text - -Clause-②: no - -When a hook body crashed during `POST /api/v1/data/:object/import` (or the async `/import/jobs` job), the row's `error` read `hook 'NAME' threw: TypeError: …`, and a hook refusal that declared a 5xx status relayed its own message. `POST /api/v1/data/:object` and `/createMany` answer both as server faults and withhold that text. The row now carries the same sentence those routes answer (`Internal server error`), and its `code` is unchanged: `IMPORT_ROW_FAILED` for a crash, the declared code for a declared 5xx. A hook refusal with no status or a 4xx status still reads in the hook's own words, and a database error's row text is unchanged. diff --git a/.changeset/pre/22719-rest-write-hook-refusal-sentence.md b/.changeset/pre/22719-rest-write-hook-refusal-sentence.md deleted file mode 100644 index 416836e83b4..00000000000 --- a/.changeset/pre/22719-rest-write-hook-refusal-sentence.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -fix(rest): twenty more REST write routes answer a sandboxed hook's refusal in the hook's own words (#22719) - -Clause-②: no - -**What was wrong.** When a sandboxed hook refuses a write (`throw new Error('Locked rows cannot be changed here.')`), the error that reaches the route carries the QuickJS debug wrapper on `.message` (`hook 'guard' threw: Error: …`) and the sentence on `.innerMessage`. Twenty write routes built their error answer by hand from `.message`, so they shipped the wrapper, while the other write doors answer the sentence. Sixteen of them also answered the refusal as a server fault (`500`). - -**What changes on the wire, for a sandboxed hook's refusal.** - -| routes | before | after | -| --- | --- | --- | -| `POST /sharing/rules`, `DELETE /sharing/rules/:idOrName`, `POST /sharing/rules/:idOrName/evaluate` | `500 RULE_*_FAILED`, flat `{ code, error }`, the wrapper | `400 VALIDATION_ERROR`, nested `{ success: false, error: { code, message } }`, the sentence | -| `POST /security/suggested-bindings/:id/confirm` and `/dismiss` | `500 SUGGESTION_*_FAILED` / `500 INTERNAL`, the wrapper | `400 VALIDATION_ERROR`, same nested `{ error: { code, message } }` shape, the sentence | -| the nine `POST /approvals/requests/:id/*` writes (`approve`, `reject`, `recall`, `revise`, `resubmit`, `reassign`, `remind`, `request-info`, `comment`) | `500 APPROVAL_*_FAILED`, flat `{ code, error }`, the wrapper | `400 VALIDATION_ERROR`, nested, the sentence | -| `POST /packages/publish` | `500 INTERNAL_ERROR`, the wrapper | `400 VALIDATION_ERROR`, the sentence | -| `POST /datasources/:name/external/tables/:remote/draft` and `/import`, `/external/refresh-catalog`, `/external/validate` | `400 EXTERNAL_DATASOURCE_ERROR` / `400 EXTERNAL_IMPORT_ERROR`, the wrapper | the same status and code, the sentence | - -A refusal that declares its own `code` keeps it (an unregistered spelling rides `declaredCode`), a refusal that declares its own `status` is answered at that status, and a `userMessage` the hook set rides the answer, as on every other write door. - -**Also moved, on the sharing-rule, suggested-binding and approval routes.** These routes now ask the same classification as the `/data` door before their `500` arm. So a refusal from the service that declares its own `status` (or `statusCode`) and `code`, and that no route-specific arm reads, is answered at that status, in the nested envelope, instead of `500` with the route's own code. The external-datasource routes keep answering every refusal at `400` with their own code, and `POST /packages/publish` already answered a declared status. - -**Unchanged.** A plain fault keeps each route's own `500` answer and code. The route-specific refusals (`VALIDATION_FAILED`, `PERMISSION_DENIED`, `RULE_NOT_FOUND`, `SHARING_NOT_ENABLED`, the approval prefixes, the typed security errors) answer exactly as before. - -**Upgrading.** A client that reads both envelopes, as `@objectstack/client` does, needs no change. A client of the sharing-rule or approval routes that reads a string `body.error` reads `body.error.message` for these answers. diff --git a/.changeset/pre/22726-write-answer-warnings.md b/.changeset/pre/22726-write-answer-warnings.md deleted file mode 100644 index 66bc10ff8ce..00000000000 --- a/.changeset/pre/22726-write-answer-warnings.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/objectql': minor -'@objectstack/metadata-protocol': minor -'@objectstack/client': minor ---- - -A write answer now carries the advisory validation-rule hits of that write as `warnings` - -Clause-②: yes (widening) - -A `severity: 'warning'` or `'info'` validation rule never blocks a write. Until now its hit was only logged on the server, so no client could show the person who saved what the rule says. A create, an update or a clone now answers the hits of that one write, and the validate-only preview reports the same entries. - -- **Write answers.** `CreateDataResponseSchema`, `UpdateDataResponseSchema` and `CloneDataResponseSchema` declare an optional `warnings`: one entry per advisory rule hit, carrying the rule's `name` as `rule`, its `severity`, the `field` it is about, the finding `code` and the author-written `message`. The key is absent when there is no hit, so an existing client's reading of the answer does not move. REST relays it in the response body unchanged. The write's status and record are unchanged, and an `error` rule still refuses the write. -- **One element, shared with the preview.** `ValidateDataIssueSchema` gains optional `rule` and `severity`, set only on an advisory rule hit. It is the element of the new `warnings`. `validateData` appends the advisory hits of the same evaluation to each accepted row's existing `results[].warnings`, after the value-shape findings the deployment admits. An import's dry run copies that array to the row verbatim, as before. -- **Engine.** `WriteObservabilityOptions` gains `onValidationAdvisory`, an in-process listener beside `onFieldsDropped`, with its event schema `ValidationAdvisoryEventSchema`. The engine calls it once per advisory hit on the write it was passed to: per row on `insert`, and on `update` by id and per matched row of a `multi` update. A nested write a hook or a flow makes inside that write carries its own options, so its hits never reach the outer write's listener or answer. `evaluateValidationRules` returns its advisory hits (an empty array when there are none) instead of `void`. -- **Unchanged.** Rule semantics, the per-write `warn` log line and the seed and boot load's one summary line per rule are all as before. No authorable key is added: `ValidationRuleSchema` is untouched. A rule whose condition could not be evaluated is not a hit. Its fault text is for the operator, so it stays in the server log only. -- **Client.** `CreateDataResult`, `UpdateDataResult` and `CloneDataResult` declare `warnings?: ValidateDataIssue[]`. - -**For authors.** Nothing to change in metadata. Write each advisory rule's `message` for the person saving the record, because a client can now show it to them. diff --git a/.changeset/pre/22727-formula-currency-result.md b/.changeset/pre/22727-formula-currency-result.md deleted file mode 100644 index 991c176a993..00000000000 --- a/.changeset/pre/22727-formula-currency-result.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/formula": minor -"@objectstack/platform-objects": patch ---- - -A formula field can declare a currency result: `returnType: 'currency'`, with its currency in its own `currencyConfig` - -Clause-②: yes (widening) - -- **What is new in `@objectstack/spec`.** `FieldSchema.returnType` accepts `'currency'` beside `'number'`, `'text'`, `'boolean'` and `'date'`. A `currency` result is an amount of money. Its value is a bare number, as a currency field's is. Its currency is the formula field's own `currencyConfig`, the same `CurrencyConfigSchema` a currency field carries, with the same defaults and the same refusals: no `currencyConfig` means dynamic mode (the tenant default currency), and `{ currencyMode: 'fixed', defaultCurrency: 'USD' }` fixes it. Nothing is read at display time from the fields the expression references: the currency is a declaration on the formula. -- **Readers.** The filter doors judge a formula returning `currency` as the currency field it reads like: a text operator on it is refused (`INVALID_FILTER`), and the number door judges its comparands. A formula is still not title-eligible unless it returns `text`, and is still refused for `sum` / `avg` / `min` / `max` (it has no stored column). -- **Designer forms.** The field designer and the object designer's field grid both offer Currency as a return type. The field designer shows its Currency Config row for a formula whose return type is Currency, as it does for a currency field. -- **What is new in `@objectstack/formula`.** `inferFormulaReturn(expression, fields)` returns the declaration authoring stamps onto a formula field, judged over the host object's declared field map: `{ returnType: 'currency' }` when the expression is provably an amount of money, plus `currencyConfig: { currencyMode: 'fixed', defaultCurrency }` when its source amounts are in one fixed currency. "Provably money" is unit checking over the expression: amounts in one currency added, subtracted, scaled by a number or a percent, divided by a number, rounded, or picked by `min` / `max` / `coalesce` / a ternary. Two different currencies, an amount multiplied by an amount, or an amount plus a plain quantity are not money. For everything it cannot prove to be money it answers exactly what `inferExpressionType` answers, so a numeric formula stays `number`. Its types `FormulaReturnDeclaration` and `FormulaSourceField` are exported with it. `inferExpressionType` is unchanged. -- **`@objectstack/platform-objects`.** The designer help text for the return type and the currency config rows names the currency result, in all four locales. -- **Nothing to migrate.** Every value accepted before is accepted now. To declare a formula over money as money, declare `returnType: 'currency'` and copy the source currency field's `currencyConfig` onto the formula, or run `inferFormulaReturn` and write what it returns. diff --git a/.changeset/pre/22729-package-install-hotloaded.md b/.changeset/pre/22729-package-install-hotloaded.md deleted file mode 100644 index 30ffa77b9dc..00000000000 --- a/.changeset/pre/22729-package-install-hotloaded.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os package install` no longer reports a hot install when the runtime answers `hotLoaded: false` (#22729) - -Clause-②: no - -- **What was wrong.** `os package install` printed "Package installed into the running kernel" for any `200` from `POST /api/v1/marketplace/install-local`. On the endpoint's lenient path (a catalog manifest whose hot-register throws), the runtime writes the package to its ledger and answers `200` with `hotLoaded: false` and the register error's message as `hotLoadError`: the package loads at the runtime's next restart, and the running kernel does not hold it. The command read neither key. -- **What it prints now.** On `hotLoaded: false` it prints a warning that the package is installed but the running kernel could not load it, and that it loads at the runtime's next restart, followed by a `Hot-load error:` line carrying `hotLoadError` as the runtime answered it. When `hotLoadError` is missing, blank or not a string, no reason line is printed. The exit code stays `0`: the install itself succeeded. -- **What did not change.** A hot-loaded install (`hotLoaded: true`) prints exactly as before, and so does an answer without `hotLoaded` (a runtime that predates the key). The command has no `--json` output, and none is added. diff --git a/.changeset/pre/22737-relation-condition-target-exposure.md b/.changeset/pre/22737-relation-condition-target-exposure.md deleted file mode 100644 index 09e13f9106e..00000000000 --- a/.changeset/pre/22737-relation-condition-target-exposure.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol)!: a nested-relation filter condition asks the related object its declared exposure, and is refused when that object does not serve `list` (#22737) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -A nested-relation filter condition, `{ : { : … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one. - -**FROM.** On `GET /api/v1/data/:object?filter=…` and `POST /api/v1/data/:object/query`, for an administrator and a member alike, such a condition was evaluated whatever the related object's `enable` block declared. A source row came back only when the related value matched, even for a related object every data route refuses. - -**TO.** The generic data door asks the spec's one exposure decision (`apiExposureDenialReason`) of the related object, for `list`, the operation `GET /api/v1/data/:target?filter=…` is judged as. A condition is a query of the related object, not a read of a record whose id the caller holds, so a `get`-only whitelist does not serve it. A related object the decision does not serve is refused, with the data door's own exposure answer: - -- `enable.apiEnabled: false` → `404 OBJECT_API_DISABLED`. -- an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `405 OBJECT_API_METHOD_NOT_ALLOWED`, with the effective operation set in `allowed`. - -The refusal names the related object and the condition's position. It applies wherever the door walks a filter: `where`, each aggregation's `filter`, and an `expand` entry's own `where` at every level. - -**Migration.** Filter on the lookup's stored id (`{ : '' }` or `{ : { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`. - -**Bindings.** - -- **Every caller.** The decision takes no user, so an administrator, a member and a system context get the same answer. -- **Refused, not withheld.** Dropping a condition would widen the result. A related object the caller may not read already answers this position with a refusal (`403`). -- **The engine's privileged callers are untouched.** A flow's or a hook's own query calls the engine directly and never passes the generic data door. -- **No new error code.** `@objectstack/metadata-protocol` now stamps the two existing exposure codes, and the error-code ledger lists them under that package too. diff --git a/.changeset/pre/22737-spec-ledger-metadata-protocol-exposure-codes.md b/.changeset/pre/22737-spec-ledger-metadata-protocol-exposure-codes.md deleted file mode 100644 index 7019958a56a..00000000000 --- a/.changeset/pre/22737-spec-ledger-metadata-protocol-exposure-codes.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the error-code ledger records `@objectstack/metadata-protocol` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22737) - -The generic data door now answers the two exposure codes for a nested-relation filter condition whose related object's `enable` block refuses a query of it, so `ERROR_CODE_LEDGER['@objectstack/metadata-protocol']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered under `@objectstack/rest`. No export, schema or generated artifact moves. diff --git a/.changeset/pre/22737-types-exposure-arm-names-target.md b/.changeset/pre/22737-types-exposure-arm-names-target.md deleted file mode 100644 index a85694a5dc5..00000000000 --- a/.changeset/pre/22737-types-exposure-arm-names-target.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/types': patch ---- - -fix(types): a thrown exposure refusal about a second object names that object on the REST body (#22737) - -The shared REST error classification gains one arm for `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED`. Both REST error doors ask it: `mapDataError`, and `resolveErrorResponse` behind `sendThrownError`. - -When the thrown error names an object other than the one the route addressed, the body's `object` is that object, and a `405` carries the effective operation set as `allowed`. The generic data door throws this when a nested-relation condition's related object is not served by the API. The new body is the same one the REST door already sends for these codes about the addressed object. Before, the body named the route's object beside a code saying that object was switched off. - -An error naming the route's own object, or a door given no route object, answers exactly as before. No code is added. diff --git a/.changeset/pre/22738-lookup-title-exposure.md b/.changeset/pre/22738-lookup-title-exposure.md deleted file mode 100644 index b33dcab599b..00000000000 --- a/.changeset/pre/22738-lookup-title-exposure.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/plugin-approvals': minor -'@objectstack/plugin-audit': minor ---- - -fix(plugin-approvals,plugin-audit)!: a lookup target's title is read only when the TARGET object's declared exposure serves `get` — the approvals inbox's `payload_display` and the activity summary (#22738) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The data routes judge an object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`), and the data door's `$expand` and the dataset door's labels already ask it of a lookup's TARGET. Two more reads follow a lookup to its target's title under a system context and never asked it. - -**FROM.** For an administrator and a member alike: - -- **Approvals inbox** (`@objectstack/plugin-approvals`): `GET /api/v1/approvals/requests` and `GET /api/v1/approvals/requests/:id` carried, in `payload_display`, the title of a snapshot lookup's target whose declaration refuses `get`. -- **Activity summary** (`@objectstack/plugin-audit`): an update's tracked-change summary, and a fired milestone's summary, named such a target's records by title in the `sys_activity` row served by `GET /api/v1/data/sys_activity`. - -**TO.** Each title read first asks the decision of the TARGET object, for `get` (turning an id into the record it names, the read `GET /api/v1/data/:target/:id` performs): - -- A refused target is not read. The inbox carries no `payload_display` entry for that key, so the snapshot's stored id stands, which is what a deleted target already answers. The activity summary names the record by its stored id, which is what an unresolvable reference already answers. -- A declaration that cannot be read withholds the title too (fail-closed, at `warn`). -- Only activity rows written from now on change. A written row is a snapshot and is never rewritten, and no other activity column changes. - -A target declaring `apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `get` (for example `['list']`) is withheld; a target with no `enable` block, or a whitelist that grants `get`, is served exactly as before. The decision takes no caller. - -**Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` and `examples/`: ten objects refuse `get` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (control: 88 lookups into `sys_user`). So no shipped object's served title changes. Deployed and cloud-held object definitions were NOT MEASURED. diff --git a/.changeset/pre/22739-import-reference-target-exposure.md b/.changeset/pre/22739-import-reference-target-exposure.md deleted file mode 100644 index b95cc300590..00000000000 --- a/.changeset/pre/22739-import-reference-target-exposure.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/core': minor ---- - -fix(core)!: an import's reference resolution asks the lookup TARGET its declared exposure before matching a cell (#22739) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -`POST /api/v1/data/:object/import` and the async `POST /api/v1/data/:object/import/jobs` judge the ADDRESSED object's `enable` block. A lookup cell is resolved by matching its text against the lookup's TARGET object — an object nobody addressed. - -**FROM.** - -- For an administrator and a member alike, a cell was matched against a target whose declaration refuses the read on every data route (`enable.apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `list`): a cell naming an existing record stored its id, and a cell naming none answered `reference_not_found` — so the row report told the two apart. -- A field of the legacy type spelling `'reference'` (not a `FieldType`, so `ObjectSchema` refuses it, but still listed in the import, export and import-template doors' own type tables) took its target from the raw `reference` key: import matched its cells against that object and export expanded it. - -**TO.** - -- The runner asks the spec's one decision (`canServeApiOperation`) of the TARGET's `enable` block, for `list` — matching a cell is a predicate read over the target, the runtime `find` the spec maps to `list`. A target the decision does not serve answers every cell, naming a record or not (a pasted id included), `reference_not_found` — the answer a target the caller cannot read (no read permission, or a record its row scope hides) already gets. Nothing of the target is read. A target with no `enable` block, or a whitelist that grants `list`, resolves exactly as before. A declaration that cannot be read withholds the target too (fail-closed). -- A field is a reference on those doors exactly when it declares a `reference` string, as before, and its target is read through the spec's arbiter `referenceTargetOf`, which names none for a type outside `lookup` / `master_detail` / `user` / `tree`: such a `'reference'`-typed field's cell is stored as written, and export leaves its stored id. Every other non-reference type was never resolved by these doors and is unchanged. Measured over every git-tracked non-test source under `packages/` and `examples/` (`.ts`, `.js`, `.json`; fixtures excluded) at `0d326bfb12`: no shipped field declares `reference` on a non-reference type (0 object literals, 0 `Field.*` helper calls; the one literal pairing `type` with `reference` is a key-classification table, not a field), against a control of 30 reference-typed literals and 80 `Field.lookup` / `masterDetail` / `user` / `tree` calls; a planted literal and a planted helper call were both found. - -**Bindings.** - -- **Every caller of `runImport` whose protocol reads object declarations.** The decision takes no user, so an administrator, a member and a system context (the connector pull) are answered the same. The target's declaration is read through the protocol's `getMetaItem`, a new optional member of `ImportProtocolLike`; a protocol without it is not judged (plugin-auth's identity import carries the reader since #22800). -- **No new error code, no status change.** The refusal is the per-row `reference_not_found` the resolver already answers. -- **Which fields count as references does not widen.** A `user` field written without `reference` still has no target on these doors (its cell reaches the write as written), although the arbiter gives it `sys_user`; adopting that target would widen what the import accepts and is not this change. - -**Fix, if an import relied on the old answer:** the target's own declaration decides — grant `list` in its `enable.apiMethods`, or drop `enable.apiEnabled: false`, if the object is meant to be matched by import. - -**Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` (spec and qa excluded) and `examples/`: twelve objects refuse `list` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (its own import door already answers 404); the control count of lookups into `sys_user` is 75. So no shipped object's import answer changes. Deployed and cloud-held object definitions were NOT MEASURED. diff --git a/.changeset/pre/22741-sandbox-fault-import-row.md b/.changeset/pre/22741-sandbox-fault-import-row.md deleted file mode 100644 index 8dd68a4dae7..00000000000 --- a/.changeset/pre/22741-sandbox-fault-import-row.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/core': patch -'@objectstack/types': minor ---- - -An import row for the sandbox's own fault reads the create door's fault sentence, not the runner's diagnostic text (#22741) - -Clause-②: yes (widening) - -The QuickJS runner raises some `SandboxError`s itself: a hook or action body that runs past its CPU budget or past the wall-clock ceiling, calls a capability it was not granted, or meets a failed install or marshal. These carry no `innerMessage`. `POST /api/v1/data/:object` and `/createMany` answer them `500 INTERNAL_ERROR` `Internal server error`. The sync `/import` row and the `/import/jobs` stored row answered the runner's text instead, for example `hook 'guard' exceeded CPU budget of 50ms (after 3 pump iterations)`. - -- **`@objectstack/types`** exports `isSandboxOwnFault(error)`. It is `true` for an error whose `name` is `'SandboxError'` and whose `innerMessage` is not a non-empty string, which is how the runner marks the faults it raises itself. A body's refusal or crash carries `innerMessage` and answers `false`. -- **`@objectstack/core`**'s import row applies the rule a crashed body already gets: when the sandbox raised the error and the create door answers it with a 5xx, the row's `error` is the door's sentence. The row's `code` is unchanged (`IMPORT_ROW_FAILED`). A driver error, and an error a code-defined hook threw, keep the row text they had. diff --git a/.changeset/pre/22743-field-types-currency-returntype.md b/.changeset/pre/22743-field-types-currency-returntype.md deleted file mode 100644 index a2eb0c57637..00000000000 --- a/.changeset/pre/22743-field-types-currency-returntype.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -fix(skills): the `objectstack-data` field-types rule names all five formula `returnType` members, `'currency'` included, and the `currencyConfig` a currency formula carries (#22743) - -Clause-②: no - -`skills/objectstack-data/rules/field-types.md` taught a formula's `returnType` as the four-member set `'number' | 'text' | 'boolean' | 'date'`, and its formula example said `(no 'currency')`. `@objectstack/spec`'s `FieldSchema.returnType` is the five-member enum `'number' | 'text' | 'boolean' | 'date' | 'currency'`, and a formula whose `returnType` is `'currency'` carries its currency in its own `currencyConfig` — `CurrencyConfigSchema`, the same shape a `currency` field declares; absent, the mode is `dynamic`, the tenant default currency. The `formula` table row and the formula example now both state the five-member set and name `currencyConfig` as the carrier. The example's `CEL — record. prefixes required` comment, which restated the table cell above it, is removed to pay the skill's token ratchet. Nothing is said about how a currency formula renders. diff --git a/.changeset/pre/22748-stack-email-sms-door.md b/.changeset/pre/22748-stack-email-sms-door.md deleted file mode 100644 index 97a9d554e51..00000000000 --- a/.changeset/pre/22748-stack-email-sms-door.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): the stack definition admits `appName`, the non-secret members of `email`, and `sms.retries`; credentials and the SMS provider are refused by name - -Clause-②: yes (widening: the stack definition admits `appName`, the non-secret members of `email`, and `sms` carrying `retries` alone, which it refused as unrecognized keys) - -- **What you can write now.** `defineStack()` accepts three top-level keys it used to refuse as unrecognized. The mail and SMS readers already read them, and `objectstack serve` and `bootStack` construct the email and SMS providers through those readers: - - `appName`: the deployment's product name. It is the last configured rung of the name chain (`OS_APP_NAME` > `email.appName` > `email.defaultTemplateContext.appName` > `appName` > `'ObjectStack'`), and it reaches the email templates and AuthPlugin. - - `email` (`StackEmailConfigSchema`): every member of `EmailServiceConfigSchema` except `apiKey`. That is `provider`, `defaultFrom`, `retries`, `persist`, `queueDelivery`, `appName` and `defaultTemplateContext`, plus `options` narrowed to `host` / `port` / `secure` / `user` / `messageStream`. `OS_EMAIL_*` still overrides it per key. - - `sms` (`StackSmsConfigSchema`): `retries`, the SMS service's attempt budget. It applies to whichever transport the `sms` settings namespace selects. -- **What stays elsewhere, and is refused by name.** Each refusal names the key and the setting that replaces it: - - `email.apiKey` and `email.options.apiKey`: a credential, and a stack definition is compiled into the published artifact. Set `OS_EMAIL_API_KEY`. - - `email.options.password`: a credential. Set `OS_EMAIL_SMTP_PASSWORD`; the reader layers it over the host / port / secure / user the file carries. - - `sms.provider`, `sms.providerOptions` (and a flat `authToken` / `accessKeySecret`): the delivering SMS transport is built by the `sms` settings namespace from its own provider and credentials, never from a stack definition. Configure them in Setup → Settings → SMS Delivery, or through that namespace's overrides (`OS_SMS_PROVIDER`, `OS_SMS_TWILIO_*`, `OS_SMS_ALIYUN_*`). -- **Composition.** All three keys compose as single values. `composeStacks` passes an identical value through and refuses two different ones by key. -- **Nothing to migrate.** No stack that was accepted before is refused now. A configuration that set these keys only in an object `defineStack` did not build (the one `objectstack serve` boots) reads the same as before. To build it, move any credential, and the SMS provider, into the environment or Settings. -- New exports from `@objectstack/spec/system`: `StackEmailConfigSchema`, `StackEmailProviderOptionsSchema`, `StackSmsConfigSchema`, and their `z.input` / `z.infer` types. diff --git a/.changeset/pre/22754-spec-webhooks-redeliver-member.md b/.changeset/pre/22754-spec-webhooks-redeliver-member.md deleted file mode 100644 index 32785e18072..00000000000 --- a/.changeset/pre/22754-spec-webhooks-redeliver-member.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): a new webhooks service contract declares an optional, transport-neutral redeliver member (`IWebhookService.handleRedeliver`) - -Clause-②: yes (widening) - -- **What is new.** `@objectstack/spec/contracts` exports a new contract, `IWebhookService`, for the `webhooks` service slot that `@objectstack/plugin-webhooks` fills. It has one optional member, `handleRedeliver?(request: Request): Promise`. It serves the webhook redeliver door, `POST /api/v1/webhooks/redeliver`, the operator button that sends a finished delivery again. -- **What it serves.** The same answers as the plugin's self-hosted route. It authenticates the caller from the request's own credentials (`401 UNAUTHENTICATED` when nobody is signed in). It reads `{ "deliveryId": string }` from a JSON body (`400` otherwise). It replays the row through the messaging service, scoped to the caller's active organization (`404 RESOURCE_NOT_FOUND` for a row outside it). It answers `409` with `DELIVERY_NOT_ELIGIBLE` or `DELIVERY_NEVER_SENT` when the outbox refuses the replay, and `200` with the row's id and new status when it succeeds. -- **Transport-neutral.** A web-standard `Request` goes in and a `Response` comes out: there is no Hono context, no raw app and no `http.server` type. A hosted tenant kernel has no raw app to mount the route on, and the runtime dispatcher's `POST /webhooks/redeliver` domain is the member's one caller. -- **Why a webhooks slot, not the messaging service's.** The messaging service owns the replay itself. The door (who may press it, whose rows it reaches, what each refusal answers, and the veto over replaying a webhook whose subscription or secret is gone) belongs to the webhook plugin, so the door exists exactly where that plugin is composed. -- **Absence.** The member is optional, so a webhook service without the door still satisfies the contract. Its caller answers a typed 404 or 501 when the `webhooks` service or this member is absent, never `ROUTE_NOT_FOUND`. -- **Nothing changes for existing code.** No existing contract or member moved. Nothing registers the `webhooks` slot or calls the member yet: the plugin implementation and the dispatcher domain land separately, and the plugin's self-hosted route stays as it is. diff --git a/.changeset/pre/22755-webhooks-redeliver-domain.md b/.changeset/pre/22755-webhooks-redeliver-domain.md deleted file mode 100644 index 3d9636e8d24..00000000000 --- a/.changeset/pre/22755-webhooks-redeliver-domain.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): the webhook redeliver button works on a kernel with no raw HTTP app — the dispatcher serves `POST /webhooks/redeliver` - -Clause-②: no - -On a self-hosted server, `@objectstack/plugin-webhooks` serves the operator's redeliver button by mounting `POST /api/v1/webhooks/redeliver` on the raw Hono app. A hosted tenant kernel has no raw app, so on that shape the path answered `404 ROUTE_NOT_FOUND`, even though the plugin had registered the `webhooks` service that can serve it. - -**The door that changes: `HttpDispatcher`, for a kernel with no raw app.** It now serves exactly `POST /webhooks/redeliver` (no sub-paths, no other method). It forwards the request, body unread, to the request kernel's `webhooks` service (`IWebhookService.handleRedeliver`), read on every request, and returns that `Response` as it is. So the hosted door answers what the self-hosted mount answers: `200` with the replayed row, and the same `401`, `400`, `404` and `409` refusals with the same bodies. An anonymous call is refused `401 UNAUTHENTICATED` there exactly as on the mount. - -- No `webhooks` service registered (plugin-webhooks absent, or present without its redelivery veto), or one without `handleRedeliver`: `501 NOT_IMPLEMENTED`, naming which. Never `ROUTE_NOT_FOUND`. -- A member that throws: a sanitised `500 INTERNAL_ERROR`, with the original error in the server log. The same answer, logged, when a transport hands over a request whose body was already read, instead of a "not JSON" refusal for a well-formed request. -- Any method other than `POST` is not claimed: it answers the dispatcher's ordinary `404 ROUTE_NOT_FOUND` and never reaches the service. -- The dispatcher's two gates apply to this path unchanged: a session that still owes an authentication step, or a signed-in caller who is not a member of the request's environment, is refused `403` before the service runs. This door is therefore stricter than the self-hosted mount, never looser. - -Nothing changes on a self-hosted server: the plugin's raw-app mount still serves the path there and never reaches the dispatcher. diff --git a/.changeset/pre/22756-webhooks-redeliver-member.md b/.changeset/pre/22756-webhooks-redeliver-member.md deleted file mode 100644 index bf062897686..00000000000 --- a/.changeset/pre/22756-webhooks-redeliver-member.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/plugin-webhooks": minor ---- - -feat(plugin-webhooks): the `webhooks` service serves the redeliver door from a web-standard `Request` (`IWebhookService.handleRedeliver`), and the redelivery veto no longer waits for realtime (#22756) - -Clause-②: yes - -- **What is new.** The plugin registers the `webhooks` service and implements its optional `IWebhookService.handleRedeliver(request)` member: the webhook redeliver door, `POST /api/v1/webhooks/redeliver`, from a `Request` to a `Response`, with no raw app, no Hono context and no `http.server`. A hosted tenant kernel owns no socket, so this is how the door reaches it; its one caller is the runtime HTTP dispatcher's `POST /webhooks/redeliver` domain, which lands separately. -- **One door, two faces.** The member and the self-hosted route run the same code: the same session check, the same active-organization tenant, the same replay through `MessagingService.redeliverHttp` and the same answer for every outcome. For the same request they answer the same status, the same `Content-Type: application/json` and the same bytes: `401 UNAUTHENTICATED`, `400 INVALID_REQUEST`, `400 MISSING_REQUIRED_FIELD`, `404 RESOURCE_NOT_FOUND`, `409 DELIVERY_NOT_ELIGIBLE`, `409 DELIVERY_NEVER_SENT`, `500 INTERNAL_ERROR`, or `200` with the row's id and new status. -- **The redelivery veto is installed wherever the door is.** The veto that refuses to replay a webhook delivery whose subscription is gone, or whose signing secret cannot be recovered, now needs only the data engine and the messaging service. It used to be installed only when realtime was present and `autoEnqueue` was not `false`, so on other kernels such a delivery was replayed. It is now refused with `409 DELIVERY_NOT_ELIGIBLE` there too. The `webhooks` service is registered only where the veto is installed. -- **The route is found under `http.server` too.** The plugin reads `http.server` first, then the `http-server` alias. A host that registers its server only as `http.server` now gets the self-hosted route; one that registers it under both names gets it once. -- **The self-hosted route is otherwise unchanged.** Where it was mounted before, it answers the same status, headers and bytes for the same request, and it is never mounted twice. diff --git a/.changeset/pre/22759-runtime-authoring-issue-path-rule.md b/.changeset/pre/22759-runtime-authoring-issue-path-rule.md deleted file mode 100644 index 3e9f8fc9c2a..00000000000 --- a/.changeset/pre/22759-runtime-authoring-issue-path-rule.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -docs(spec): `RuntimeAuthoringIssueSchema.path` states the name-keying rule for every collection-resident write type, so a `dataset` write's name-keyed paths are no longer described as positional - -Clause-②: no - -The description of `path` on a runtime publish-gate finding (the 422 `issues[]` and the 2xx `advisories[]`) named `object`, `permission` and `book` as the only write types whose top-level collection entry is keyed by name, and called every other write type positional. A `dataset` write's findings have been name-keyed too (for example `datasets.acme_invoice_metrics.dimensions[0].field`), so a consumer that followed the text would have expected `datasets[N]` and never received it. - -The description now states the rule instead of a list: a write type is collection-resident when the gate's per-write snapshot holds the tenant's other stored items of that type beside the written one, and there the top-level entry is keyed by name. Every other write type is the only member of its collection in that snapshot, so its `[0]` stays positional. The set is derived inside `@objectstack/lint`'s runtime gate, and the description names that derivation rather than copying its members. It also states the fallback the gate already applies: an entry whose name cannot be spliced into a dotted path keeps its index. - -Description text only: the schema's shape, accepted values and types are unchanged. The generated `content/docs/references/api/protocol.mdx` is regenerated. diff --git a/.changeset/pre/22761-explain-read-absent-layers.md b/.changeset/pre/22761-explain-read-absent-layers.md deleted file mode 100644 index e34ca689105..00000000000 --- a/.changeset/pre/22761-explain-read-absent-layers.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -`security/explain` answers a by-id write of a row the caller cannot read with the whole answer it gives for an id no row carries, layers included. - -Clause-②: no - -- The update, delete and transfer doors already answer such a row `404 RECORD_NOT_FOUND`, as they answer a nonexistent id, and explain's record verdict already took the missing-record shape. Every row-scoped layer's `record` block, and the tenant layer's verdict, now come from the same path a nonexistent id takes, so no layer names a status the door does not give. -- Nothing you author changes. The doors and the record verdict answer exactly as before, and an explanation of a row the caller can read is unchanged. diff --git a/.changeset/pre/22764-rest-write-fault-withheld.md b/.changeset/pre/22764-rest-write-fault-withheld.md deleted file mode 100644 index f776207c820..00000000000 --- a/.changeset/pre/22764-rest-write-fault-withheld.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -fix(rest): the hand-built REST write routes answer a sandboxed hook that crashed, or that declared a server error, as the data routes do (#22764) - -Clause-②: no - -**What was wrong.** Two kinds of sandboxed hook failure reach a route as faults, not refusals. One is a hook body that crashed (`ctx.input.title.trim()` on a number): the route receives the QuickJS debug wrapper on `.message`, for example `hook 'guard' threw: TypeError: boom`. The other is a hook that declared a server-band status (`status: 503`). The `/data` routes answer both as faults and withhold the text: a crash answers `500 INTERNAL_ERROR` with `Internal server error`, and a declared `503` answers `503` with its own code and `Internal server error`. The routes below build their error answer by hand, and they sent the wrapper and the native error text to the caller. The four external-datasource routes also answered a crash as `400`, the caller's mistake. - -**What changes on the wire, for those two failures only.** - -| routes | before | after | -| --- | --- | --- | -| `POST /sharing/rules`, `DELETE /sharing/rules/:idOrName`, `POST /sharing/rules/:idOrName/evaluate` | `500 RULE_*_FAILED`, flat `{ code, error }`, the wrapper | the `/data` answer, nested `{ success: false, error: { code, message } }` | -| `POST /security/suggested-bindings/:id/confirm` and `/dismiss` | `500 SUGGESTION_*_FAILED`, the wrapper | the `/data` answer, `{ error: { code, message } }` | -| the nine `POST /approvals/requests/:id/*` writes (`approve`, `reject`, `recall`, `revise`, `resubmit`, `reassign`, `remind`, `request-info`, `comment`) | `500 APPROVAL_*_FAILED`, flat `{ code, error }`, the wrapper | the `/data` answer, nested | -| `POST /packages/publish` | a crash: `500 INTERNAL_ERROR` with the wrapper; a declared `503`: `503` with the hook's sentence | the `/data` answer | -| `POST /datasources/:name/external/tables/:remote/draft` and `/import`, `/external/refresh-catalog`, `/external/validate` | `400 EXTERNAL_DATASOURCE_ERROR` / `400 EXTERNAL_IMPORT_ERROR`, the wrapper for a crash, the sentence for a declared `503` | the `/data` answer, nested | -| `POST /security/explain` | `500 EXPLAIN_FAILED`, the wrapper | the `/data` answer, nested | -| `POST /analytics/dataset/query`, a crash only (a declared 5xx already answered as `/data` does) | `500 ANALYTICS_QUERY_FAILED`, the wrapper | the `/data` answer, in this route's flat `{ code, error }` | - -"The `/data` answer" is `500 INTERNAL_ERROR` with `Internal server error` for a crash, whatever status it declared. For a refusal that declared a 5xx, it is that status, the declared code and `Internal server error`. The prose is released only when the hook marked the declaration a refusal, and a `userMessage` the hook set rides the answer. The withheld text goes to the server log. The answer is computed by the same classification the `/data` routes use, so it cannot drift from theirs. - -The same arms serve the read routes of the sharing-rule, suggested-binding and external-datasource families (`GET /sharing/rules`, `GET /sharing/rules/:idOrName`, `GET /security/suggested-bindings`, `GET /datasources/:name/external/tables`), so they answer these two failures the same way. - -**Unchanged.** A hook's refusal keeps its sentence and status on every route. A refusal that declared a 4xx on the external-datasource routes still answers `400` with that family's code. A fault that is not from a sandboxed hook keeps each route's own answer and code. A service-declared `503` on `POST /packages/publish` is one such fault. The per-record share routes already withheld the text at their own `500 SHARE_*_FAILED` and are unchanged. - -**Upgrading.** Nothing to change. A client that branched on `RULE_*_FAILED`, `APPROVAL_*_FAILED`, `SUGGESTION_*_FAILED`, `EXPLAIN_FAILED`, `ANALYTICS_QUERY_FAILED` or a `400` from an external-datasource route to detect a broken hook reads `INTERNAL_ERROR` (or the declared 5xx code) instead. Those codes still answer every other fault. diff --git a/.changeset/pre/22769-versioned-http-signature.md b/.changeset/pre/22769-versioned-http-signature.md deleted file mode 100644 index d039ad05187..00000000000 --- a/.changeset/pre/22769-versioned-http-signature.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/trigger-api': minor ---- - -feat(core,trigger-api): a timestamped `X-Objectstack-Signature` form with a five-minute tolerance window, accepted by the inbound flow hook beside the body-only form - -Clause-②: yes - -- **The new form.** `x-objectstack-signature: t=,v1=`, where the hex is the lowercase HMAC-SHA256 of `.` under the flow's secret. The timestamp is inside the signed material, so it cannot be changed without the secret. The inbound hook (`POST /api/v1/automation/hooks/:flowName/:hookId`) refuses the value once `t` is more than 300 seconds from the server's clock, in either direction, so a captured post stops verifying five minutes after it was signed. -- **Refusals.** A value that starts with `t=` is judged by the timestamped form alone. If it is malformed, carries the wrong HMAC, carries the body-only HMAC, or has a `t` outside the window, the answer is the same `401 INVALID_SIGNATURE` a bad signature gets, and nothing is enqueued. -- **Body-only signatures keep working.** `sha256=` is still accepted, so no existing sender breaks. It signs no time, so a captured post can be replayed, and it is now deprecated: the trigger logs one `warn` line per armed hook that names the flow and the hook, the first time that hook accepts a body-only post. -- **`@objectstack/core` exports.** `signHttpBodyAt(body, secret, timestampSeconds)` signs the new form. `verifyHttpSignature(body, secret, header)` verifies both forms and returns an `HttpSignatureVerdict` (`{ valid: true, scheme: 'v1' | 'body-only' }`, or `{ valid: false, reason }`). `HTTP_SIGNATURE_TOLERANCE_SECONDS` is `300`. They sit beside `signHttpBody` and `HTTP_SIGNATURE_HEADER` in the one definition of the scheme. `@objectstack/trigger-api`'s `verifySignature` keeps its signature and now verifies through it, so it accepts both forms. -- **Unchanged.** ObjectStack's own senders, the messaging HTTP outbox (webhooks) and the flow `http` node, still send the body-only form. Receivers outside ObjectStack verify exactly that today. - -**To adopt it as a sender.** Sign at send time, not at enqueue: `t = Math.floor(Date.now() / 1000)`, then send `t=,v1=.">` in `x-objectstack-signature`. From TypeScript, call `signHttpBodyAt(rawBody, secret, t)`. diff --git a/.changeset/pre/22771-milestone-activity-kind.md b/.changeset/pre/22771-milestone-activity-kind.md deleted file mode 100644 index 2deaa756282..00000000000 --- a/.changeset/pre/22771-milestone-activity-kind.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -A `sys_activity` row that an `activityMilestones` entry fired carries `metadata.kind: 'milestone'`, so a reader can tell a milestone from an ordinary update or a finished task - -Clause-②: no - -ADR-0052 §5 keeps `sys_activity.type` domain-neutral and puts a row's domain kind in -`metadata.kind`. A fired milestone keeps the `type` its declaration names: `completed`, or -`updated` when it names none. Nothing on the served row said it was a milestone. So a "Case -resolved" milestone declared with `type: 'completed'` read as a finished task, and a feed that -hides completed tasks hid it too. - -The CRUD mirror now writes `metadata.kind: 'milestone'` on every row a milestone fires. `type` is -unchanged, and a row that no milestone fired carries no `kind`. - -The kind is served by the same rule as the text beside it. "A milestone fired" means that a -watched field entered its declared value, so: - -- **A reader served every field that a declared milestone watches** is served `metadata.kind`. -- **A reader not served one of those fields** is served the row without `metadata.kind`, just as - it is served the change without that field. -- **A row an app writes itself** keeps its own `metadata.kind` for every reader, as written. - -The rest of the redaction is unchanged. The provenance declaration (`metadata.text_sources`) is -never served, and a change key the reader is not served is dropped. Rows written before this change -carry no kind, and this change does not backfill them. Nothing you author changes: no key, option, -export or type is added or removed. diff --git a/.changeset/pre/22772-spec-trigger-api-inbound-hook-member.md b/.changeset/pre/22772-spec-trigger-api-inbound-hook-member.md deleted file mode 100644 index 2cc47b32ed1..00000000000 --- a/.changeset/pre/22772-spec-trigger-api-inbound-hook-member.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): a new trigger-api service contract declares an optional, transport-neutral inbound-hook member (`ITriggerApiService.handleInboundHook`) - -Clause-②: yes (widening) - -- **What is new.** `@objectstack/spec/contracts` exports a new contract, `ITriggerApiService`, for the `trigger-api` service slot that `@objectstack/trigger-api` fills. It has one optional member, `handleInboundHook?(request: Request, hook: { flowName, hookId }): Promise`. It serves the inbound hook, `POST /api/v1/automation/hooks/:flowName/:hookId`, the door a third-party sender posts a signed event through to start an `api` flow. -- **What it serves.** The same answers as the trigger's self-hosted route, in the same order. An unknown flow or a wrong hook id answers `404 RESOURCE_NOT_FOUND`, identically. A hook secret that cannot be read answers `503 SERVICE_UNAVAILABLE`. A missing or wrong `x-objectstack-signature` answers `401 INVALID_SIGNATURE`. Only a verified body is read as JSON: one that is not valid JSON, or not a JSON object, answers `400 INVALID_REQUEST`. An enqueued post answers `202` with `{ accepted: true, messageId }`, and the flow runs on the queue consumer, never in-band (ADR-0041 §5). No queue answers `503 SERVICE_UNAVAILABLE`, and a failed publish answers `503 ENQUEUE_FAILED`. -- **The member is the signature boundary.** The implementer verifies inside the member, with the hook's own secret, in constant time, and before anything is enqueued. It reads the raw body from the request itself. The caller forwards the request with its body unread, and never parses, re-serialises or pre-checks it. -- **Transport-neutral.** A web-standard `Request` goes in and a `Response` comes out: there is no Hono context, no raw app and no `http.server` type, and no execution context, because the signature is the only credential. The two path parameters arrive as the second argument, as the caller's route matched them, so the member never re-parses the URL. A hosted tenant kernel has no raw app to mount the route on, and the runtime dispatcher's exact `POST /automation/hooks/:flowName/:hookId` domain is the member's one caller. -- **Why a trigger-api slot, not the automation service's.** The automation service holds the trigger registry that the `api` trigger registers into. The door (the hook lookup, the secret, the signature check, the body parse, the enqueue and every answer) belongs to the trigger, so the door exists exactly where `@objectstack/trigger-api` is composed. -- **Absence.** The member is optional, so a trigger-api service without the door still satisfies the contract. Its caller answers a typed 404 or 501 when the `trigger-api` service or this member is absent, never `ROUTE_NOT_FOUND`. -- **Nothing changes for existing code.** No existing contract or member moved. Nothing registers the `trigger-api` slot or calls the member yet: the trigger's implementation and the dispatcher domain land separately, and the trigger's self-hosted route stays as it is. diff --git a/.changeset/pre/22773-automation-hooks-domain.md b/.changeset/pre/22773-automation-hooks-domain.md deleted file mode 100644 index 7c365dcb726..00000000000 --- a/.changeset/pre/22773-automation-hooks-domain.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/runtime': minor -'@objectstack/core': minor ---- - -feat(runtime,core): inbound automation hooks work on a kernel with no raw HTTP app — the dispatcher admits `POST /automation/hooks/:flowName/:hookId` with no session, through to the trigger's own signature check - -Clause-②: yes - -On a self-hosted server, `@objectstack/trigger-api` serves a flow's inbound hook by mounting `POST /api/v1/automation/hooks/:flowName/:hookId` on the raw Hono app, where a third-party sender (a payment provider, a code host) posts a signed event with no session. A hosted tenant kernel has no raw app, so on that shape the path belonged to the dispatcher's `/automation` domain, whose anonymous floor refused every sender `401 UNAUTHENTICATED`. - -**What widens: one route passes the anonymous floor and the ADR-0069 gate with no session.** `@objectstack/core`'s auth-gate allow-list gains its first parameterised row: exactly `POST /automation/hooks/:flowName/:hookId` in the dispatcher's spelling (four segments, optionally under one `/environments/:id` scope), and nothing else. `/automation/hooks`, `/automation/hooks/x`, `/automation/hooks/x/y/z`, every other method on the route and every other `/automation` path stay gated exactly as before. - -- `@objectstack/core`: `isAuthGateAllowlisted(path, method?)` and `evaluateAuthGate(user, path, method?)` take an optional method. The existing rows are exempt for every method, as before, so a caller that passes no method gets the answer it always got. The new row is exempt for `POST` only, and only when a method is passed. `shouldDenyAnonymous` now passes its `method` to the allow-list beside its `path`. New export: `matchInboundHookRoute(path, method)`, the one reading of the route that the row matches through, returning the two path segments raw. -- `@objectstack/runtime`: the `/automation` domain's anonymous floor now asks the allow-list with its path and method. On the very next statement it hands the admitted route to the request kernel's `trigger-api` service (`ITriggerApiService.handleInboundHook`), ahead of every other `/automation` gate and service probe. The request goes with its body unread and the two path parameters decoded, and the service's `Response` comes back as it is. The service verifies the HMAC signature, so a correctly signed post is accepted and a wrongly signed or unsigned one is refused by that service, with its own answer. The membership gate skips the route for `POST` only, so a signed-in caller is judged exactly as an anonymous one, as on the self-hosted mount. -- No `trigger-api` service registered, or one without `handleInboundHook`: `501 NOT_IMPLEMENTED`, naming which, never `ROUTE_NOT_FOUND`. A service that throws, or a request whose body a transport already read: a sanitised `500 INTERNAL_ERROR`, logged. - -Nothing changes on a self-hosted server: the trigger's raw-app mount still serves the path there and never reaches the dispatcher, and the dispatcher mounts nothing that answers it. diff --git a/.changeset/pre/22774-inbound-hook-member.md b/.changeset/pre/22774-inbound-hook-member.md deleted file mode 100644 index 5b6c57ad311..00000000000 --- a/.changeset/pre/22774-inbound-hook-member.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/trigger-api': minor ---- - -feat(trigger-api): the `trigger-api` service serves the inbound hook from a web-standard `Request` (`ITriggerApiService.handleInboundHook`), and the self-hosted route is found under `http.server` too (#22774) - -Clause-②: yes - -- **What is new.** Wherever `ApiTriggerPlugin` installs its `api` trigger, it now registers the `trigger-api` service and implements its optional `ITriggerApiService.handleInboundHook(request, hook)` member. That member is the inbound hook, `POST /api/v1/automation/hooks/:flowName/:hookId`, taking a `Request` and returning a `Response`, with no raw app, no Hono context and no `http.server`. A hosted tenant kernel owns no socket, so this is how the door reaches it. Its one caller is the runtime HTTP dispatcher's exact `POST /automation/hooks/:flowName/:hookId` domain, which lands separately. The flow name and the hook id come from `hook`, the route the caller matched, and are never parsed again from the URL. The body is read once, from the request itself, and verified as the sender's own bytes. -- **One door, two faces.** The member and the self-hosted route run the same code: the same hook match, the same per-hook secret, read at that moment, the same constant-time verification of `x-objectstack-signature` (the timestamped form and the deprecated body-only form alike), done before anything is enqueued, and the same answer for every outcome. For the same request they answer the same status, the same `Content-Type: application/json` and the same bytes: `401 INVALID_SIGNATURE` for every post that does not verify, `503 SERVICE_UNAVAILABLE` when the hook's secret cannot be read or no queue service is registered, `400 INVALID_REQUEST` for a body that is not a JSON object, `503 ENQUEUE_FAILED`, or `202` with `{ accepted: true, messageId }`. -- **No trigger, no service.** Where the automation service is absent, no trigger is installed, so the `trigger-api` slot stays empty (as no route is mounted). The member's caller then answers its typed absence. The boot `warn` line says so. -- **The route is found under `http.server` too.** The plugin reads `http.server` first, then the deprecated `http-server` alias. A host that registers its server only as `http.server` (the runtime's `config.server` path) now gets the self-hosted route. One that registers a single server under both names still gets it once. Where the two names hold two servers, the route goes on the `http.server` one. -- **The self-hosted route is otherwise unchanged.** Where it was mounted before, it answers the same status, headers and bytes for the same request, and the path is never mounted twice. diff --git a/.changeset/pre/22777-core-relation-condition-sites.md b/.changeset/pre/22777-core-relation-condition-sites.md deleted file mode 100644 index 39fc3beb530..00000000000 --- a/.changeset/pre/22777-core-relation-condition-sites.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/metadata-protocol': patch ---- - -feat(core): export `relationConditionSites`, the one walk that finds a filter's nested-relation conditions and each one's target object (#22777) - -`relationConditionSites(fields, filter)` returns every `{ : { : … } }` condition a filter holds, at the top level and under `$and` / `$or` / `$not`, with the reference field, the target object (resolved by the spec's `referenceTargetOf`) and the condition. An id comparand, an operator bag and a non-reference field are not conditions. - -It moved unchanged from `@objectstack/metadata-protocol`, where it was module-private, so that the MCP stdio data bridge (`@objectstack/mcp`) and the generic data door ask each condition's target its exposure over the same sites. `@objectstack/metadata-protocol` now imports it; its behaviour does not change. diff --git a/.changeset/pre/22777-mcp-stdio-relation-condition-target-exposure.md b/.changeset/pre/22777-mcp-stdio-relation-condition-target-exposure.md deleted file mode 100644 index c0d766169a3..00000000000 --- a/.changeset/pre/22777-mcp-stdio-relation-condition-target-exposure.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/mcp': minor ---- - -fix(mcp)!: on the stdio transport, a nested-relation filter condition asks the related object its declared exposure, and is refused when that object does not serve `list` (#22777) - -Clause-②: yes (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -A nested-relation filter condition, `{ : { : … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one. - -**FROM.** On the MCP stdio transport (`OS_MCP_STDIO_API_KEY`), the `query_records` and `aggregate_records` tools judged only the object they address against its `enable` block, so such a condition in `where` was evaluated whatever the related object's `enable` block declared — including a related object those same tools refuse when it is addressed. - -**TO.** The stdio data bridge asks each condition's related object the same exposure gate it applies to an addressed object, for `list` (the operation a query of that object is judged as), before the engine is asked. A related object the gate does not serve refuses the call with the bridge's existing exposure refusal, the one an addressed object gets: - -- `enable.apiEnabled: false` → `OBJECT_API_DISABLED` (404). -- an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `OBJECT_API_METHOD_NOT_ALLOWED` (405), with the effective operation set. - -The tool's error text names the related object and the condition's position (`where.`), with the remedy. It applies at the top level and under `$and` / `$or` / `$not`. The generic data door has given the same answer for the same condition since #22737. - -**Migration.** Filter on the lookup's stored id (`{ : '' }` or `{ : { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`. - -**Bindings.** - -- **Refused, not withheld.** Dropping a condition would widen the result. -- **One decision, one walk.** The gate is the one the bridge already applies to an addressed object, and the conditions are found by `relationConditionSites` from `@objectstack/core`, the walk the data door uses. -- **The engine is untouched.** A flow's or a hook's own query calls the engine directly and never passes this bridge. -- **No new error code.** diff --git a/.changeset/pre/22780-bulk-param-promoted-route-guidance.md b/.changeset/pre/22780-bulk-param-promoted-route-guidance.md deleted file mode 100644 index 56ffbc56cf4..00000000000 --- a/.changeset/pre/22780-bulk-param-promoted-route-guidance.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the bulk action param's rejection text names the promoted field-backed route instead of saying the bulk surface has none (#22780) - -Clause-②: no - -`BulkActionParamSchema` still refuses `field`, and the widget-config keys, on an authored `bulkActionDefs[].params` entry. An authored param reaches the bulk dialog as written, and nothing on that path resolves it against the object's field definitions. What changed is the reason and the remedy in two rejection messages, which said the bulk surface has no field-backed param route at all. The console now has one. An action the object declares, named in a list view's `bulkActions`, is promoted with its params, and a field-backed param on it (`{ field }`, plus `objectOverride`) is resolved through the single-record dialog's resolver when the bulk dialog opens. - -- **`field`.** The rejection now offers two fixes. Declare the param inline (`name` + `type`, plus `object` and optionally `labelField` for a picker). Or declare the action on the object with the field-backed param and name it in `bulkActions`. The promoted form runs the action once per selected record. -- **Widget-config keys** (`min`, `max`, `step`, `accept`, `lookupFilters` and the rest of that family). The rejection states what the promoted route carries from the field: `type`, `label`, `options`, `required`, `description` (as the help text), `placeholder`, `defaultValue`, `multiple`, `accept` and `maxSize`, plus a reference field's `reference`, `displayField` and `dependsOn`. Of the refused keys, only `accept` and `maxSize` reach the bulk dialog that way. `lookupFilters`, `lookupColumns`, `lookupPageSize` and `descriptionField` are dropped on the bulk path, and the rest are never read off the field. - -Nothing to migrate: the accepted and refused keys are unchanged. Only the text of the two rejection messages, and two source comments, moved. diff --git a/.changeset/pre/22782-objectql-after-hook-abort-rolls-back.md b/.changeset/pre/22782-objectql-after-hook-abort-rolls-back.md deleted file mode 100644 index 074f8488e18..00000000000 --- a/.changeset/pre/22782-objectql-after-hook-abort-rolls-back.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -An `after*` hook whose `onError` is `abort` (the default) now rolls its write back on the default write door, as `HookSchema.onError` says ("abort: Rollback transaction (if blocking)") - -Clause-②: yes (narrowing) - - - -**BREAKING** stored-outcome narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0087, the ratified pre-launch launch-window exemption, as amended 2026-09-13, #18003). - -- **FROM:** `insert` / `update` / `delete` (REST `createData` / `updateData` / `deleteData`, `ctx.api`, the engine door) on an object with a blocking (not `async`) `after*` hook bound from metadata whose `onError` is `abort` or omitted. When that hook threw, the caller was refused, but the row stayed stored, changed or deleted, and so did every write the hook chain had already made through `ctx.api`. A client that retried the refused create made a duplicate. -- **TO:** that write runs inside a unit of work, so the abort rolls back the row and the hook chain's engine writes together. The caller is refused exactly as before. Hook count and order are unchanged. -- **Unchanged:** a write already inside a unit (a caller's `transaction()`, an atomic batch or cascade) joins it, and that unit's owner still decides. No unit is opened for an `onError: 'log'` or `async: true` hook that carries no `condition` (#22856), a hook registered in code with `registerHook` (no declared `onError`), a write with `skipAutomations`, an object whose datasource is not the default one, or a default driver with no transactions (ADR-0119 D1, such as a libSQL remote transport). On those the row stays stored after an abort, as before. -- **Inside the new unit, the rules of any unit apply.** An `after*` hook on such an object that writes a business row to an object on another datasource is now refused with `CrossDatasourceTransactionWriteError`, which the abort turns into a refused write. A MongoDB default store needs a replica set for this unit, as it does for `transaction()` already. If either applies to you, mark the hook `onError: 'log'` or `async: true` and give it no `condition` (#22856), or keep the objects on one datasource. -- **A sandboxed hook body's own `ctx.api.transaction`** on such an object, when no transaction is open around the write, used to open and own its transaction, so a throw from it rolled back only the body's own writes and the triggering row stayed stored. It now joins the door's unit, as it joins any open unit, and the abort rolls back the triggering row too. -- **Work a unit starts and does not await** (an `async: true` hook) no longer inherits the unit's finished transaction handle. It used to fail with "Transaction query already complete" and lose its write. It now runs on its own connection, as it would outside any unit. -- **Cost:** one transaction per write on the objects above only. Measured on the in-process SQLite driver: about 1.36x the per-insert time of the same object with an `onError: 'log'` hook. Objects without such a hook pay one map lookup. diff --git a/.changeset/pre/22782-spec-hook-onerror-abort-text.md b/.changeset/pre/22782-spec-hook-onerror-abort-text.md deleted file mode 100644 index c583ceb1b10..00000000000 --- a/.changeset/pre/22782-spec-hook-onerror-abort-text.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`HookSchema.onError` and `HookEvent` now say where an `after*` hook's `abort` rolls the write back, and what it means elsewhere - -Clause-②: no - -Text only: no key, shape, default or enum changes, and every hook that parsed before parses the same. - -- **`onError`** (its TSDoc and `describe()`): `abort` refuses the operation. A blocking hook's abort rolls the write back whenever the write runs inside a unit of work. Those units are a caller's `transaction()`, an atomic batch or cascade, and the unit the write door now opens for a blocking `abort` `after*` hook on an object on the default datasource whose driver declares transactions (`@objectstack/objectql`, same release). On an object on another datasource, or a driver without transactions, an `after*` hook's abort refuses the caller while the row stays stored. -- **`HookEvent`'s JSDoc** listed three ways a write ends up inside a unit of work. It now lists four: the write door's own unit is the new one. diff --git a/.changeset/pre/22785-import-user-field-target.md b/.changeset/pre/22785-import-user-field-target.md deleted file mode 100644 index 01cc172462b..00000000000 --- a/.changeset/pre/22785-import-user-field-target.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/core': minor ---- - -fix(core)!: an import reads WHICH fields are references through the spec's arbiter, so a `user` field written without `reference` resolves against `sys_user` (#22785) - -Clause-②: yes (narrowing) - - - -**BREAKING** (an accept-set narrowing and a change to published output), shipped as `minor` under the launch-window convention for breaking changes. Two populations are affected, both only for a `user` field written without `reference`: - -- an import row whose cell holds the id of a user the caller cannot see (a user outside the caller's organizations): stored before, `reference_not_found` now, as on a `Field.user` field; -- a downstream reader of that column in `GET /api/v1/data/:object/export`: the cell held the stored user id and now holds the user's name, as a `Field.user` column already does. - -A `user` field's target is fixed by its type: the spec's arbiter `referenceTargetOf` gives `{ type: 'user' }` the target `sys_user` whether or not `reference` is written (`Field.user()` writes it; hand-written JSON, a form or an AI author may not), and the data door's `$expand` already reads it that way. `buildFieldMetaMap`, the field map behind the import, export and import-template doors, counted a field as a reference only when it carried a `reference` string, so those doors disagreed with the arbiter for such a field. It now asks the arbiter both whether a field is a reference and what its target is. - -**Door by door, before and after.** Measured on a real boot, as an administrator and as a member, on a fixture object holding a `user` field written without `reference` beside a `Field.user` one; the connector pull through its executor over a stub protocol. - -- **Import: `POST /api/v1/data/:object/import`, `POST /api/v1/data/:object/import/jobs`, and a mapping's `connectorSource` pull (same runner).** - - FROM: a cell holding a user's email or name was written as the field's id, so the row failed `reference_not_found` ("no sys_user record has id …") and nothing was stored. - - TO: the cell is matched against `sys_user` exactly as on a `Field.user` field, so an email or a name stores the user's id. A pasted id of a user the caller can see still stores. A value matching no user still fails `reference_not_found`, now in the import's own words ("no record matches …"), and a name matching several users fails `reference_ambiguous`. - - `sys_user` is asked its declared exposure first, as every lookup target is: where its `enable` declaration refuses `list`, the cell is refused. -- **The one population that narrows, to `Field.user`'s answer.** A pasted id of a user the caller's row scope hides (a user outside the caller's organizations) used to store, since the write's own reference check runs elevated. It now fails `reference_not_found`, as it already did on a `Field.user` field. The data door's `POST /api/v1/data/:object` is unchanged and still accepts that id. -- **Export: `GET /api/v1/data/:object/export`.** FROM: the column carried the stored user id (JSON and CSV). TO: it is expanded like a `Field.user` column and renders the user's name, so an exported file imports back through the name. -- **Import template: `GET /api/v1/data/:object/export?template=true`.** FROM: the column's instructions named no target ("The name of a record, or its id."). TO: they name `sys_user` by its label ("The name of a User record, or its id."). -- **Unchanged:** - - `Field.user` and every field declaring `reference`. - - A `lookup`, `master_detail` or `tree` field without `reference`: the arbiter names no target for it. - - A non-reference type declaring `reference`: still no target. - - A present `reference` that is not a string: still no target, and no throw. - - plugin-auth's identity import, which reads `sys_user`'s own map. That map has no reference-typed field without `reference`, so it is identical. - -**Fix, if a pipeline relied on the old answer:** none is needed for one that wrote visible user ids. A pipeline that imported ids of users outside the caller's organizations runs as a caller who can see them, as a `Field.user` field already required. A reader that needs the stored user id takes it from the record read (`GET /api/v1/data/:object`, without `expand`), not from the export. diff --git a/.changeset/pre/22786-withhold-milestone-row.md b/.changeset/pre/22786-withhold-milestone-row.md deleted file mode 100644 index e1ddc01f41a..00000000000 --- a/.changeset/pre/22786-withhold-milestone-row.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -A `sys_activity` row that an `activityMilestones` entry fired is withheld from a reader who is not served every field a declared milestone watches - -Clause-②: no - -A fired milestone's row speaks about its watched fields: its `type` and its summary come from the -milestone declaration, beside `metadata.kind`. Narrowing the row column by column left those two -columns speaking to a reader not served a watched field. - -The row is now withheld from that reader as a row, on every read (`find`, `findOne`, `count` and -`aggregate`), by the same pre-scan and the same served-field answer that withhold an update whose -every recorded change the reader is not served. A list's total, its pages and a by-id read all -agree with the rows served. - -- **A reader served every watched field** is served the row unchanged, with `metadata.kind`. -- **A reader withheld only fields no milestone watches** is served it too. -- **A row no milestone fired**, and a row an app writes itself, are unchanged. -- **If the pre-scan fails**, the read is denied rather than served unjudged. - -Rows written before the CRUD mirror declared the watched fields carry no `metadata.kind`, so nothing -on them says a milestone fired; they are judged as before. Nothing you author changes: no key, -option, export or type is added or removed. diff --git a/.changeset/pre/22787-readable-ids-leaf.md b/.changeset/pre/22787-readable-ids-leaf.md deleted file mode 100644 index f1628b56100..00000000000 --- a/.changeset/pre/22787-readable-ids-leaf.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor ---- - -feat(spec): `ReadableIds`, the branded `$in` comparand leaf meaning "the ids of object X this caller may read" (ADR-0139 D1) - -Clause-②: yes (widening) - -- **What is new in `@objectstack/spec/data`.** `ReadableIds.of(object)` builds a frozen leaf, `{ object }`, placed as the comparand of `$in` on a pointer field: `{ record_id: { $in: ReadableIds.of('cpz_doc') } }`. `isReadableIds(value)` answers whether a value is one. `READABLE_IDS_BRAND` is the `Symbol.for('objectstack.filter.readableIds')` key the brand lives under. The `ReadableIds` type carries the brand, so an object literal does not type-check as one. `ReadableIds.of()` throws a `TypeError` for an object name that is not a non-empty string. -- **No request can spell it.** No filter operator is added. The brand is a non-enumerable symbol-keyed property, so JSON, a structured clone or a spread produces an unbranded `{ object }`, and a plain object as the comparand of `$in` is refused `INVALID_FILTER` / 400, as it was before. -- **The comparand-shape door admits it only for a caller that composes it.** `assertListComparandShapes` takes an optional fourth parameter, `ListComparandShapeOptions`. With `{ admitReadableIds: true }` a branded leaf is admitted as the comparand of `$in`, and nowhere else. Without it, which is every existing caller (`parseFilterAST`, the save door, the analytics doors, the RLS compiler), the leaf is refused as a non-list comparand, unchanged. -- **`@objectstack/objectql` does not compose the leaf yet.** The engine's comparand door passes the option, then refuses a branded leaf found anywhere in a `where`, an aggregation's `filter` or a `having` with `INTERNAL_ERROR` / 500, before any driver is asked. Do not emit the leaf until the engine composes it. Until then, read the parent object as the caller and match the ids that read returns with a list. -- Nothing emits the leaf yet, and no read that ran before this change answers differently. diff --git a/.changeset/pre/22789-sms-configured-provider.md b/.changeset/pre/22789-sms-configured-provider.md deleted file mode 100644 index 117e6bccc64..00000000000 --- a/.changeset/pre/22789-sms-configured-provider.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/service-sms': patch ---- - -A configured SMS provider delivers through its credentials in the environment or in Settings, without `OS_SMS_PROVIDER` - -Clause-②: no - -A configuration that named `provider: 'twilio'` (or `'aliyun'`) and kept the provider's keys in the environment, but did not also set `OS_SMS_PROVIDER`, booted on `LogSmsTransport` with `isConfigured() === false`, through `os serve` and `@objectstack/verify`'s `bootStack` alike. The boot logged "SMS will NOT be sent". Two readers missed the credentials: - -- **`resolveSmsCapabilityArg`** now merges the selected provider's credentials into `providerOptions`, env over `config.sms.providerOptions`, key by key, as the mail resolver does. It reads the variables the `sms` settings namespace is env-locked with: `OS_SMS_TWILIO_ACCOUNT_SID`, `_AUTH_TOKEN`, `_FROM_NUMBER` and `_MESSAGING_SERVICE_SID`, and `OS_SMS_ALIYUN_ACCESS_KEY_ID`, `_ACCESS_KEY_SECRET`, `_SIGN_NAME` and `_TEMPLATE_CODE`. -- **`SmsServicePlugin`** honours its constructor's `provider` when the `sms` settings namespace names none, and reads the namespace for that provider's credentials. The namespace's credentials overlay the constructor's `providerOptions`, so a constructor-only option such as `baseUrl` is kept. A provider named in Settings, or by `OS_SMS_PROVIDER`, still wins. - -Unchanged: a configured provider whose credentials are missing everywhere still boots onto the log transport with a warning and `isConfigured() === false`. The resolver still refuses only a provider tag no transport can deliver through. Signatures and exports are unchanged. diff --git a/.changeset/pre/22790-search-total-counted.md b/.changeset/pre/22790-search-total-counted.md deleted file mode 100644 index 6f92b8155a2..00000000000 --- a/.changeset/pre/22790-search-total-counted.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/objectql": minor -"@objectstack/metadata-protocol": patch ---- - -A paged list under a search now reports the real number of matching records as `total`, and `hasMore` agrees with it on every page. - -Clause-②: yes (widening) — `EngineCountOptionsSchema` gains two OPTIONAL keys, `search` and `searchFields`, so the accept set of the count options grows. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write them. - -Until now `GET /api/v1/data/:object?$top=25&$search=Line` over 75 matching records answered `total: 26` (offset + page + 1, a page-local estimate), and the last page claimed `hasMore: true`; `POST /api/v1/data/:object/query` with a `search` did the same. Nothing on the response marked the number as an estimate, so the Console rendered it as the record count and offered the wrong number of pages. - -- **`@objectstack/spec`** — `EngineCountOptionsSchema` declares `search` (the bare string, or the structured `FullTextSearchSchema` form) and `searchFields`, identically to `EngineQueryOptionsSchema`. FROM `count(object, { where })` only → TO `count(object, { where, search, searchFields })`. The structured `search` form carries flag defaults, so the parsed state is named `EngineCountOptionsParsed` (ADR-0122). `HookCountQuery` (`ctx.api.object(name).count()`) derives from it and gains the same two keys. -- **`@objectstack/objectql`** — `engine.count()` (and `ctx.api.object(name).count()`) accepts the two keys it used to refuse as unknown options, and expands them through the same ADR-0061 expansion `find()` and `aggregate()` use: the same server-resolved searchable fields, the same `internal: true` exclusion, the same `searchFields` narrowing, AND-ed with `where` before the security middlewares run. There is one expander, not two, so a count under a search is the number of rows `find()` returns for the same bag. -- **`@objectstack/metadata-protocol`** — `findData`'s paged branch passes `search` / `searchFields` to `engine.count()`, and the page-local estimate branch is gone. `$count=false` still skips the count and omits `total`. - -Nothing to migrate. A client that worked around the estimate, for example by labelling the count as page-local under a search, can read `total` as the match count again. diff --git a/.changeset/pre/22792-explain-asker-gate.md b/.changeset/pre/22792-explain-asker-gate.md deleted file mode 100644 index 8f28203823b..00000000000 --- a/.changeset/pre/22792-explain-asker-gate.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/plugin-security': patch -'@objectstack/spec': patch ---- - -`security/explain` decides whether a record-grained explanation may say a record exists by the read access of whoever asked for it, not by the explained principal's. - -Clause-②: no - -- **An asker who cannot read the record** gets, for every operation, exactly the answer an id no row carries gets: `record` is `{ recordId, visible: false }` with no `decidedBy`, and every layer's per-record attribution is the missing record's. - - An asker explaining another user (`manage_users`, or a delegated administrator's scope) cannot read a record its own by-id read finds no row for, or refuses (a declared 4xx, such as no read grant on the object). - - A principal explaining itself cannot read a record its own by-id read finds no row for. A refused read does not count, so the explanation shows what that principal's own doors answer: a principal that may edit a record it may not read is told what its edit does. - - Before, a principal explaining itself a `read` of a row its read finds no row for was told the row's verdict and why, and an administrator with no read grant on the object was told any row's story; the by-id write verbs already answered a self-explanation of such a row as missing. -- **An asker who can read the record** gets the explained principal's full record story. For a `read`, that is the principal's own verdict, decider included, so the `controlled_by_parent` read verdict on the master leg (not visible, decided by the `sharing` layer) is what such an asker is told. For a by-id `update`, `delete` or `transfer` of a row the principal cannot read, the verdict stays `visible: false`, the answer that principal's write gets, and now names the layer that decided the principal's read; each row-scoped layer's `record` carries that read's attribution, prefaced with the write's `404 RECORD_NOT_FOUND`. Before, such an asker was told the missing record's answer. -- A system asker reads every record. The batch form (`recordIds`) answers each id as the singular form does. -- `@objectstack/spec`: the `record` verdict's description and the `recordIds` "Missing records" clause in `explain.zod.ts` now say which ids an asker cannot read, by asker, and that those answer as missing. Description text only; no schema shape changes. -- Nothing you author changes, and no exported type or signature moves. Object-level explanations (no `recordId`) are unchanged. diff --git a/.changeset/pre/22792-explain-read-master-leg.md b/.changeset/pre/22792-explain-read-master-leg.md deleted file mode 100644 index bd24f936118..00000000000 --- a/.changeset/pre/22792-explain-read-master-leg.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -`security/explain`'s record-grained `read` verdict for a `controlled_by_parent` record now equals the read door's answer on the master leg (ADR-0055). - -- **What changed.** The read door scopes such a record by its master as well as by its own row-level security. explain's read branch modelled the record's own row-level security only. It now asks the read door's own by-id read, with the context it is explaining, wherever every other leg of the read's row story admits the record. A record the door does not return is reported not visible, decided by the `sharing` layer. A record the door returns is reported exactly as before. A rejection of that read is reported fail-closed, as a dependency that throws already is. An `export` explanation, which streams the same find, answers the same way. -- **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field. Every write verb. The answer for an id no row carries. The `ExplainEngineDeps` type: the `recordAbsentToCaller` dependency keeps its signature and is now asked for such a read too. diff --git a/.changeset/pre/22800-identity-import-exposure.md b/.changeset/pre/22800-identity-import-exposure.md deleted file mode 100644 index f032dbad32e..00000000000 --- a/.changeset/pre/22800-identity-import-exposure.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -fix(plugin-auth)!: the identity import asks each lookup target its declared exposure before matching a cell, like every other `runImport` caller (#22800) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The identity import (`runAdminImportUsers`) parses its payload with `prepareImportRequest` and runs its rows through `runImport`, with a run protocol of its own that writes through better-auth. Its `sys_user` field map, which decides which cells are references, is read through the import's declaration reader (`IdentityImportDeps.getMetaItem`). - -**FROM.** The run protocol was built without that reader, so `runImport`'s `servesReferenceTarget` answered "served" without asking: a lookup cell was matched against its TARGET object even when the target's declaration refuses `list` (`enable.apiEnabled: false`, or an `apiMethods` whitelist without `list`). A cell naming an existing record resolved and a cell naming none answered `reference_not_found`, so the row report told the two apart. - -**TO.** The run protocol carries the same reader the parser is handed (one binding, so whenever a cell is a reference its target is judged). A target the spec's decision (`canServeApiOperation`, for `list`) does not serve answers every cell, naming a record or not, a pasted id included, `reference_not_found`, and nothing of the target is read. A target with no `enable` block, or a whitelist that grants `list`, resolves exactly as before. The decision takes no caller, so the system identity the import's reads run under is answered like any other caller. No new error code, no status change. - -**Measured producers.** `sys_user` declares two lookups: `manager_id` into `sys_user` (the import strips that column before `runImport`; its own second pass links managers) and `primary_business_unit_id` into `sys_business_unit`. Both targets serve `list`, and the registry injects no columns into `sys_user` (`managedBy: 'better-auth'`), so no shipped import answer changes. Deployed object extensions of `sys_user` were NOT MEASURED. - -**Fix, if an import relied on the old answer:** the target's own declaration decides. Grant `list` in its `enable.apiMethods`, or drop `enable.apiEnabled: false`, if the object is meant to be matched by import. diff --git a/.changeset/pre/22804-redeliver-inactive-veto.md b/.changeset/pre/22804-redeliver-inactive-veto.md deleted file mode 100644 index bc3bf38ddbe..00000000000 --- a/.changeset/pre/22804-redeliver-inactive-veto.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-webhooks': patch ---- - -The webhook redeliver veto now refuses a delivery whose `sys_webhook` subscription is inactive. The `active` field declares that an inactive webhook is skipped by the dispatcher, and an operator's explicit replay is the dispatcher too, so the declaration now holds on both paths. - -Clause-②: no - -- FROM: the redeliver door (`POST /api/v1/webhooks/redeliver`, and the `webhooks` slot's `handleRedeliver`) replayed a finished delivery whose subscription had `active` switched off. The veto refused only a subscription that was gone, or whose signing secret could not be recovered. -- TO: the door answers `409 DELIVERY_NOT_ELIGIBLE` for that delivery and leaves the row as it is. The message names the webhook, says it is inactive, and names the remedy. The test is the dispatcher's own predicate, `active === true`, so a row whose `active` is null or absent is refused exactly as the dispatcher skips it. Every row the engine writes carries a boolean there: the field is required, with default `true`. -- Fix, for an operator who means to replay: re-activate the webhook, then redeliver. - -The gone-subscription and unrecoverable-secret refusals are unchanged, in text and in order. The inactive check runs after the gone check and before the secret check, so an inactive webhook whose key is also unrecoverable is refused as inactive, the first remedy to try. No code, status, field or export changes. diff --git a/.changeset/pre/22806-uniform-hook-refusal.md b/.changeset/pre/22806-uniform-hook-refusal.md deleted file mode 100644 index 421ca118478..00000000000 --- a/.changeset/pre/22806-uniform-hook-refusal.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/trigger-api': minor -'@objectstack/spec': minor ---- - -fix(trigger-api,spec): every inbound hook post that does not verify gets one answer, `401 INVALID_SIGNATURE`, whatever its flow and hook id - -Clause-②: yes - -- **FROM → TO.** On `POST /api/v1/automation/hooks/:flowName/:hookId`, a post that named no armed flow, or the wrong hook id, answered `404 RESOURCE_NOT_FOUND`, while a post naming an armed flow and its hook id with an absent or bad `x-objectstack-signature` answered `401 INVALID_SIGNATURE`. Now all four answer `401 INVALID_SIGNATURE` with the same body, so the answer no longer tells an armed flow name from an unarmed one, whichever hook id a flow is armed with. -- **Who sees a difference.** A correctly configured sender (the flow's URL, its hook id, its secret) sees none: it still gets `202`. A sender posting to a flow that is not armed, or with a hook id that was rotated away, now gets `401` where it got `404`. A sender with a wrong secret or signing code still gets `401`, as before. -- **The cause is in the server log.** The trigger logs one `warn` line per refused post, with the cause and the flow name the post carried as structured fields: `cause` is one of `unknown-flow`, `wrong-hook-id`, `signature-absent`, `signature-malformed`, `signature-mismatch` or `signature-outside-window`. The line never carries the secret, the signature value or the body. -- **Unchanged: the `503` residual.** A post that names an armed flow and its hook id while that hook's secret cannot be read still answers `503 SERVICE_UNAVAILABLE`, signed or not, without verifying. It stays a `503` on purpose: it is the deployment's fault, a sender retries it, and it lasts only while the secret cannot be read. A wrong hook id never reaches the secret read, so it gets the uniform `401` even then. -- **`@objectstack/spec`.** The `ITriggerApiService.handleInboundHook` docblock states the one refusal and names the `503` as its residual. The type is unchanged. - -**To adopt it.** A sender or monitor that read `404` from this route as "wrong URL" can no longer tell that from a bad signature by the answer: read the trigger's `warn` line for the post instead, whose `cause` names which one it was. diff --git a/.changeset/pre/22810-lowered-body-scope-submitted-refused.md b/.changeset/pre/22810-lowered-body-scope-submitted-refused.md deleted file mode 100644 index 99b4b997e4d..00000000000 --- a/.changeset/pre/22810-lowered-body-scope-submitted-refused.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli)!: `objectstack build` refuses to lower a hook body that reaches `ctx.dispatch.scope` or `ctx.submitted` (#22810) - -Clause-②: no (narrowing) - -The sandboxed body face does not carry either member, and the spec says so: `HookContextSchema.dispatch` gives a body `{ mode, index }` "and not `scope`", and `submitted` is "NOT marshalled into the sandboxed `body` face". The build lowered such a handler anyway, at exit 0, with no warning. Measured through `bootStack`'s artifact door: a body writing `ctx.dispatch.scope.` threw `TypeError: cannot set property … of undefined` on every insert, so REST answered `500 INTERNAL_ERROR` and stored nothing, while the same handler in-process answered `201`. A body reading `ctx.submitted` saw `undefined` on an update, where the in-process handler sees the caller's submission, so a guard written on it evaluated differently with no error at all. - -The hook body extractor (`extractHookBody`) now refuses both members as forbidden tokens, the way it already refuses `.sudo(` and `.create(`. `os lint` calls the same extractor, so it reports them too. - -**BREAKING — what moves for consumers.** - -- **`objectstack build`.** A handler that writes or reads `ctx.dispatch.scope`, or reads `ctx.submitted`, was lowered to a `body` that fails in the sandbox. It is now bundled into the runtime module (`objectstack-runtime.*.mjs`) instead, with a warning naming the hook and the member, and the build still exits 0. Where that module is served beside the artifact the handler runs in-process, so `scope` and `submitted` are real again: measured, the same insert answers `201` and stashes. A deployment that serves `objectstack.json` alone has no function to bind the hook to, so it logs the hook as refused at boot and the hook does not fire. -- **`objectstack build --strict-body`.** Such a handler built at exit 0. It now fails the build (exit 1), naming the hook and the member. -- **`os lint`.** It said nothing. It now reports a `hook-body/bundled-fallback` warning at `hooks[i].handler`. -- **The spellings refused.** Direct and optional (`ctx.dispatch.scope`, `ctx?.submitted`), bracket (`ctx.dispatch['scope']`), through an alias (`const d = ctx.dispatch; d.scope`, `const c = ctx; c.submitted`), and destructured (`const { scope } = ctx.dispatch`, `const { submitted } = ctx`). For `scope` the body must also name `dispatch` somewhere, because `scope` is an ordinary field name. -- **Not refused.** `ctx.dispatch.mode` and `ctx.dispatch.index`, and a field that happens to be named `scope` or `submitted` read through `ctx.input` or `ctx.previous` (`ctx.input.scope`). A body reading such a field off a local row variable (`row.submitted`), or spelling `x.submitted` inside a string, is refused too. That is the over-refusal side: the handler is bundled and keeps working. - -**The one-line fix**, for a handler you want shipped as a body: do the cross-dispatch work once at `ctx.dispatch.index === 0`, or keep the state in the record through `ctx.api`, instead of stashing on `ctx.dispatch.scope`; read the stored row on `ctx.previous` instead of `ctx.submitted`. A handler you are content to run bundled needs no change. - -**Unchanged.** No spec key, export, type or stored shape moves. The sandbox still marshals exactly what it did: `{ mode, index }` for `ctx.dispatch`, no `ctx.submitted`. An in-process handler, a top-level `functions:` entry referenced by name, and a hook that carries an explicit `body` are not lowered here and are not judged. - - diff --git a/.changeset/pre/22817-action-activation-unpackaged.md b/.changeset/pre/22817-action-activation-unpackaged.md deleted file mode 100644 index 0faf2b3f707..00000000000 --- a/.changeset/pre/22817-action-activation-unpackaged.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/runtime": minor -"@objectstack/core": minor ---- - -The action activation door switches an action no package ships, its row names no package, and a ledger write it refuses answers with the status its code means - -Clause-②: yes (widening) - -- **The door accepts an action the environment authored** (`@objectstack/runtime`). `POST /api/v1/actions/_activation/:object/:action` declares that it does not require the action to be packaged. FROM: an object-less action saved through the metadata door (`PUT /api/v1/meta/action/:name`), or one bound to an object no package ships, was refused `503` with `{ code: 'SERVICE_UNAVAILABLE' }` and the store's sentence about an empty package id, and nothing was written. TO: it is switched like a packaged one, `200`, its row carries no package (`package_id` is `null`), and dispatch then refuses it `409 ACTION_DISABLED` like any switched-off action. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared action `404`, an ambiguous name `409`, an engine with no ledger `501`). -- **An action is credited only to the package that ships it** (`@objectstack/runtime`). FROM: an action with no package of its own was credited to its route object's package, so an action the environment saved against a packaged object wrote that object's package into its row. TO: the row names the action's own package; for an action declared inside its object's `actions`, the object's package; otherwise no package. A `sys_metadata` placeholder or a tenant-authored action's package binding is not a shipping package. Rows of shipped actions are unchanged, including the platform's own actions declared inside their objects. No reader decides anything from `package_id`, so nothing that is switched on or off changes. -- **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, an error that declares its own pair keeps it (no ledger attached: `503 SERVICE_UNAVAILABLE`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. -- **The in-memory activation store refuses what the durable store refuses** (`@objectstack/core`). FROM: `InMemoryMetadataActivationStore.setActive` (the base of `InMemoryActionActivationStore` and `InMemoryFlowActivationStore`) accepted `packageId: ''`, which `ObjectStoreMetadataActivationStore.setActive` refuses. TO: it refuses `packageId: ''` with the same `TypeError` and the same sentence, before it keeps anything. If a test passes `''`, omit the key instead. diff --git a/.changeset/pre/22821-admin-password-choice.md b/.changeset/pre/22821-admin-password-choice.md deleted file mode 100644 index 72769d0859d..00000000000 --- a/.changeset/pre/22821-admin-password-choice.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -The Setup "Create User" and "Set Password" dialogs no longer offer a "Generate Temporary Password" box the server ignores once a password is typed - -Clause-②: no - -The `sys_user` actions `create_user` and `set_user_password` showed two controls for one decision: a "Generate Temporary Password" box (ticked by default on Create User) and a password field labelled "leave empty to generate". With a password typed, the box did nothing, yet it was sent ticked, so its state was false whenever the field was filled. - -The password field's label is now the only rule. `generatePassword` is no longer a dialog param on either action. It is sent as a constant `true` in each action's `bodyExtra`, and the endpoint's existing rule gives the label's behaviour: - -- **A typed password** is the one applied, and nothing is generated. -- **An empty password** generates a temporary password, shown once in the result dialog. On Set Password this is new: the box was unticked by default there, so an empty field answered `400` unless the admin ticked it. - -The endpoints `POST /api/v1/auth/admin/create-user` and `POST /api/v1/auth/admin/set-user-password` are unchanged. A caller that sends neither a password nor `generatePassword: true` still gets `400`. diff --git a/.changeset/pre/22823-calldata-fallback-relation-exposure.md b/.changeset/pre/22823-calldata-fallback-relation-exposure.md deleted file mode 100644 index 7c9ec3940a8..00000000000 --- a/.changeset/pre/22823-calldata-fallback-relation-exposure.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime)!: `callData` asks each nested-relation condition's related object its declared exposure before the engine reads it, on the query fallback and the aggregate arm (#22823) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -A nested-relation filter condition, `{ : { : … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one. - -**FROM.** The runtime dispatcher's data bridge, `callData`, judged only the object a call addresses against its `enable` block, and then handed the caller's `where` to the ObjectQL engine at two positions: - -- the `query` / `find` fallback, which serves when no protocol service resolves (an assembly that composes the engine plugin with `registerProtocol: false` and no `MetadataProtocolPlugin`); -- the `aggregate` arm, which never takes the protocol service, so on every assembly (the MCP `aggregate_records` tool over HTTP reaches it). - -At both, such a condition was evaluated whatever the related object's `enable` block declared, including a related object every data door refuses when it is addressed. - -**TO.** `callData` asks each condition's related object the exposure gate it applies to an addressed object, for `list` (the operation a query of that object is judged as), before the engine is called. A related object the gate does not serve refuses the call with the data door's own exposure answer, the one the protocol path gives the same condition: - -- `enable.apiEnabled: false` → `404 OBJECT_API_DISABLED`. -- an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `405 OBJECT_API_METHOD_NOT_ALLOWED`, with the effective operation set in `allowed`. - -The refusal names the related object (`object`) and the condition's position (`param`, `where.`), with the remedy. It applies at the top level and under `$and` / `$or` / `$not`. A related object with no `enable` block, or a whitelist that grants `list`, is served exactly as before. With the protocol service present the `query` arm is unchanged: the data door judges the condition, as it has since #22737. - -**Migration.** Filter on the lookup's stored id (`{ : '' }` or `{ : { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`. - -**Bindings.** - -- **Refused, not withheld.** Dropping a condition would widen the result. -- **One gate, one walk.** The gate is the one `callData` already applies to an addressed object, and the conditions are found by `relationConditionSites` from `@objectstack/core`, the walk the data door and the MCP stdio bridge use. -- **A system context is not asked**, as it is not asked for the addressed object: these flags govern API exposure, not the engine's own reads. A flow's or a hook's own query calls the engine directly and never passes `callData`. -- **No new error code.** `@objectstack/runtime` now stamps the two existing exposure codes, and the error-code ledger lists them under that package too. - -**Measured producers.** Read on `origin/main` `75b6d6a048`: no shipped composition reaches the `query` fallback. Every non-test `ObjectQLPlugin` construction under `packages/` and `examples/` keeps the built-in protocol (`os serve`, the standalone stack, the dev plugin, the verify harness, the kernel factory), and `registerProtocol: false` appears only in tests. The `aggregate` arm is reached on every assembly that serves the MCP route over HTTP. Deployed compositions were NOT MEASURED. diff --git a/.changeset/pre/22823-spec-ledger-runtime-exposure-codes.md b/.changeset/pre/22823-spec-ledger-runtime-exposure-codes.md deleted file mode 100644 index 5a5c7165489..00000000000 --- a/.changeset/pre/22823-spec-ledger-runtime-exposure-codes.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the error-code ledger records `@objectstack/runtime` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22823) - -The runtime dispatcher's `callData` now answers the two exposure codes for a nested-relation filter condition whose related object's `enable` block refuses a query of it, on its `query` fallback and its `aggregate` arm, so `ERROR_CODE_LEDGER['@objectstack/runtime']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered. No export, schema or generated artifact moves. diff --git a/.changeset/pre/22824-admin-rescue-named-slate.md b/.changeset/pre/22824-admin-rescue-named-slate.md deleted file mode 100644 index 0572f18f881..00000000000 --- a/.changeset/pre/22824-admin-rescue-named-slate.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/plugin-approvals': minor -'@objectstack/spec': patch -'@objectstack/lint': patch ---- - -feat(plugin-approvals): an approval step whose approvers resolve to no one opens on the organization's administrators under the default `onEmptyApprovers: 'admin_rescue'`, as ordinary approvers in their inbox - -Clause-②: yes - -**FROM → TO: who receives an `admin_rescue` request.** A node whose approvers resolve to no concrete person (an unstaffed position, an empty team or department, an unset manager, an expression that yields no one) under the default `onEmptyApprovers: 'admin_rescue'`: - -- **FROM:** the request opened on a slate that named no one, such as the literal `position:sales_manager`. It was in no one's "My Pending", the bell counted 0, no one was told, and a `lockRecord: true` record stayed locked until a platform or tenant admin found it under **All** and decided it through the privileged override. -- **TO:** the request opens on the request organization's administrators: the people who hold the organization-admin capability (`organization_admin` or `organization_admin_no_bypass`, ADR-0095 D3) in that organization. That is the same authority that lets a tenant admin override a request, resolved to the people who hold it, through the same grant resolution every request runs. An organization's owners and admins receive the capability automatically. - -They are ordinary approvers. The request is listed and counted for each of them under their own `approverId`, each is told once by `approval.requested`, out-of-office delegation applies to them, and a decision by one of them is an ordinary approval or rejection, recorded with `via_override: false`. The node's `behavior` reads them as one group. A platform administrator is not added for being one; one who also holds the organization's capability, such as its owner, is on the slate for that. - -The request says why. Its timeline carries a note, for example "Routed to this organization's administrators because the approver slate named no one: position 'sales_manager' has no holder.", and its node-config snapshot keeps the abandoned slate and the reason under `__resolvedFrom` (`admin_rescue:abandoned`, `admin_rescue:reason`). The abandoned slate is replaced, not kept beside the administrators, so a holder staffed into the position after the request opened does not receive it; an administrator can reassign it to them. - -**Unchanged.** A slate that names a person; `fallback`, `auto_approve` and `fail`; a `fallback` whose own list resolves to no one still degrades to `admin_rescue`, which now opens on the administrators. The privileged override stays for what the administrators' slate cannot reach: a request whose named approvers have all left after it opened, and an organization with no administrator (or a request with no organization), where the request still opens on the slate that names no one and the server logs a warning. - -**To name the person who should decide instead of the administrators,** declare `onEmptyApprovers: 'fallback'` with a `fallbackApprovers` list. - -**`@objectstack/spec`:** the `onEmptyApprovers` description says that `admin_rescue` opens on the organization's administrators and that `fallback` names who decides instead. - -**`@objectstack/lint`:** `approval-approvers-may-resolve-empty` no longer says the request "waits forever": it says the request opens on the organization's administrators, its remedy names `onEmptyApprovers: 'fallback'` as the way to name who decides, and its `os explain` text says the same. diff --git a/.changeset/pre/22825-inbound-hook-timestamped-guidance.md b/.changeset/pre/22825-inbound-hook-timestamped-guidance.md deleted file mode 100644 index 09101671b1e..00000000000 --- a/.changeset/pre/22825-inbound-hook-timestamped-guidance.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/lint': patch -'@objectstack/skills': patch ---- - -docs(automation): every text that teaches a sender of an inbound `api` hook teaches the timestamped `x-objectstack-signature` form only — `t=,v1=`, the HMAC-SHA256 of `.` under the flow's secret, refused when `t` is more than 300 seconds from the server clock - -Clause-②: no - -The inbound hook door has accepted the timestamped form since `signHttpBodyAt` landed in `@objectstack/core`, and it is the only form that stops a captured post from verifying forever; the published teaching still showed the body-only `sha256=` form. The guidance converges first; the door's refusal of the body-only form is a later change of its own. - -- **`@objectstack/skills`.** The `objectstack-automation` skill's Inbound webhook section teaches `t=,v1=`: `v1` is the HMAC-SHA256 of `.` under the start node's `secret`, and the door refuses a `t` more than 300 seconds from its clock. The body-only form is no longer shown. -- **`@objectstack/lint`.** The hint of `flow-api-trigger-secret-missing` and its `os explain` entry describe the same form. The rule's id, severity, verdict sentence and judgement are unchanged. -- The showcase example's comment and the platform checklist's intake recipe sign the same way. - -**Nothing to migrate for an author.** A sender that already signs with `signHttpBodyAt` is unchanged. **The outbound contract stays as published:** the messaging outbox and the flow `http` node keep signing body-only (`signHttpBody`) for third-party receivers, and the inbound door's accept set is not changed by this release. diff --git a/.changeset/pre/22826-refuse-body-only-signature.md b/.changeset/pre/22826-refuse-body-only-signature.md deleted file mode 100644 index 33f795a896d..00000000000 --- a/.changeset/pre/22826-refuse-body-only-signature.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/trigger-api': major -'@objectstack/spec': patch ---- - -feat(trigger-api)!: the inbound hook door refuses the body-only `x-objectstack-signature` form, so only the timestamped, replay-protected form verifies - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a runtime door, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**What narrows, from → to.** - -- `POST /api/v1/automation/hooks/:flowName/:hookId`, through the self-hosted mount and through `ITriggerApiService.handleInboundHook` alike. A post whose `x-objectstack-signature` was the body-only value (`sha256=` and the hex HMAC-SHA256 of the raw body under the flow's secret) was accepted, `202` and enqueued, with a deprecation `warn` line once per armed hook. It now gets the same `401 INVALID_SIGNATURE` every post that does not verify gets, and nothing is enqueued, even when the value is the secret's HMAC of the body. The body-only form signs no time, so it carries no replay protection. -- The one accepted form is the timestamped one: `t=,v1=`, the HMAC-SHA256 of `.` under the flow's secret, refused when `t` is more than 300 seconds from the server clock. -- The deprecation line is gone. The refusal's `warn` line carries the cause `signature-body-only`, beside `unknown-flow`, `wrong-hook-id`, `signature-absent`, `signature-malformed`, `signature-mismatch` and `signature-outside-window`. It never carries the secret, the signature value or the body. -- `verifySignature(secret, rawBody, header)`, exported by `@objectstack/trigger-api`, answers as the door does: `true` only for the timestamped form inside its window, `false` for a body-only value. Its signature is unchanged. - -**Unchanged: the outbound contract.** `@objectstack/core`'s `verifyHttpSignature` still verifies both forms, and `signHttpBody` still writes the body-only value: the messaging outbox and the flow `http` node sign it for third-party receivers, as the outbound webhook contract publishes. Only this inbound door refuses it. - -**Who sees a difference.** A sender that signs the timestamped form, as the inbound-hook guidance teaches (the `objectstack-automation` skill's Inbound webhook section, `os explain flow-api-trigger-secret-missing`, `signHttpBodyAt` in `@objectstack/core`), sees none. A sender still on the body-only value now gets `401`, and the trigger's `warn` line for its post names `signature-body-only`, which tells an operator its secret is right and its signing form is the retired one. - -**`@objectstack/spec`.** The `ITriggerApiService.handleInboundHook` docblock names the timestamped form as the only one accepted and the body-only form as refused. The type is unchanged. diff --git a/.changeset/pre/22835-datasource-reads-environment-only.md b/.changeset/pre/22835-datasource-reads-environment-only.md deleted file mode 100644 index c6cbd4f35a2..00000000000 --- a/.changeset/pre/22835-datasource-reads-environment-only.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/service-datasource': minor -'@objectstack/metadata-core': minor -'@objectstack/objectql': patch ---- - -feat(service-datasource,metadata-core)!: the datasource-admin plugin reads the environment's stored `datasource` rows only; a legacy organization-scoped row is neither loaded at boot nor served (ADR-0131 D6) - -Clause-②: no (narrowing) - - - -**BREAKING (narrowing).** Graded `minor` on the v18 prerelease line, as the read narrowing it completes. - -**What changes.** `DatasourceAdminServicePlugin` reads stored `datasource` rows in five places — the boot restore (`restoreRuntimeDatasources`), the by-name read behind cluster convergence and the `/meta` door's datasource projection, and the existence probes behind its own create, update and remove. Each now reads the environment's rows only, judged by the same row-value test the metadata reads apply. A row stored organization-scoped by an earlier release: - -- is no longer registered at boot, so the datasource-admin door and the metadata read door no longer serve it, and no live pool is opened from it; -- is never updated or deleted by an admin create, update or remove under its name — those write and remove an environment row of their own; -- stays at rest, and the boot report `[metadata_org_scoped_unserved]` keeps naming it. Nothing is deleted or rewritten; the v18 migration ceremony (`os migrate`, ADR-0131 D10) carries it. - -**What moves for consumers.** - -| From | To | -|:--|:--| -| a legacy organization-scoped runtime datasource a deployment relied on | re-create it through the datasource-admin door or save it through the `/meta` door to make it environment-wide now, or wait for the v18 migration ceremony | - -`@objectstack/metadata-core` exports `isLegacyOrganizationRow`, the one row-value test for such a row; `@objectstack/objectql`'s authored hook and action binders now read it from there, with no change in what they bind. diff --git a/.changeset/pre/22837-lowered-hook-parameter-rebind.md b/.changeset/pre/22837-lowered-hook-parameter-rebind.md deleted file mode 100644 index 48edf5aaa40..00000000000 --- a/.changeset/pre/22837-lowered-hook-parameter-rebind.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli)!: `objectstack build` rebinds a hook handler's parameter to the sandbox's `ctx` instead of dropping it, and refuses the parameter lists it cannot rebind (#22837) - -Clause-②: yes (narrowing) - -The lowering ships a hook handler's statements as `body.source`, and the runtime runs a body as `(async (ctx) => { SOURCE })`, so `ctx` is the only name a body is given. The handler's parameter list was dropped. A handler written `async (hookCtx) => { hookCtx.input.note = … }`, or `async ({ input }) => { input.note = … }`, lowered at exit 0 with no warning, and its body threw `ReferenceError: 'hookCtx' is not defined` on every run. Measured through `bootStack`'s artifact door: REST answered `500 INTERNAL_ERROR` and stored nothing, while the same handler in-process answered `201`. - -The hook body extractor (`extractHookBody`) now re-emits the handler's one parameter as the body's first statement, `var hookCtx = ctx;` or `var { input } = ctx;`, so the handler's own code runs as written. Both boots of the door above now store the same row. `os lint` calls the same extractor, so it agrees. - -**BREAKING: what moves for consumers.** - -- **A hook handler whose parameter is not named `ctx`.** It was lowered to a body that failed in the sandbox. It is now lowered to a body that runs. A handler whose parameter is `ctx`, or that takes none, lowers byte-for-byte as before. -- **A hook handler whose parameter list cannot be rebound.** This covers more than one parameter, a default value or a rest parameter on it, a destructuring pattern that binds the name `ctx`, a body that declares its own `ctx` with `let` / `const` / `class` / `function`, and a destructured `log` / `crypto` / `title` / `api` taken apart further than one plain name (capability inference cannot follow that). Such a handler was lowered to a body that failed, or that ran with a parameter the sandbox never bound. It is now bundled into the runtime module (`objectstack-runtime.*.mjs`), as `.sudo(` and `fetch(` already are, with a warning that names the hook and the parameter. `objectstack build` still exits 0, `objectstack build --strict-body` now fails on it (exit 1), and `os lint` reports `hook-body/bundled-fallback` at `hooks[i].handler`. A handler taking `(ctx, extra)` is refused even when `extra` is unused: an in-process hook handler is only ever called with the context. -- **A hook handler reaching `ctx.provenance`, `ctx.ql` or `ctx.transaction`.** These are the rest of the in-process `HookContext` that the sandbox does not carry. A body reads `ctx.provenance` as `undefined`, so a guard on `ctx.provenance.flowRunId` evaluated as if the write had no origin. `ctx.ql` (the engine) and `ctx.transaction` (a driver handle) are live host objects the sandbox cannot hold. Such a handler is now bundled, with the same warning, build exit code, `--strict-body` failure and lint rule as above. `ctx.api.transaction(fn)`, `ctx.api.object(name)` and a field merely named `provenance`, `ql` or `transaction` read through `ctx.input` or `ctx.previous` are not refused. -- **A member destructured in the parameter list now meets the refusal the body spelling meets.** `async ({ input, submitted }) => …` and `async ({ dispatch: { scope } }) => …` were lowered (and failed). They are now refused like `const { submitted } = ctx`. -- **A method-shorthand handler with exactly one nested function** (`async handler(ctx) { … rows.map((r) => r.id) … }`) is now judged on the method rather than on the nested function. A method-body name that nested function closes over was reported as a free identifier, and the handler was bundled. It now lowers. A module-scope helper called outside the nested function went unreported, and the handler lowered and threw `ReferenceError`. It is now refused and bundled, like any other module-scope reference. -- **The published `extractHookBody` (`@objectstack/cli/hook-body`) is the hook face.** Its signature is unchanged. An action's `(input, ctx)` target passed to it is refused for its second parameter, where `objectstack build` lowers it unchanged: an action body runs in its own `(input, ctx)` wrapper and gets no rebind. - -**The one-line fix**, for a refused handler you want shipped as a body: take the context as the one parameter (under any name, or destructured), drop the default, and read the member you need through `ctx.api` or `ctx.previous`. A handler you are content to run bundled needs no change. - -**Unchanged.** No spec key, export, type or stored shape moves, and `extractHookBody`'s published signature and refusal kinds are the same. The sandbox still marshals exactly what it did. An action target's lowering is unchanged. An in-process handler, a top-level `functions:` entry referenced by name, and a hook that carries an explicit `body` are not lowered here and are not judged. - - diff --git a/.changeset/pre/22839-strict-body-count.md b/.changeset/pre/22839-strict-body-count.md deleted file mode 100644 index 5bd78f31132..00000000000 --- a/.changeset/pre/22839-strict-body-count.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli)!: `objectstack build --strict-body` counts each callable once, and `--json` `issues` lists callables only (#22839) - -Clause-②: no (narrowing) - -`--strict-body` printed "N callable(s) lack a metadata body" with N one higher than the callables it listed: one refused handler read 2, four read 5. The list was the extractor's refusals plus one extra row whenever any callable was not lowered, with `origin: ''` and the reason "N callable(s) lowered to handler ref but produced no body". The header counted that extra row as a callable, and `--json` published it in `issues` beside the real ones. - -The list is now one row per callable that was not lowered into a metadata body, each callable once. It is the population the extra row's number was meant to count, itemized: in the two shapes below that number undercounted it. The header's N is the number of rows. - -**BREAKING — what moves for consumers.** - -- **`--json` `issues`, on the `strict-body: missing body` exit.** Before: the refusals plus the `` row. Now: callables only, and no row has the origin ``. A handler the extractor refused keeps its row unchanged (`origin`, `reason`, `kind`, `freeIdentifiers`). A callable that was never tried now gets its own `{ origin, reason }` row instead of being folded into the aggregate's number: a top-level `functions` entry (`function 'name'`, which has no `body` slot), or a hook or action whose author wrote its own `body` beside an inline function (that function still rides the runtime module). -- **The header.** It reads the number of rows listed. For one refused handler that is 1, not 2. -- **A stack that carries the same callable at the top level and inside a `packages[i].manifest` body.** Such a stack is walked twice, so a refusal was listed twice and a lowered body was counted twice. Each callable is now listed once. A `functions` entry in such a stack beside a lowered hook was hidden by the double count, so the build passed `--strict-body`; it is now refused, and named. -- **A stack that binds one function as the handler of two or more hooks or actions, beside a `functions` entry or a hook or action with an authored `body`.** This happens in a single walk too. The shared function was lowered at each of its slots, and each slot counted as one lowered body, while the function counted as one callable. The surplus cancelled the callable beside it that was never lowered, so the old count read 0, no warning was recorded, and the build passed `--strict-body`. Such a stack is now refused, and the row names the `functions` entry, or the hook or action whose author wrote its `body`. - -**The one-line fix**, for a script that read the `` row: read `issues.length` for the number of callables, and the rows themselves for which ones. - -**Unchanged.** For a stack in which each callable is bound at one slot, `--strict-body` refuses exactly the stacks it refused before. Every refusal keeps the same exit code (1) and the same `error` string. The default build (no `--strict-body`), its warning lines, `bodyExtractionWarnings` in the success payload and the runtime-bundle decision do not move. No spec key, export, type or stored shape moves. - - diff --git a/.changeset/pre/22841-cli-rule-ids-one-line-verdicts.md b/.changeset/pre/22841-cli-rule-ids-one-line-verdicts.md deleted file mode 100644 index cbe6cce7708..00000000000 --- a/.changeset/pre/22841-cli-rule-ids-one-line-verdicts.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/cli": patch -"@objectstack/lint": patch -"@objectstack/objectql": patch ---- - -fix(cli): the nine rule ids the CLI produces print one verdict sentence each, `os explain ` carries their reasoning, and the undeclared-handlers boot line is one line - -Clause-②: no - -- **One verdict sentence of at most 200 characters.** Measured on every finding the CLI's suites fire (message length; before → after): - - `os lint`'s hook-body rules: `hook-body/not-lowerable` 565–661 → 165–178, `hook-body/bundled-fallback` 324–947 → 109–171, `hook-body/unparseable` 469 → 157, `hook-body/extraction-failed` 331 → 140. Over every refusal the extractor can throw (14 forbidden constructs and 7 parameter refusals), `hook-body/bundled-fallback` runs 296–1,011 → 113–199: the verdict quotes the extractor's own verdict clause, cut to fit and marked `…` when cut. - - package docs (`os build`, `os validate`, `os lint`): `docs/uncollected-directory` 433–558 → 124–183, `docs/nav-target` 263–299 → 147–184, `docs/frontmatter-tags` 233–285 → 135–195, `docs/duplicate-name` 330 → 167 (its within-package arm is unchanged at 101). A doc finding no longer repeats its path, which every printer already leads with; lists of files, packages and directories name the first three and count the rest. - - `picklist-reference-unverified` (the `os validate` / `os build` notice): 240–245 → 173–175, naming at most two outside dependencies and counting the rest. -- **A `fix:` line for the hook-body rules.** Their remedy moves out of the message into the finding's `fix`, at most 200 characters: `os lint` prints it as `→ fix:` under `--fix`, and `os lint --json` now carries `fix` on these four ids' issues. The remedy is still chosen per case: inline a module-scope name, or keep a check on a host-only global such as `Intl` in a string handler ref or a validation rule. A doc finding carries no separate fix, so its sentence ends with the remedy; the picklist notice's `hint` is unchanged. -- **`os explain ` carries the rest.** Each of the nine ids gains a `RULE_EXPLANATIONS` entry in `@objectstack/lint`, the one table `os explain` reads, and `os lint`'s `rule:` line now points at it. They hold what the messages stopped saying: why a bundled handler is a change of deployment shape and how to bundle on purpose, what the sandbox cannot run and where `os build` prints the full refusal, why an instrument limit is not a verdict, where package docs are read from and why the collector never guesses, why a doc name may appear once, what a doc navigation item can open, how doc tags are read, and why a reference into a dependency outside the stack is not judged. -- **The `[action-governance]` boot line for registered handlers with no declaration** (`os dev`, `os serve`, and any host that boots ObjectQL) is one line of 192 characters, where it was 628: it names the three sources it read, states the 404 as expected rather than measured, and says to declare each with `defineAction`. Its structured `count` / `handlers` tail is unchanged, and the dead-button line beside it is unchanged. -- **Unchanged:** every rule id, severity and `path`, the boot lines' level and payload, and what each check accepts or refuses. A tool that matched on this message text should match on `rule` and `path` instead. diff --git a/.changeset/pre/22848-service-slot-contracts-webhooks-trigger-api.md b/.changeset/pre/22848-service-slot-contracts-webhooks-trigger-api.md deleted file mode 100644 index 563ec32eb51..00000000000 --- a/.changeset/pre/22848-service-slot-contracts-webhooks-trigger-api.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `ServiceSlotContracts` types the `webhooks` and `trigger-api` slots - -Clause-②: yes (widening) - -`ServiceSlotContracts` (from `@objectstack/spec/contracts`) gains two entries: - -- `webhooks: IWebhookService`. `@objectstack/plugin-webhooks` registers this slot with a service typed as `IWebhookService`. -- `'trigger-api': ITriggerApiService`. `@objectstack/trigger-api` registers this slot with a service typed as `ITriggerApiService`. - -FROM → TO: - -- `ServiceSlotContract<'webhooks'>` goes from `unknown` to `IWebhookService`, and `ServiceSlotContract<'trigger-api'>` goes from `unknown` to `ITriggerApiService`. -- A lookup keyed by `keyof ServiceSlotContracts` now accepts both names. One example is the runtime dispatcher's `resolveService` / `getRequestKernelService`, which used to answer `any` for them. It now answers the contract or `undefined`. So `resolveService(ctx, 'webhooks')` resolves to `IWebhookService | undefined`, and `?.handleRedeliver` has the declared member's type. The same holds for `'trigger-api'` and `?.handleInboundHook`. - -Every member of both contracts is optional, so a reader still checks that the member it calls is present. No other slot's entry changes, and nothing changes at runtime: this is type information only. - -Nobody needs to migrate. Existing casts at those lookups still compile. If you build an object typed as the whole `ServiceSlotContracts` map, it must now carry the two new keys. This repository and the pinned objectui checkout build no such object. diff --git a/.changeset/pre/22853-runtime-bundle-bodyless.md b/.changeset/pre/22853-runtime-bundle-bodyless.md deleted file mode 100644 index 383f3e3b89c..00000000000 --- a/.changeset/pre/22853-runtime-bundle-bodyless.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -fix(cli)!: `objectstack build` writes the runtime module whenever a callable still needs it, and its warning counts each refused handler once (#22853) - -Clause-②: no (narrowing) - -`objectstack build` decided whether to write `objectstack-runtime.*.mjs` from the number of callables minus the number of slots lowered into a metadata body. The first counts each callable once and the second counts each slot, so a callable lowered at two slots cancelled a `functions` entry beside it. The build then printed `Skipping legacy runtime bundle (all 2 callables are body-only)`, exited 0 and wrote `dist/objectstack.json` alone, and the `functions` entry shipped with no implementation. Three shapes reached it: - -- the same hook at the top level and inside a `packages[i].manifest` body, beside a `functions` entry; -- one handler shared by two hooks, beside a `functions` entry; -- one function bound both as a hook handler and as a `functions` entry. - -The module is now decided from the list `--strict-body` counts: one row per callable the runtime module has to carry, each callable once. A slot that has no body, which is a `functions` entry or a hook or action whose extraction was refused, keeps its callable on that list even when another slot lowered the same function. The default build's warning (`N handlers could not be lowered to a metadata body`) and its summary line now count the refused handlers on that list, each once. - -**BREAKING: what moves for consumers.** - -- **`objectstack build`.** It now writes the runtime module for the three shapes above, and for a function bound at a refused slot and at a slot that lowered it. Every stack it wrote the module for before still gets one. -- **`objectstack build --no-runtime-bundle`.** Those stacks are now refused (exit 1). They passed at exit 0 while the `functions` entry, or the refused hook, had no implementation. The message's first number counts the callables that lack a body, and its second counts the refused handlers, each once. -- **`objectstack build --strict-body`.** A stack that binds one function at a slot that lowered it AND as a `functions` entry or at a refused slot is now refused, and the row names the `functions` entry or the refused slot. It passed before. For a function bound at several slots, the row in `--json` `issues` is the extractor's refusal when any slot refused it, then the `functions` entry's row, then the authored-`body` row. Before, it was the first such slot's row. -- **The default build's warning count.** A refused handler shared by two hooks, or carried at the top level and in a package body, counts as one handler. It counted once per slot. - -**The one-line fix**, for a CI step that runs `--no-runtime-bundle` on a stack carrying a `functions` entry: drop the flag. A `functions` entry has no metadata body, so it runs only from the runtime module. - -**Unchanged.** For a stack in which each callable is bound at one slot and walked once, the module decision, `--no-runtime-bundle` and `--strict-body` give the same verdicts as before. Exit codes and `error` strings are the same. `--json` `bodyExtractionWarnings` still carries one record per refused slot. No spec key, export, type or stored shape moves. - - diff --git a/.changeset/pre/22855-scoped-transaction-ambient.md b/.changeset/pre/22855-scoped-transaction-ambient.md deleted file mode 100644 index 84f24aa7355..00000000000 --- a/.changeset/pre/22855-scoped-transaction-ambient.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -Work a `ctx.api.transaction(fn)` unit starts and does not await no longer keeps the unit's finished transaction handle - -Clause-②: no - -`ctx.api.transaction(fn)` is `ScopedContext.transaction()`, the transaction a hook or action handler opens. When the unit it opened finished, work the callback had started and not awaited still held its handle. The typical case is an `async: true` hook on a record the callback wrote. That work then failed with "Transaction query already complete" and its write was lost. Only the hook's log showed it. Measured on the in-process SQLite stack: the record was stored and the hook's later note was not. - -The handle reached that work two ways, and both now close when the callback settles, before the commit: - -- **Ambient.** `ScopedContext.transaction()` published its unit into the engine's ambient store and never closed the entry. It now runs its callback through the same helper `transaction()` uses, which closes the entry. -- **Threaded.** The unit's handle is also threaded into the callback's context (`tx.object(…)`), and from there into the triggered hook's `ctx.api`. A handle whose unit has closed is now treated as absent, so that work runs on its own connection, as it would outside any unit. That includes the write door's own unit for an aborting blocking `after*` hook: a late write opens one exactly when the same write outside any unit would, so that hook's abort rolls the write back. This applies on both surfaces: it also covers a caller who opens `transaction()` and passes its context to a write (`{ context: trx }`). - -**Unchanged:** work the callback awaits still runs inside the unit and commits or rolls back with it. A nested `transaction()` still joins the open unit, and its handle stays live until the outermost callback settles. Writes with no transaction are unaffected. A handle opened through the discrete `beginTransaction` / `commitTransaction` / `rollbackTransaction` trio (the sandboxed hook body's path) is not covered and behaves as before. diff --git a/.changeset/pre/22856-log-hook-unevaluable-condition.md b/.changeset/pre/22856-log-hook-unevaluable-condition.md deleted file mode 100644 index 6c7e5026bb6..00000000000 --- a/.changeset/pre/22856-log-hook-unevaluable-condition.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -fix(objectql)!: a write refused because an `after*` hook's `condition` could not be evaluated no longer leaves its row behind when that hook is `onError: 'log'` or `async: true` (#22856) - -Clause-②: no (narrowing) - - - -**BREAKING** stored-outcome narrowing, shipped as `minor` under the launch-window convention for breaking changes. - -A hook `condition` that cannot be evaluated aborts the operation whatever the hook's `onError` says. Examples are a condition reading `previous` on an insert, a key the object does not declare, or `null > null`. The condition is checked before an `async: true` hook detaches, so that hook aborts too. - -- **FROM:** `insert` / `update` / `delete` (REST `createData` / `updateData` / `deleteData`, `ctx.api`, the engine door) on an object whose `after*` hooks bound from metadata are all `onError: 'log'` or `async: true`, with at least one carrying a `condition`. The write door opened no unit of work, because it opened one only for a blocking `onError: 'abort'` hook. So a condition that could not be evaluated refused the caller while the row stayed stored, changed or deleted. Measured on REST `POST /data/:object`: `500`, and the row was stored. -- **TO:** an `after*` hook bound from metadata that carries a `condition` opens the write door's unit, whatever its `onError` and `async`. A condition that cannot be evaluated rolls back the row and the hook chain's engine writes together. The caller is refused exactly as before. -- **Inside the new unit, the rules of any unit apply,** so writes that were accepted are now refused on objects carrying such a hook: - - A blocking `onError: 'log'` hook whose body writes a business row to an object on another datasource has that write refused with `CrossDatasourceTransactionWriteError`. The hook's `onError: 'log'` logs the refusal and the row is not written, where it used to land. The caller's own write is still stored. - - The same applies to the part of an `async: true` hook's body that runs before the unit's callback settles. A write the body makes after that lands as before. - - A MongoDB default store without a replica set now refuses writes to objects carrying such a hook, because this unit needs a replica set, as `transaction()` already does. - - **Fix, if one of these applies to you:** drop the hook's `condition` (and test it in the hook's body instead), or keep the objects on one datasource, or run MongoDB as a replica set. -- **Unchanged:** a condition's verdict is read as before. `false` skips the hook, and with `true` an `onError: 'log'` hook's thrown error is logged and the caller's row is stored. A write already inside a unit joins it. No unit is opened with `skipAutomations`, off the default datasource, or on a default driver with no transactions. An `onError: 'log'` or `async: true` hook with no `condition` still opens no unit. -- **Cost:** one transaction per write on objects that carry such a hook. This is the same per-write cost as the unit for `onError: 'abort'` hooks. In the showcase example this covers updates to `showcase_task` and `showcase_project`, whose `async: true` `afterUpdate` hooks carry conditions. diff --git a/.changeset/pre/22858-bulk-param-promoted-route-migration-text.md b/.changeset/pre/22858-bulk-param-promoted-route-migration-text.md deleted file mode 100644 index ff8556b035c..00000000000 --- a/.changeset/pre/22858-bulk-param-promoted-route-migration-text.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the `ui-bulk-action-param-unknown-keys-refused` upgrade guidance names the promoted field-backed bulk route instead of saying the bulk surface has none (#22858) - -Clause-②: no - -The major-18 semantic entry `ui-bulk-action-param-unknown-keys-refused`, which `objectstack migrate meta` prints, said two things that stopped being true once the console resolved a promoted bulk action's params through the single-record dialog's resolver. It called `field` / `objectOverride` contracts "the bulk surface does not implement", and it told the reader that declaring a widget-config key on the object's field cannot help because "the bulk surface has no field-backed param route". The entry now uses the wording the `BulkActionParamSchema` rejection messages already use. - -- **`replacement`.** An authored `bulkActionDefs[].params` entry has no field-backed route: it reaches the dialog as written. The `field` rejection offers two fixes: declare the param inline, or declare the action on the object with the field-backed param and name it in the view's `bulkActions`. That second form is the promoted route, whose params are resolved when the bulk dialog opens. Of the widget-config keys, only `accept` / `maxSize` declared on the field reach the dialog, through a promoted action. `carryOver` / `defaultFromRow` / `requiresFeature` are still ACTION-param contracts the bulk surface does not implement. -- **`reason`.** Declaring the key on the field and keeping the authored def still does not reach the dialog. A promoted action is the one route that consults the field. Of the refused keys, only `accept` / `maxSize` arrive that way. `lookupFilters` / `lookupColumns` / `lookupPageSize` / `descriptionField` are resolved and then dropped on the bulk path, and the rest are never read off the field. - -Nothing to migrate: the accepted and refused keys are unchanged. Only the guidance text of one migration entry moved. diff --git a/.changeset/pre/22860-lowered-body-parse-peel.md b/.changeset/pre/22860-lowered-body-parse-peel.md deleted file mode 100644 index 2eb8f6113b1..00000000000 --- a/.changeset/pre/22860-lowered-body-parse-peel.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): `objectstack build` reads a lowered hook or action body off the handler's parse, so a `}` in a string, a statement-level inner arrow or a `function` handler with a pattern parameter no longer ships a body that is not the handler's - -Clause-②: no - -When `objectstack build` lowers an inline handler to a metadata `body`, it ships the handler's statements as `body.source`. It found those statements by counting `{` / `}` and `(` / `)` through the handler's text, with no idea of what a string, a template literal, a regular expression or a comment is, and it took the first parenthesised `=>` anywhere in the text as the handler's header. So ordinary handler code shipped a body that is not the handler's, and the build still exited 0. Measured through a real `objectstack build`: - -- **A `}` inside a string, a template literal, a regex or a comment** cut the body off at that brace. `ctx.input.note = 'a}b'` shipped as `ctx.input.note = "a`, a `SyntaxError` on the body's first run. -- **A statement-level inner arrow** in a `function` handler (`const f = (a) => a + '!'`) was read as the handler, and the body was built around it, which is also a `SyntaxError`. With a block-bodied inner arrow, the inner arrow's own statements shipped: a body that runs and does none of the handler's work. -- **A `function` handler whose parameter is a pattern** (`async function ({ input }) { … }`) shipped the pattern as its body. After the parameter rebind that is `var { input } = ctx; input`. It throws nothing and stores nothing, where the in-process handler stores the row. -- **A `{` inside a string** left the count unbalanced, so the handler was refused as unparseable and bundled into the runtime module, with a warning telling the author to rewrite a handler that had nothing wrong with it. - -The only signal for the first three was `os lint`'s `hook-body-source-unparseable` warning on the lowered body. It pointed the author at a syntax error their handler does not have, and the pattern-parameter case raised no signal at all. - -The body is now read off the TypeScript parse of the handler: the statements between its block's own braces, or the expression of an implicit-return arrow. This is the same parse the parameter rebind and the free-identifier check already read. Every shape above now lowers to the handler's own body and runs as the handler does. The `{`-in-a-string handler is lowered instead of bundled. `hook-body-source-unparseable` no longer fires for any of them. - -This applies to both faces, a hook's `handler` and an action's `target`. The action face still gets no parameter rebind. - -**Unchanged.** A handler the old count read correctly lowers to the same bytes, comments and layout included. This was measured over 21,262 function texts from `packages/cli`, `examples` and `packages/qa`, and over the callables the `app-crm` and `app-todo` configs load. No refusal message is reworded. The hook face's separate "could not read the parameter list" refusal is gone: the body and the parameter list are now read off one parse, so a handler that parse cannot read meets the existing "could not parse the body" refusal instead. It is the same kind (`unparseable`) with the same remedy, and no measured handler reached the old one. `extractHookBody`'s published signature and refusal kinds are the same. No spec key, export, type or stored shape moves. diff --git a/.changeset/pre/22861-readable-ids-composition.md b/.changeset/pre/22861-readable-ids-composition.md deleted file mode 100644 index 9075cb2b11c..00000000000 --- a/.changeset/pre/22861-readable-ids-composition.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/core': patch ---- - -feat(objectql): the engine composes a `ReadableIds` leaf at its executor boundary, on the final query immediately before the driver call, and refuses it by name wherever it cannot (ADR-0139 D2, D5) - -Clause-②: no - -- **Composed.** On `find`, `findOne`, `count` and `aggregate`, after the middleware chain and the `beforeFind` hooks, every `{ FIELD: { $in: ReadableIds.of(P) } }` in `where` is replaced by `ReadableIds.compose(leaf, scope)`, where `scope` is the registered security service's `getReadFilter(P, caller)`, marked `'policy'` (`undefined` composes with no row restriction). The comparand becomes `$in: []`, which matches no row, when: - - `canReadObject(P, caller)` is false or throws (a service without `canReadObject` is asked `explain`, and only `allowed: true` admits); - - `getReadFilter` answers `RLS_DENY_FILTER` (by identity) or throws; - - no security service is registered, or the registered one cannot answer both halves (the engine reads the kernel's `security` service through `ObjectQLPlugin` when a leaf is composed, so an engine used without that plugin has none); - - the registry holds no object `P`. - - Each parent object is asked once per read, and nothing is kept across reads. A leaf that a middleware or a `beforeFind` hook writes is composed the same way. -- **Refused, `INTERNAL_ERROR` / 500, before any driver is asked:** - - on `update` and `delete`: the caller's leaf at the comparand door, a middleware's at the executor's entry, before the prior-row reads; - - in `having` and in an aggregation's own `filter`; - - anywhere but a `$in` comparand, including a leaf the array form of `where` lowers into another position; - - a composed leaf the engine did not compose, written by a middleware or a hook (the caller's own, as a `$in` comparand, stays the comparand door's `INVALID_FILTER` / 400); - - ADR-0139 D5's three classes: a delegated (on-behalf-of) caller, a parent with an object-scoped read middleware, and a parent bound to another datasource than the object read. -- **Before:** the comparand door refused every `ReadableIds` leaf `INTERNAL_ERROR` / 500 ("this engine does not compose that leaf yet"), and a leaf that a middleware or a hook wrote reached the driver unrefused. -- **`@objectstack/core`:** `resolveFilterTokens` returns a `ReadableIds` leaf by reference. When the filter also held a placeholder, it used to rebuild the leaf as a plain `{ object }` and drop its brand. -- **Not yet:** no driver evaluates the composed leaf (ADR-0139 E3 and E4), and no code in the platform writes a `ReadableIds` leaf today. diff --git a/.changeset/pre/22868-sandbox-fault-not-unknown-object.md b/.changeset/pre/22868-sandbox-fault-not-unknown-object.md deleted file mode 100644 index 97c37072a6e..00000000000 --- a/.changeset/pre/22868-sandbox-fault-not-unknown-object.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/types': patch ---- - -fix(types): the data door answers the sandbox's own fault as a fault, not as "this object does not exist" (#22868) - -Clause-②: no - -**What was wrong.** The QuickJS runner raises some `SandboxError`s itself. These carry no `innerMessage`. Two of them name the object the hook body addressed: a denied capability on `ctx.api.object(...)` (`capability 'api.read' not granted to hook 'guard' (called ctx.api.object('account').find)`) and a `ctx.api` method the host does not implement (`ctx.api.object('account').find not implemented`). When that object was the one the request named, `mapDataError` read the quoted object name plus the word `not` as an unknown object. `POST /api/v1/data/account` then answered `404 OBJECT_NOT_FOUND` with `Object 'account' is not registered`. Every other runner fault already answered `500 INTERNAL_ERROR`, which is what the runner's contract says these errors are. - -**What changes.** `mapDataError` checks `isSandboxOwnFault` first, next to the existing check for a sandboxed body that crashed. An error the sandbox raised itself now answers `500 INTERNAL_ERROR` with `Internal server error`, whatever its text names. The runner's text goes to the server log only. The sync `/import` row and the `/import/jobs` stored row read that same sentence. - -**Unchanged.** A driver's unknown-object text (`no such table: account`, `relation "account" does not exist`) still answers `404 OBJECT_NOT_FOUND`. So does the same wording thrown by anything other than the sandbox: the check reads the error's producer, not its words. A hook body's refusal keeps its sentence and status, and a crash keeps its `500`. diff --git a/.changeset/pre/22870-field-mail-association.md b/.changeset/pre/22870-field-mail-association.md deleted file mode 100644 index 261762987de..00000000000 --- a/.changeset/pre/22870-field-mail-association.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/driver-sql': patch -'@objectstack/metadata-core': patch -'@objectstack/platform-objects': patch ---- - -An `email` field can declare that it identifies a correspondent for inbound mail: `mailAssociation` - -Clause-②: yes (widening) - -- **`mailAssociation: boolean`** (optional, no default) on a field definition. TRUE marks this email field as identifying a message correspondent: when a mailbox service ingests a message, it associates the message with every record of the object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Absent or FALSE: the field takes no part in matching. -- **The platform learns which fields carry a correspondent's address, never what a contact is.** Every object that flags a field is treated alike; no app code and no resolver service is involved. -- **The door that judges it.** `FieldSchema` refuses the key on any type other than `email`, naming the field's type and the fix (change the type to `email`, or drop the key). Every metadata that parsed before parses unchanged. -- **Who reads it.** Nothing yet: the mailbox service's ingest is the reader, and it ships separately. Until then `objectstack validate` and `objectstack lint` warn an author who sets it (`liveness-planned-property`) that no mailbox service reads it yet. -- **Where to author it.** The field designer offers it on email fields as "Match Inbound Mail". -- **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies `mailAssociation` as `presentation`: the mailbox service reads it, never `createColumn`, so a declaration on a builtin column name is not reported as discarded storage. No behaviour changes. -- **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` classifies `mailAssociation` as `keep`: the FLS scrub of a served object definition passes the flag through unchanged, because it names no field. -- **`@objectstack/platform-objects`.** The field designer's new row carries its label and help text in the metadata-form translation bundles for en, zh-CN, ja-JP and es-ES. diff --git a/.changeset/pre/22870-service-mailbox.md b/.changeset/pre/22870-service-mailbox.md deleted file mode 100644 index 3fb8199f702..00000000000 --- a/.changeset/pre/22870-service-mailbox.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-mailbox': minor -'@objectstack/core': minor -'@objectstack/plugin-security': minor -'@objectstack/plugin-audit': patch -'@objectstack/service-storage': patch -'@objectstack/cli': patch -'@objectstack/verify': patch ---- - -Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability - -Clause-②: yes (widening) - -- **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. - - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. - - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. -- **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: - - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. - - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. - - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). - - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. -- **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. -- **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. -- **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. -- **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. diff --git a/.changeset/pre/22871-imap-mailbox-sync.md b/.changeset/pre/22871-imap-mailbox-sync.md deleted file mode 100644 index c9f55db576c..00000000000 --- a/.changeset/pre/22871-imap-mailbox-sync.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/connector-imap': minor -'@objectstack/service-mailbox': minor ---- - -Inbound mail has a mail source: the new `@objectstack/connector-imap` reads a mailbox over IMAP, and `@objectstack/service-mailbox` polls each mailbox's folder into its ingest seam, keeping a UID cursor on `sys_mailbox` - -Clause-②: yes (a new published package, `@objectstack/connector-imap`, and its exports; new exports and plugin options in `@objectstack/service-mailbox`, and four new fields on its published `sys_mailbox` object) - -- **`@objectstack/connector-imap` (new) — protocol and authentication only.** - - `openImapSession({ account, credentials, timeoutMs?, maxLiteralBytes?, maxCommandBytes? })` opens a read-only IMAP4rev1 session: `examine(folder)` (`EXAMINE`, never `SELECT`; answers `uidValidity`, `uidNext`, `exists`), `listMessages(fromUid)`, `fetchMessages(uids)` (`BODY.PEEK[]`, so no message is marked `\Seen`), `logout()` and `close()`. A folder opened without a UIDVALIDITY is refused. - - The account and credential abstraction is shaped for the per-user mailbox: a `MailAccount` (`{ id, username, imap: { host, port?, security? } }`) and a `MailCredentialProvider` that resolves the account's credential each time a session opens. `staticCredentials(credential)` answers one fixed credential. - - A `password` credential is sent with `AUTHENTICATE PLAIN` when the server offers it, otherwise `LOGIN`; an `oauth2` access token with `AUTHENTICATE XOAUTH2`. A server that refuses `LOGIN` and offers no `PLAIN` is refused before anything is sent. - - `security: 'tls'` (the default) is implicit TLS with the certificate verified. A plaintext connection (`security: 'none'`) is refused to any host but loopback, before a socket opens. - - Failures are `ImapError` with a `kind`: `configuration`, `connection`, `timeout`, `authentication`, `refused`, `protocol` or `oversized`. - - An IMAP server is treated as an untrusted peer. A line above 4 MiB, a literal above `maxLiteralBytes` (64 MiB by default), a response of more than `IMAP_MAX_RESPONSE_LITERALS` (32) literals, and more than `maxCommandBytes` of responses to one command (128 MiB by default) are refused as `oversized` before the excess is kept, and the connection is closed. - - `@objectstack/connector-imap/testing` exports `ImapServerDouble`, an in-process IMAP server on loopback for tests. - - No runtime dependencies. -- **`@objectstack/service-mailbox` — the IMAP sync.** - - `new MailboxServicePlugin({ imap: { accountFor, pollIntervalMs?, batchSize?, maxMessagesPerRun?, maxMessageBytes?, maxBatchBytes? } })` schedules the interval job `mailbox:imap-sync` on the composed job service. For each mailbox `accountFor` answers (asked per mailbox per run; `null` for a mailbox the host does not sync), it fetches the messages above the stored cursor, in UID order, and hands each one to `ingest` under the system context. Dedupe, association, the pool and auto-reply suppression are unchanged. - - Without `imap` — the capability boot constructs the plugin with no options — no mailbox is synced, and the boot says so once. With `imap` but no job service, the boot warns, and `plugin.syncService.syncAll({ isSystem: true })` runs a pass on the host's own schedule. A run in which a mailbox could not be synced reports `degraded` to the job service. - - `sys_mailbox` gains the cursor, written by the sync only: `imap_folder`, `imap_uid_validity`, `imap_last_uid` and `imap_synced_at`. A cursor taken under another UIDVALIDITY, or in another folder, is discarded and the folder is read from its start; the messages already stored answer `duplicate`. No credential is stored on the object: the account and its credential come from `accountFor`. - - The cursor moves after each batch past every message stored, already stored, suppressed or skipped. A store failure stops the run below the failed message, so the next run retries it. A message above `maxMessageBytes` (32 MiB by default) or one the seam refuses as unreadable is skipped and logged at `error` with its UID. - - A batch is bounded by count (`batchSize`, 25) and by listed bytes (`maxBatchBytes`, 64 MiB). The session's literal bound is `maxMessageBytes` plus a 64 KiB framing envelope, so a server that omits or understates a message's size cannot hand over more. That message is skipped as `too_large`, after the batch is read again one message at a time on a fresh session, and the rest of the mailbox keeps arriving. - - A forwarded `message/global` part (RFC 6532, the internationalized `message/rfc822`) is now read as an `attachment` source, the same as `message/rfc822`. - - New exports: `MailboxSyncService`, `MAILBOX_SYNC_JOB`, `DEFAULT_MAILBOX_POLL_INTERVAL_MS`, `DEFAULT_SYNC_FOLDER`, `DEFAULT_SYNC_BATCH_SIZE`, `DEFAULT_SYNC_MAX_MESSAGES_PER_RUN`, `DEFAULT_SYNC_MAX_MESSAGE_BYTES`, `DEFAULT_SYNC_MAX_BATCH_BYTES`, `SYNC_LITERAL_ENVELOPE_BYTES`, and the types `MailboxServicePluginOptions`, `MailboxImapSyncOptions`, `MailboxAccountResolver`, `MailboxImapAccount`, `MailboxSyncTarget`, `MailboxSyncResult`, `MailboxSyncAllResult`, `MailboxSyncSkipReason`, `MailboxSyncServiceOptions`, `MailSyncEngine` and `MailSyncLogger`. - - This supersedes the ingest seam entry's "no mail source feeds the seam": the IMAP sync does. Attachments are still not stored. diff --git a/.changeset/pre/22871-mail-association-live.md b/.changeset/pre/22871-mail-association-live.md deleted file mode 100644 index 74e3c877117..00000000000 --- a/.changeset/pre/22871-mail-association-live.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The `mailAssociation` liveness row is `live`: the mailbox service's matcher reads the key, so authoring `mailAssociation: true` on an email field no longer draws the "no mailbox service reads this yet" author warning - -Clause-②: no (dropping an author warning widens no accept set and no export: `FieldSchema` accepts `mailAssociation` exactly as before; only the warning the shipped liveness ledger carried is gone) - -- `liveness/field.json`: the row moves from `planned` with `authorWarn` and `authorHint` to `live`, citing `MailRecordMatcher` in `@objectstack/service-mailbox` as its reader. The `field` state counts move from 94 live and 4 planned to 95 live and 3 planned. diff --git a/.changeset/pre/22871-mail-ingest-seam.md b/.changeset/pre/22871-mail-ingest-seam.md deleted file mode 100644 index c150d818835..00000000000 --- a/.changeset/pre/22871-mail-ingest-seam.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/service-mailbox': minor ---- - -Inbound mail has an ingest seam: `MailIngestService`, the kernel service `mailbox`, turns one raw RFC 822 message into one `sys_mail_message` row, associates it at ingest with the records whose `mailAssociation` email fields hold a participant's address, and re-matches a mailbox on request - -Clause-②: yes (the package gains an exported ingest entry point it does not export today; a widening of the public surface) - -- **`ingest({ mailboxId, raw, receivedAt? }, context)`** reads one message and answers `stored`, `duplicate` or `suppressed`. Only the system context may call it: a message enters a mailbox from its mail source, never from a user. - - **Participants.** The addresses of From, Sender, Reply-To, To and Cc, plus the same five headers of a message forwarded as an attachment (a `message/rfc822` part of the received message). The mailbox's own address is left out. The set is stored on the row in `match_addresses`, a JSON list of `{ address, source }` where `source` is `outer` or `attachment`. A forward quoted inline in the body is not read yet, and `inline` is declared so a later reader of it can store its addresses. - - **Association.** The message is linked, one `sys_mail_association` row each (`matched_by: 'ingest'`), to every record of every object with an `email` field declaring `mailAssociation: true` whose value equals a participant address (ASCII case-insensitive), inside the mailbox's organization only. A flagged object with no organization column is skipped and logged once. No app code and no resolver service is involved. A message nothing matches is stored `association_state: 'unassociated'`: the restricted pool. - - **Dedupe.** One row per message per mailbox. The key is `mid:` plus the SHA-256 of the Message-ID, or `hdr:` plus the SHA-256 of the header block as received for a message without one. A second fetch answers `duplicate` and writes nothing, and the unique key settles a concurrent pair. - - **Threading.** `message_id`, `in_reply_to` and `reference_ids` hold the RFC 5322 ids with their angle brackets stripped. - - **Suppression.** No row is written for an auto-reply (`Auto-Submitted` other than `no`, `Precedence: bulk`, `junk` or `list`, `X-Autoreply` or `X-Autorespond` other than `no`) or a bounce (`multipart/report; report-type=delivery-status`). -- **`rematch({ mailboxId, messageIds? }, context)`** replays each message's stored participant set against the records that exist now. It adds the missing links (`matched_by: 'rematch'`) and moves a pool message to `associated` once it has one. It never removes a link. A stored set that is not the stored shape is reported in `unreadable` and not matched. The system context may run it, and so may the mailbox's owner and a `manage_mailboxes` holder in the mailbox's organization. Anyone else is refused with `PERMISSION_DENIED` (403), and another organization's mailbox answers `RECORD_NOT_FOUND` (404). -- **New exports:** `MailIngestService`, `MAILBOX_SERVICE`, `MailboxServiceError` (`code` and `status`), `MAIL_MATCH_ADDRESS_SOURCES`, `MAIL_SUPPRESSION_REASONS`, and the types `MailIngestInput`, `MailIngestResult`, `MailRematchInput`, `MailRematchResult`, `MailboxCallerContext`, `MailIngestEngine`, `MailIngestLogger`, `MailAssociationMatch`, `MailMatchAddress`, `MailMatchAddressSource`, `MailSuppressionReason`, `MailboxServiceErrorCode` and `RawMailMessage`. `MailboxServicePlugin` registers the service in `init()`. -- **New dependencies:** `postal-mime` (MIT-0, no dependencies of its own, ES module and CommonJS builds) reads the MIME structure, the encoded words and the address lists. `@objectstack/types`, already installed through `@objectstack/core`, supplies the keyset walk the re-match pages with. -- **Not yet:** no mail source feeds the seam (the IMAP connector ships separately), and attachments are not stored, so a message forwarded as an attachment keeps the forwarder's own text and not the forwarded message's body. diff --git a/.changeset/pre/22876-email-template-category-is-system-retired.md b/.changeset/pre/22876-email-template-category-is-system-retired.md deleted file mode 100644 index ad063561b35..00000000000 --- a/.changeset/pre/22876-email-template-category-is-system-retired.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-email': patch -'@objectstack/platform-objects': patch ---- - -feat(spec)!: retire an email template's `category` and `isSystem` — the grouping and the built-in flag nothing read (#22876) - -**BREAKING** — `category` and `isSystem` on an email template (`EmailTemplateDefinitionSchema`, the `email_template` metadata type) are now refused at parse. Nothing read either key once templates stopped being copied into `sys_email_template` rows: the Studio listing never offered the filter facet `category` promised, and no door refused deleting a template that set `isSystem`. Delete both keys. A template is found by its `name`, so group templates with a dotted namespace prefix (`auth.password_reset`, `crm.welcome`). A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. - -Clause-②: no (narrowing) - -Measured before removal: no reader of either key in `@objectstack/plugin-email`, `@objectstack/service-messaging`, `@objectstack/plugin-auth`, the metadata protocol, ObjectQL, the runtime or the REST layer, and none in the Studio console at its pinned commit beyond a fallback form input, which the console's own companion change removes. Both keys carried a schema default (`category: 'custom'`, `isSystem: false`), so every parsed template carried them and the liveness advisory fired on every template, with no way for an author to clear it. - -## FROM → TO - -| you wrote (17.7 and earlier) | write instead | -| --- | --- | -| `category: 'auth'` (or `'notification'`, `'workflow'`, `'marketing'`, `'custom'`) | nothing — delete the key; group by the `name` prefix | -| `isSystem: true` | nothing — delete the key | -| `isSystem: false` | nothing — delete the key | -| `import { EmailTemplateDefinitionCategorySchema } from '@objectstack/spec/system'` | nothing — the enum is removed with its key | -| `type EmailTemplateDefinitionCategory` | nothing — the type is removed with its key | - -**The one-line fix:** delete `category` and `isSystem` from every email template. - -**What an author who still writes either sees.** `tsc` fails at the authoring site (`EmailTemplateDefinition` types both keys as retired), and the parse — `defineEmailTemplateDefinition()`, `defineStack({ emailTemplates })`, `PUT /api/v1/meta/email_template/:name` — refuses each at its own path with the prescription: - -> `email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). 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. - -> `email_template.isSystem` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. 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 - -- **Two `retiredKey()` tombstones** on `EmailTemplateDefinitionSchema`, a `strictObject` — so the refusal carries the prescription rather than a bare unknown-key report, and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `system/EmailTemplateDefinition:category`, `system/EmailTemplateDefinition:isSystem`. The `category` enum, `EmailTemplateDefinitionCategorySchema`, had no other consumer and leaves with it: `RETIRED_DEFS_BY_MAJOR[18]`: `system/EmailTemplateDefinitionCategory`. -- **The D2 conversion `email-template-category-is-system-removed`** (protocol 18, retired from the load path) deletes both keys from every `emailTemplates[]` entry, one notice per key, as a lossless delete. Because both keys carried a default, every built artifact and every stored `email_template` row written under 17.x carries them; both load through the rehydration seams, which replay this conversion. The retired-default residue stage is not adopted: the replayed conversion heals those populations, and an authored source that still writes either key meets the prescription. -- **The D3 entry `email-template-category-is-system-retired`** asks the author whether anything they built read either key — a report or integration that filtered templates by `category`, or one that treated `isSystem: true` as protection against deletion. Nothing ever enforced that. -- **Studio:** the email-template form drops the `category` select and the `isSystem` checkbox. -- **Ledger:** both rows stay `dead`, re-noted as tombstones (a tombstone keeps the key in the walked shape). -- **No deprecation window**, per the project's startup-stage posture. - -**`@objectstack/plugin-email`:** the six built-in auth templates (in every locale) no longer write `category: 'auth'` or `isSystem: true`. They render and send exactly as before. - -**`@objectstack/platform-objects`:** the metadata-form translation bundles drop the two retired form inputs' labels (`email_template.fields.category`, `email_template.fields.isSystem`). The `sys_email_template` object and its `category` / `is_system` columns are unchanged. - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. - - diff --git a/.changeset/pre/22877-member-selector.md b/.changeset/pre/22877-member-selector.md deleted file mode 100644 index 996ecbafbd2..00000000000 --- a/.changeset/pre/22877-member-selector.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/rest': minor -'@objectstack/spec': minor -'@objectstack/objectql': minor ---- - -feat(metadata-protocol): one language of an email template is read, reset, published, viewed in history and reverted by name — the by-name `/meta` doors accept a bundle member selector (#22877) - -Clause-②: yes - -**What an administrator can now do.** An email template kept in several languages is one item per language in the store. Each language can now be addressed by name on every by-name door, with `?member=` naming the language: - -- `GET /api/v1/meta/email_template/:name?member=zh-CN` reads the `zh-CN` wording: the stored customization when there is one, else the package's `zh-CN` template. A language that nothing stores or ships answers as an absent item (404). -- `DELETE /api/v1/meta/email_template/:name?member=zh-CN` resets the `zh-CN` customization alone; the other languages keep theirs. -- `POST /api/v1/meta/email_template/:name/publish?member=zh-CN` publishes the `zh-CN` draft. -- `GET /api/v1/meta/email_template/:name/history?member=zh-CN` lists the `zh-CN` saves. All languages of one template share one version counter, so the version numbers of one language are not consecutive. -- `POST /api/v1/meta/email_template/:name/rollback?member=zh-CN` reverts the `zh-CN` customization to one of its own versions. A version saved for another language answers `404 VERSION_NOT_FOUND`. - -With no `?member=`, every door addresses the canonical member (`en-US`), exactly as before; naming `en-US` is the same request. This closes the window the previous release's changeset stated: a non-`en-US` language could not yet be reset, published, listed or reverted by name. - -**The selector.** It is named `member`, after what the store keys a row by, and not `locale`: on `/meta`, `?locale=` already chooses the language the response is translated into. The protocol requests gain one optional field, `member`, on `GetMetaItemRequest`, `DeleteMetaItemRequest`, `PublishMetaItemRequest` and `HistoryMetaItemRequest`. A wrong value is refused with `400 VALIDATION_ERROR`, naming the parameter: the selector on a type whose items have no languages (every type except `email_template` today), a blank value, or a value longer than a stored member can hold (100 characters). - -**Each of the five routes now declares its query parameters.** `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` refuse a query parameter they do not read with `400 VALIDATION_ERROR`, naming the parameter and listing the supported ones, instead of ignoring it. A misspelled `?memeber=zh-CN` is therefore refused, not answered with the `en-US` wording. The supported names are the ones each route has always read, plus `member`: - -- `GET /meta/:type/:name`: `layers`, `state`, `preview`, `package`, `locale`, `member`. -- `DELETE /meta/:type/:name`: `state`, `dropStorage`, `member`. -- `GET /meta/:type/:name/history`: `sinceSeq`, `limit`, `member`. -- `POST /meta/:type/:name/publish`: `package`, `member`. -- `POST /meta/:type/:name/rollback`: `toVersion`, `member`. - -A client that sent any other parameter to these routes was having it ignored; it now gets a 400 and can drop it. The deprecated `?layers=` view reads no language, so `?layers=` together with `?member=` is refused rather than answered with the `en-US` layers. A read naming a member skips the shared read cache. - -**What else now follows the language** (`@objectstack/objectql`, `@objectstack/metadata-protocol`): - -- A by-name read that names no language answers the `en-US` template even when only another language is customized. Before, the registry's read of the name could answer the other language's customization. -- Resetting one language keeps the other languages' customizations served at once, not only after the next restart. -- Publishing, discarding and reverting a package's changes (`POST /packages/:id/publish-drafts`, discard, and commit revert) address each language's draft or change as its own item. Before, a package whose only pending draft was a second language failed to publish (`NO_DRAFT`), and a discard reported that draft discarded while it stayed. The draft list and the commit timeline keep the fields they declare. -- In a cluster, a save, reset or revert of one language converges the other servers on that language. - -**For code that uses these packages directly.** Every addition below is optional; code that does not pass it behaves as before, except the bare-name read noted in the first item. - -- `SchemaRegistry` (`@objectstack/objectql`) takes an optional last argument, `member`, on `getItem`, `getArtifactItem`, `removeRuntimeShadow` and `removeOverlayEntry`. It is the discriminator value of one bundle member, such as `'zh-CN'`. - - `getItem(type, name, packageId, member)` answers that member, in the usual order (customization, then the requested package, then any package), or `undefined`. With no `member`, `getItem` now answers the canonical (`en-US`) member of a bundle. A bundle with no `en-US` member still answers its first member. - - `getArtifactItem(type, name, packageId, member)` answers that member's packaged definition. - - `removeRuntimeShadow(type, name, member)` and `removeOverlayEntry(type, name, member)` remove that member's customization entry only. With no `member`, they remove the whole bundle's customization entries, as before. - - A type whose items have no languages ignores the argument. -- `MetadataMutationEvent` (`@objectstack/metadata-protocol`) has an optional `member`: the language the mutated row belongs to. It is present only for a non-`en-US` language. Listeners registered with `onMetadataMutation` receive it. It also rides `ClusterMetadataMutationPayload`, and a server that receives it re-reads that language's row. -- The entries that `SysMetadataRepository.listDrafts` returns have an optional `member`, present for a draft of a non-`en-US` language. The protocol's draft list (`GET /meta/_drafts`) and a package's commit timeline serve it too, on every entry of a template, with the canonical `en-US` spelled out. diff --git a/.changeset/pre/22877-overlay-locale-member.md b/.changeset/pre/22877-overlay-locale-member.md deleted file mode 100644 index 24d8960af22..00000000000 --- a/.changeset/pre/22877-overlay-locale-member.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch -'@objectstack/metadata-core': patch ---- - -fix(metadata-protocol): two locales of one email template saved through the metadata door both survive a restart — an overlay row is keyed by its bundle member (#22877) - -Clause-②: no - -**What an administrator sees.** Saving the same email template in two languages through the metadata door (`PUT /api/v1/meta/email_template/:name`, the Studio editor's save) keeps both. Before, the store kept one overlay row per template name, so the second language's save replaced the first language's row: both read back until the next restart, and then the first language mailed the package wording again. Each language now keeps its own wording across restarts, and each send in that language uses it. - -**What changed in the store.** - -- `sys_metadata` gains one nullable column, `bundle_member`. It holds the member of a metadata type whose identity is a pair — today `email_template`, identified by `(name, locale)`. NULL is the canonical member (`en-US`, the locale a send falls back to). Every other row stays NULL, so rows of every other type, and an `en-US` template row, are byte-identical to before. -- The value is taken from the saved body's `locale`; it is never authored. A body that declares no `locale` is the `en-US` member. -- The overlay uniqueness index becomes `(type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, ''))`, for active rows and for draft rows, and the declared index in `metadata-core` carries the column too. A second row for the same template, language, package and state is refused by the index. - -**What a read or a door addresses.** A save addresses the language its body names. A read, delete, publish, history or revert by name that names no language addresses the `en-US` member: `GET /meta/email_template/:name` serves the `en-US` wording (the stored one when it was customized, else the package's), where before it served whichever row the name had. The layered list and the send path serve every language as before. A non-`en-US` language's own customization is reached through the list, and is replaced by a save that names its `locale`; a by-name reset (`DELETE`) removes the `en-US` customization only. The lineage counter stays one per template name, so a version number names one saved snapshot, and a revert by name restores only a version of the `en-US` member. Resetting, publishing or reading a non-`en-US` language by name needs the by-name member selector, which arrives with stage 2 of #22877; until then those doors address the `en-US` member. - -**What happens on the first boot after upgrading.** Nothing to configure. - -1. The schema sync adds the `bundle_member` column. -2. Boot hydration gives every `email_template` row stored before the column existed the member its body declares, so a row an earlier save left holding a second language's wording becomes that language's row, not the `en-US` one. Rows of every other type are not read or written. -3. The first metadata write rebuilds the overlay index with the new key, through the existing probe-then-replace order. Adding a key part only relaxes uniqueness, so no rows a store already holds can block the rebuild; a store that was already blocked is reported as before, with the duplicate-listing query and `os migrate duplicates`. - -**On a database without partial indexes (MySQL).** The rebuild above is refused there, as before, and reported in the server log at the first metadata write. A store that booted an earlier version keeps the four-column declared index under `idx_sys_metadata_overlay_active`, because the schema sync skips an index whose name already exists. That index has never constrained an environment-wide overlay row: those rows store no organization, and a UNIQUE key does not collide NULLs. So two languages of one template coexist there too, and no save is refused. `os migrate plan` lists the declaration change on that index as a destructive `recreate_index`. To bring the index to the five-column declaration, run `os migrate apply --allow-destructive`. A fresh store gets the five-column declaration. - -`os migrate duplicates` boots read-only and adds no column. Run against a store this version has not booted yet, it lists the two overlay indexes with every row read as the empty member — the value the column will hold — so a duplicate that will block the rebuild is still reported, and the entry's `duplicateSql` is the member-less statement that ran there. diff --git a/.changeset/pre/22885-sandbox-own-fault-rest-write-routes.md b/.changeset/pre/22885-sandbox-own-fault-rest-write-routes.md deleted file mode 100644 index 523fb2c9933..00000000000 --- a/.changeset/pre/22885-sandbox-own-fault-rest-write-routes.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -A sandboxed hook or action that fails on the sandbox's own fault (a capability it was not granted, a `ctx.api` method that is missing or unavailable, a CPU budget or wall-clock ceiling, an install or marshal failure) is now answered by the hand-built write routes the way `POST /api/v1/data/:object` answers it: a withheld `500` with `Internal server error`. The runner's diagnostic text, which names the hook, the object and the capability, goes to the server log only. - -Clause-②: no - -Before, 23 write routes relayed that text to the caller: `POST /analytics/dataset/query`, `POST /security/explain`, `POST` and `DELETE /data/:object/:id/shares`, the three sharing-rule writes, the two suggested-binding writes, the nine approval-request writes, `POST /packages/publish` and the four `POST /datasources/:name/external/*` routes. Most of them answered `500` with their own fault code, and the four external routes answered `400`. Twenty-one of them now answer `500 INTERNAL_ERROR`, the answer each already gave a sandboxed body that crashed. The record-share routes (and the share list, `GET /data/:object/:id/shares`) keep their own `500` and `SHARE_*_FAILED` code, as they do for a crash, and withhold the sentence the same way. - -A refusal a sandboxed body throws keeps its own sentence and status on every route. No key, default, enum member or export moves. diff --git a/.changeset/pre/22886-functions-key-keeps-authored-name.md b/.changeset/pre/22886-functions-key-keeps-authored-name.md deleted file mode 100644 index c4ecb5cb19e..00000000000 --- a/.changeset/pre/22886-functions-key-keeps-authored-name.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `objectstack build` ships every `functions` name under its own name, bound to its own code, and every hook and action ref resolves to its own code (#22886) - -Clause-②: no - -The runtime resolves a hook's `handler`, an action's `target` and a `functions` key against one namespace, filled from the runtime module (`objectstack-runtime.*.mjs`). The build and the module used to name its entries twice, and the two disagreed: - -- **A `functions` key that shared its function with a hook or an action was lost.** `functions: { score: tidy }` beside a hook whose handler is `tidy` shipped as `functions: { tidy_hook: 'tidy_hook' }`, and two keys bound to one function shipped as one. -- **A `functions` key equal to a hook's name, or to an action's `OBJECT_ACTION` ref, was re-keyed and its hook ran the wrong code.** The key shipped as `NAME__2`, a bare string with no callable after load, while the hook's ref resolved to the `functions` entry's code. -- **An action's handler never reached the runtime module**, collision or not, so a script action whose handler could not be lowered to a `body` shipped with a ref that resolved to nothing. -- **A `functions` map exported by name** beside the default export shipped as bare strings: the module read the default export only. - -Now an authored `functions` name (a map key, or an array entry's `name`) is always its own ref: it ships under that name and runs its own code. A hook or action ref is internal, so on a collision it is the one that takes a numeric suffix (`hooks[i].handler: 'NAME__2'`); the hook's `name` is unchanged. A hook or action bound to the same function as a `functions` entry re-uses that entry's name. The runtime module is generated from the build's own ref → callable map, locating each callable in the loaded config by identity, so it serves every ref the artifact names. A stack with no collision ships the same artifact refs as before. - -No action is needed: rebuild to pick up the fix. An artifact built before this release keeps the old names until it is rebuilt. diff --git a/.changeset/pre/22892-predicate-related-master-detail.md b/.changeset/pre/22892-predicate-related-master-detail.md deleted file mode 100644 index 94a1bcb4983..00000000000 --- a/.changeset/pre/22892-predicate-related-master-detail.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -fix(objectql)!: a validation rule that reads its master through a master-detail field asks the caller's read door for the header first — a header the caller cannot read is unavailable to the rule as a header that exists nowhere is (#22892) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -A detail write answers a header the caller cannot read exactly as a header that exists nowhere: its `parent` binding and its reference check ask the caller's read door first (the companion entry for #22519). A validation rule on the detail that reads the header through the master-detail field (`record.invoice.status == 'locked'`) still read the header elevated, and rules run before the reference check, so that rule could still answer by what an unreadable header holds. - -**FROM.** For a non-system caller who may write the detail but cannot read its header, a rule reading the header through the master-detail field judged the header's stored values. Measured with a rule refusing a line under a locked invoice: a header id that exists nowhere was refused with `_record` `rule_violation` ("the related record was not found"); an unreadable open header passed the rule and was refused `reference_not_found`; an unreadable locked header was refused with the rule's own message. Three answers, one bit of the header's state. - -**TO.** Through a `master_detail` field the rule's related read takes the write's two reads: - -1. The caller's read door, under the caller's own context. A header it does not return (hidden by row scope, a refused read of the header object, or a failed read) is unavailable to the rule exactly as a header that exists nowhere is: the rule cannot be evaluated and the write is refused `400 VALIDATION_FAILED` at `_record` (`rule_violation`, "the related record was not found"), whatever the header holds. -2. The elevated, tenant-kept read of the columns the rule names, only for the headers step 1 returned. A header the caller can read is still judged on its STORED values, a column field-level security hides or masks for the caller included. - -**Bindings.** - -- Every write door that runs the rule: `insert` (single and batch), `insertMany`, update by id (a repoint included), the predicate (bulk) update, and the `validate()` preview. -- System callers (`isSystem`: seed replay, boot, package install) ask no read door and are judged exactly as before. -- A rule reading through a plain `lookup` is unchanged. -- No new error code and no status change: the answer is the one a header that exists nowhere already got. -- Cost: one id-projected read under the caller's context per master-detail field a rule reads through, once per write batch. - -**Fix, if a write relied on the old answer:** give the writer read access to the header (an object permission or sharing rule on the master), or declare the detail `controlled_by_parent` so the header's access governs it. - -**Not measured.** How many deployed detail objects carry a rule reading their master was not measured: the rule only tightens, so it holds whatever that answer is. diff --git a/.changeset/pre/22902-stack-functions-array-duplicate-name-refused.md b/.changeset/pre/22902-stack-functions-array-duplicate-name-refused.md deleted file mode 100644 index 377e5a1d7e3..00000000000 --- a/.changeset/pre/22902-stack-functions-array-duplicate-name-refused.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: `defineStack` refuses two entries of one stack's `functions` array that author the same `name`, naming the name and every index (#22902) - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. - -Functions are one flat namespace resolved by name: a hook's `handler`, an action's `target` and a flow `script` node's `config.function` all look a function up by its name. The `functions` **array** form could author one name twice, and nothing refused it. Measured on `main` at this change's base with `functions: [{ name: 'kc_a', handler: first }, { name: 'kc_a', handler: second }]`: - -- `objectstack validate` exited 0 (`valid: true`); -- `objectstack build` exited 0 and shipped `[{ name: 'kc_a' }, { name: 'kc_a__2' }]`: the second entry went out under a suffixed internal ref the author never wrote and nothing references, and `kc_a` ran `first`; -- a source boot (`objectstack serve`, no build) registered `kc_a` as `second` and dropped `first`, with no notice. - -So the same stack ran different code under `kc_a` depending on whether it was built. `composeStacks` already refused one name defined by two stacks (`STACK_COMPOSE_FUNCTION_CONFLICT`); this is the one-stack case. - -**What is refused.** `defineStack`'s cross-reference pass refuses the stack with its existing envelope (`STACK_CROSS_REFERENCE_INVALID`, status 422, `defineStack cross-reference validation failed (N issues):`), one finding per colliding name, naming the name and every index: - -```text -✗ Function 'kc_a' is declared twice in the `functions` array: stack.functions[0] and stack.functions[1]. … -``` - -It runs in an object-less stack too, and every entry counts, byte-identical twins included. `objectstack validate`, `objectstack build` and a source boot all load the config through `defineStack`, so all three refuse it at load, printing that line: `objectstack validate --json` answers `valid: false` with `code: 'STACK_CROSS_REFERENCE_INVALID'` (exit 1), `objectstack build` exits 2, and `objectstack serve` exits 1 before the kernel boots. Like every cross-reference check it is skipped under `defineStack(…, { strict: false })`. - -**Unchanged.** The map form, which cannot author one name twice; an array whose names are distinct; the suffixed refs a build emits for an internal slot (`kc_a__2` beside `kc_a` is two names); and the cross-stack refusal in `composeStacks`. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `functions: [{ name: 'sync', handler: a }, { name: 'sync', handler: b }]` | `functions: [{ name: 'sync', handler: a }, { name: 'sync_backup', handler: b }]`, and point each hook, action or script node that meant `b` at `sync_backup` | -| two entries that are the same function under one name | one entry: delete the other | - -**The one-line fix: give each `functions` array entry its own `name`, or merge the duplicates into one entry.** - -No D2 conversion rewrites it: which entry keeps the name, and what every caller of the other one should name instead, is the author's call. Its D3 record is the semantic entry `stack-functions-array-duplicate-name-refused` (protocol 18). - -**Who is affected, measured.** On `main` at this change's base, no example app, fixture or test authors one name twice in a `functions` array: the four example apps author the map form or none, and every array-form fixture under `packages/` names distinct entries. `objectstack validate --json` on `examples/app-crm`, `app-todo`, `app-showcase` and `app-multi-package` answers byte-for-byte as it did without the check, apart from the `duration` value. Deployed metadata and other repositories were not measured. diff --git a/.changeset/pre/22908-multi-reference-element-shape.md b/.changeset/pre/22908-multi-reference-element-shape.md deleted file mode 100644 index 5a23fbca0e1..00000000000 --- a/.changeset/pre/22908-multi-reference-element-shape.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -fix(objectql)!: each element of a multi-valued `lookup` / `user` / `file` / `image` field is held to the single value's stored form — an embedded record, a boolean or a number as an element answers `invalid_type` (#22908) - -Clause-②: yes (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -**FROM.** On a `lookup`, `user`, `file` or `image` field declared `multiple: true`, the record validator checked that the value was an array and stopped there. So an element of the wrong shape was stored: - -- REST `POST` / `PATCH /api/v1/data/:object`, as a signed-in caller: an embedded record `[{ id }]` answered `201` / `200` and was stored verbatim. `[false]` and `[42]` were refused only by the reference-existence check, as `reference_not_found`. -- A system write (`isSystem`: seed replay, hooks, package install): `[false]`, `[42]` and `[{ id }]` were all stored verbatim. -- The single-valued field refused the same three `invalid_type` on both doors. - -**TO.** Each element is parsed against the single value's stored form (ADR-0104 D1, `valueSchemaFor`): a record id for `lookup` / `user`, an opaque `sys_file` id for `file` / `image`. The deployment's value-shape posture decides, the same posture a single value answers to: - -- **Strict** (a fresh store is born attested; or after `os migrate value-shapes --apply` for references, `os migrate files-to-references --apply` for media; or `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`): the write is refused `400 VALIDATION_FAILED`, with a `fields[]` entry `{ field, code: 'invalid_type' }` whose detail names the index of the wrong element (`at index 1: …`). -- **Warn-first** (an unverified deployment, or `OS_ALLOW_LAX_VALUE_SHAPES=1` / `OS_ALLOW_LAX_MEDIA_VALUES=1`): the write is admitted, logged and reported as an admitted ADR-0104 violation, exactly as a single value is. -- The shape check runs for a system write too. Whether a referenced id EXISTS is unchanged: a system write still skips that check. -- `os migrate value-shapes` already counted these elements; its report detail now names the index too, because the scan and the write path share one reading. - -**Fix, if a write relied on the old answer:** write the ids as the elements, never the record a read with `$expand` returns — `team: ['acc_1', 'acc_2']`, not `team: [{ id: 'acc_1', name: 'Acme' }]`. To find stored rows that hold such an element, run `os migrate value-shapes` (a dry run writes nothing). diff --git a/.changeset/pre/22911-blank-prefixed-project-name.md b/.changeset/pre/22911-blank-prefixed-project-name.md deleted file mode 100644 index 4199774d73b..00000000000 --- a/.changeset/pre/22911-blank-prefixed-project-name.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"create-objectstack": patch ---- - -fix(create-objectstack): a project name that starts with the template's namespace scaffolds instead of being refused - -Clause-②: no - -A project whose namespace begins with the template's own namespace plus `_` (for example `blank-probe`, namespace `blank_probe`, under the `blank` template) was refused with exit 1: "1 object name(s) still carry the template's 'blank_' prefix … This is a bug in the scaffolder". The rewrite was correct (`blank_note` became `blank_probe_note`), but the check that verifies it tested each name against the template prefix alone, and `blank_probe_note` starts with `blank_`. - -- The check now reports a name as stale only when it carries the template's prefix and not the new namespace's prefix, so `blank-probe` scaffolds with objects named `blank_probe_*`. -- A name the rewrite really missed (`blank_note` under namespace `blank_probe`) is still refused, as before. -- The same rule applies to every template namespace; no name is treated specially. -- No migration: a project that scaffolded before this release is unaffected, and a name that was refused before can be scaffolded again. diff --git a/.changeset/pre/22912-scaffold-vitest-range-npm10.md b/.changeset/pre/22912-scaffold-vitest-range-npm10.md deleted file mode 100644 index 1e5824f1edc..00000000000 --- a/.changeset/pre/22912-scaffold-vitest-range-npm10.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/cli": patch ---- - -fix(cli): plugin scaffolds declare `vitest ~4.0.0`, so `npm install` works on npm 10 - -Clause-②: no - -`objectstack init -t plugin` and `objectstack create plugin` now declare `vitest ~4.0.0` instead of `^4.0.0`. With `^4.0.0`, a fresh `npm install` of the emitted project crashed on npm 10, the npm that Node 22 bundles, with `Cannot read properties of null (reading 'edgesOut')`. npm 11 was not affected. - -The cause is upstream. vitest 4.1.x accepts vite 8, and vite 8's optional devtools peers lead back to a `vitest@*` peer that npm 10 cannot resolve against an older vitest at the root. vitest 4.0.x accepts only vite 6 or 7, which carry no such peer. A scaffolded project now installs vitest 4.0.18 with vite 7.3.7 on npm 10 and npm 11. - -Projects that were already scaffolded are unchanged. If one of them fails to install on npm 10, change its `vitest` devDependency to `~4.0.0`, or install with npm 11. diff --git a/.changeset/pre/22913-audit-provision-failure-error.md b/.changeset/pre/22913-audit-provision-failure-error.md deleted file mode 100644 index c2ba43fbd1d..00000000000 --- a/.changeset/pre/22913-audit-provision-failure-error.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -A failed boot provisioning of an audit table is logged at `error`, naming what is lost and the fix - -Clause-②: no - -At `kernel:ready`, `AuditPlugin` creates the storage for `sys_audit_log`, `sys_activity`, `sys_comment` and `sys_comment_reaction`. When the driver refuses one of those DDL statements, the boot still completes and the deployment looks healthy, but the table does not exist. For `sys_audit_log` and `sys_activity` the loss is silent: audited writes keep succeeding while their audit and activity rows have nowhere to land. That failure was logged at `warn`. It is now logged at `error`, once per object per boot and never per write. The boot is still not failed. - -- **What the line says.** It names the object, what is lost without it, and that the deployment keeps looking healthy. It gives the fix: resolve the driver's refusal, then restart (this provisioning runs on every boot) or run `os migrate apply`, which creates the missing storage. `os migrate plan` lists it as a `create_table` entry for that object. Both remedies were measured to create a missing `sys_activity` once the fault was gone, with and without the telemetry sibling database. -- **Where the driver's error is.** In the record's meta as `{ object, error }`, not in the message, so a multi-line driver error stays one record. -- **What is unchanged.** The other objects are still provisioned when one fails, and the placement lines (`system tables provisioned`, the non-default datasource note) are unchanged. A log search on `could not provision sys_activity storage` (or any of the four object names) still matches the line. diff --git a/.changeset/pre/22934-stack-collection-duplicate-entry-refused.md b/.changeset/pre/22934-stack-collection-duplicate-entry-refused.md deleted file mode 100644 index 9906023330a..00000000000 --- a/.changeset/pre/22934-stack-collection-duplicate-entry-refused.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata': patch ---- - -feat(spec)!: `defineStack` refuses two entries of one named collection under one identity, naming the collection, the identity and every index — for 27 collections (#22934) - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. - -The runtime registers the entries of these collections under one identity each, and a stack could author two entries under one identity with nothing refusing it: `defineStack` and `objectstack validate` accepted the pair (exit 0, `valid: true`, no finding naming it). Measured on a source boot (`objectstack serve`, no build) and on `objectstack build` followed by an artifact-backed boot: - -- `objects`, `pages`, `dashboards`, `reports`, `datasets`, `flows`, `jobs`, `emailTemplates`, `books`, `positions`, `permissions`, `capabilities`, `sharingRules`, `webhooks`, `mappings`, `analyticsCubes`, `connectors`, `picklists`, `tools`, `skills`, `views`, `apis`, `apps` and `objectExtensions` kept only the LAST entry on both paths, with no notice at the default log level (for `apis`, the first entry's path answered 404); -- `datasources`: the metadata described the last entry while the driver connected with the first one's configuration; -- `hooks`: the metadata listed one hook while both handlers were bound and ran; -- `docs`: `objectstack validate` and `objectstack build` refused the pair (`docs/duplicate-name`), while a source boot kept the last. - -**What is refused.** `defineStack`'s cross-reference pass refuses the stack with its existing envelope (`STACK_CROSS_REFERENCE_INVALID`, status 422, `defineStack cross-reference validation failed (N issues):`), one finding per collision, naming the collection, the identity and every index: - -```text -✗ Datasource 'crm_external' is declared twice in `datasources`: stack.datasources[0] and stack.datasources[1]. … -``` - -The identity is what the runtime keys an entry by: its `name`; the `(name, locale)` pair for `emailTemplates` (one name across several locales is an i18n bundle, and an entry with no `locale` counts as `en-US`); the extended object (`extend`) for `objectExtensions`; and for `views` the object a container binds to (`object`, else `list.data.object`, else `form.data.object`, else its `name`). The check runs in an object-less stack too, and every entry counts, byte-identical twins included. `objectstack validate`, `objectstack build` and a source boot all load the config through `defineStack`, so all three refuse it at load. Like every cross-reference check it is skipped under `defineStack(…, { strict: false })`. On an `app` package, two apps under one name now get this refusal, which runs before the single-app count. - -**Unchanged.** Distinct identities; an email-template i18n bundle (one name, one entry per locale); the map form under distinct keys (two keys whose entries spell one explicit `name` are one identity, and are refused); the same name reused by another package of a multi-package project (`composeStacks(…, { manifest: 'preserve' })` keeps each package's collections in its own `packages[i]` body, which this check does not read); `plugins` (last-one-wins with a warning is the kernel's declared contract), `agents` (withdrawn for app packages) and `picklistExtensions` (merged by option value); and the existing `actions` and `functions` refusals. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `pages: [{ name: 'home', … }, { name: 'home', … }]` (any of the 24 name-keyed collections) | one entry per name: rename one (`home_admin`) and every reference that meant it, or merge the two into one entry | -| `emailTemplates: [{ name: 'acme.welcome', … }, { name: 'acme.welcome', locale: 'en-US', … }]` | one entry per name and locale: give the second its own `locale` (`zh-CN`), or merge the two | -| `datasources: [{ name: 'ext', config: a }, { name: 'ext', config: b }]` | one datasource per name — the driver used to connect `a` while the metadata described `b` | -| `hooks: [{ name: 'audit', object: 'a', … }, { name: 'audit', object: 'b', … }]` | one hook with `object: ['a', 'b']`, or two hooks with two names | -| `apis: [{ name: 'feed', path: '/api/v1/apps/x/a' }, { name: 'feed', path: '/api/v1/apps/x/b' }]` | give each endpoint its own name | -| `views: [defineView({ object: 'task', list }), defineView({ object: 'task', form })]` | one container per object: `defineView({ object: 'task', list, form })` (named views go in `listViews` / `formViews`) | -| `objectExtensions: [{ extend: 'task', fields: { a } }, { extend: 'task', fields: { b } }]` | one extension per target: `{ extend: 'task', fields: { a, b } }` | - -**The one-line fix: give each entry of a collection its own identity, or merge the duplicates into one entry.** - -No D2 conversion rewrites it: which entry keeps the identity, and what the other one becomes, is the author's call. Its D3 record is the semantic entry `stack-collection-duplicate-entry-refused` (protocol 18). - -**`@objectstack/metadata`.** `deriveViewContainerObject` — the "which object does a `views` container bind to" derivation the registrars file containers by — moved into `@objectstack/spec` (exported from its root and `./ui`), because the stack contract now judges the same slot. `@objectstack/metadata` re-exports that one declaration under the same name, from its root and from `@objectstack/metadata/view-container`; behaviour is unchanged. That subpath used to import nothing and now loads the `@objectstack/spec` root entry, which every in-repo importer of it already loads. - -**Who is affected, measured.** The four example apps (`examples/app-crm`, `app-todo`, `app-showcase`, `app-multi-package`) carry no duplicate under these identities. Deployed metadata and other repositories were not measured. diff --git a/.changeset/pre/22937-cli-ceremony-promotions.md b/.changeset/pre/22937-cli-ceremony-promotions.md deleted file mode 100644 index 7dd2aa630e1..00000000000 --- a/.changeset/pre/22937-cli-ceremony-promotions.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -feat(cli): `os migrate organization-ownership --apply` promotes customized email templates and moves the settings global rung (ADR-0131 D10) - -Clause-②: yes (widening) - -Two steps join the ceremony, after the authored catalog conversion and before the verified id→name rewrite. Each is checkpointed and resumable like the others. - -- **`template-promotion`** (ruling C on ADR-0131 §6 Q1). Every `sys_email_template` row the mirror deletion keeps for it (customized, or with `customized` unknown, and every row no seeder wrote: `managed_by` NULL or other than package / platform / system, with or without an organization) becomes an environment `email_template` item through the metadata door, one per `(name, locale)`, before mirror deletion removes the rows. It runs under every posture. Seeded, uncustomized copies are not promoted. A promotion that already landed is skipped. The step stops, keeping every row of the template, in four cases: - - a conflict: rows of one `(name, locale)` that differ. Each conflict is listed in the plan and in the `--apply` report, with every candidate's row id and organization. It counts as remaining and holds back `verified_at` until the operator chooses one row with the new repeatable flag `--template-choice ROW_ID`. A choice that names no candidate, or two candidates of one conflict, is refused before anything is written. The chosen body is promoted, and the other candidates are deleted only after that promotion is verified; - - a customized or authored row with no name; - - an environment template that already exists and differs, since the environment's edit is never overwritten; - - a door refusal. -- **`settings-move`** (ADR-0131 D7). Every `sys_setting` row with `scope = 'global'` and no organization is copied into `sys_platform_setting` by the database itself (`INSERT … SELECT`), so `value` and the `value_enc` secret handle move verbatim. The destination row is read back, and only when it equals the source does the row leave `sys_setting`. A key `sys_platform_setting` already holds with the same value is only removed from `sys_setting`. The step stops, moving nothing for that key, in two cases: - - `sys_platform_setting` already holds the key with a different value (the one the runtime serves); - - `sys_setting` holds differing rows for the key. - -The `global-rung-move` departure is now executed, so it reads zero after the run and no longer holds back `verified_at`. The `declared-rule-mirrors` departure still counts until the declared-rule seeder is retired. - -Mirror deletion now also deletes the promoted template rows, and only those. Before its first template delete it runs the promotion pass again, so a row customized or authored between runs (on a resumed ceremony) is promoted, not deleted. It then deletes the seeded, uncustomized mirrors and, by id, the rows whose environment copy that pass verified. Any other promotion row stays and blocks. The apply report gains `movedOut` / `movedIn` per table, and row counts reconcile as before − deleted − moved out + moved in. diff --git a/.changeset/pre/22937-cli-organization-ownership-apply.md b/.changeset/pre/22937-cli-organization-ownership-apply.md deleted file mode 100644 index 4b2c410f716..00000000000 --- a/.changeset/pre/22937-cli-organization-ownership-apply.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -feat(cli): `os migrate organization-ownership --apply` — the v18 organization-ownership ceremony's apply step and post-check (ADR-0131 D10, ceremony items 2 to 4) - -Clause-②: yes (widening) - -The command that planned the ceremony now performs it. Without `--apply` nothing changes: the read-only plan, written to a file. With `--apply`: - -- **The backup comes first.** It is the operator's act: pass `--backup-taken`, or answer yes at the prompt. A non-interactive run without the flag is refused before the database is opened. -- **The fates run in order**, each step stopping the run where it meets a blocker: - 1. **Attribution** — every NULL `organization_id` row the plan derives an owner for takes it (a parent, subject or holder anchor; under `single`, the Default Organization). A row with no derivable owner is never assigned one: it stays NULL and is listed by id. - 2. **Authored catalog rows** — permission sets and positions an organization authored in Setup become environment metadata through the metadata door (under `single`; under a wall they are reported), with each position's binding rows carried into its `permissionSets`. - 3. **The verified id→name rewrite** — every `sys_user_permission_set` grant names its set in `permission_set`, then every grant is verified to name a set the security catalog resolves. `permission_set_id` stays. - 4. **Mirror deletion** — only after that verification, read again just before the first delete: the seeded catalog copies (per organization and NULL residue), the binding rows, seeded capabilities and seeded templates. Authored, customized and organization-stamped rows stay. - 5. **Column drops** — `organization_id` leaves the deployment-level objects through the schema-drift ceremony (`os migrate apply`'s machinery), never a hand `ALTER`. A table whose rows still need another step keeps its column, and the run says why. -- **Idempotent and resumable.** Every write is conditioned on the state it changes, and each finished step is checkpointed in `sys_migration` (row `adr-0131-organization-ownership`); a run interrupted mid-step resumes at that step and never re-derives an owner already written. -- **The post-check** re-reads the plan and reports, per table, what remains. The ledger row records `applied_at` once every step has finished and `verified_at` when the post-check also reads zero discrepancies. Exit code 1 while the ceremony is unfinished or the post-check finds a discrepancy. -- A JSON report is written beside the plan (`organization-ownership-apply-TIMESTAMP.json`, or `--out`); an existing file is never overwritten. - -```bash -os migrate organization-ownership --out plan.json # read the plan first -os migrate organization-ownership --apply --backup-taken # then perform it -``` diff --git a/.changeset/pre/22937-core-email-template-row-definition.md b/.changeset/pre/22937-core-email-template-row-definition.md deleted file mode 100644 index 18aba45eb4e..00000000000 --- a/.changeset/pre/22937-core-email-template-row-definition.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/core': minor ---- - -feat(core): `emailTemplateDefinitionFromRow` — a `sys_email_template` row as the `email_template` definition it carries (ADR-0131 D10, ruling C on §6 Q1) - -Clause-②: yes (widening) - -A new pure export beside `permissionSetDefinitionFromRow` and `positionDefinitionFromRow`: one row in, one body out, with no engine and no I/O. Its one import is the spec's floor-locale constant. The upgrade ceremony (`os migrate organization-ownership --apply`) uses it to promote a customized or admin-authored template row to an environment `email_template` item through the metadata door before the row is deleted. It runs only in that ceremony, never at boot. - -The mapping: `body_html` → `bodyHtml`, `body_text` → `bodyText`, `from_address` / `from_name` → `fromOverride`, `reply_to` → `replyTo`, `variables_json` → `variables`, and `active` keeps its stored meaning. `label` falls back to the name and `locale` to `EMAIL_TEMPLATE_FLOOR_LOCALE` (`en-US`). The retired `category` / `is_system` columns and every provenance or state column are left out. It does not validate; the door does. - -Nothing existing changes. diff --git a/.changeset/pre/22937-core-row-definition-mappers.md b/.changeset/pre/22937-core-row-definition-mappers.md deleted file mode 100644 index e302bda0e5b..00000000000 --- a/.changeset/pre/22937-core-row-definition-mappers.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/core": minor ---- - -feat(core): two pure row → definition mappers for the ADR-0131 upgrade ceremony (`permissionSetDefinitionFromRow`, `positionDefinitionFromRow`) - -Clause-②: yes (widening) - -- **What is new.** `permissionSetDefinitionFromRow(row)` turns one `sys_permission_set` row into the permission-set definition it carries, and `positionDefinitionFromRow(row)` turns one `sys_position` row into the position definition it carries. The row types `PermissionSetRowForDefinition` and `PositionRowForDefinition` are exported with them. Both sit beside `convertPositionBindingRows`. The upgrade ceremony (`os migrate`) is to apply them, to save Setup-authored rows through the metadata door. ⛔ Neither runs at boot. -- **The permission-set mapping.** The JSON-text facet columns become the camelCase body keys: `object_permissions` → `objects`, `field_permissions` → `fields`, `system_permissions` → `systemPermissions`, `row_level_security` → `rowLevelSecurity`, `tab_permissions` → `tabPermissions`, and `admin_scope` → `adminScope` only when the column is truthy and reads to a truthy value. `label` falls back to the name only when the column is `null` or absent; an empty-string label is kept (unlike the position rule below). A facet column that is `null`, absent, an empty string, JSON `null` or unparseable text answers its default; a facet a driver already parsed is kept as it is. Only the definition columns are read, so `active`, `managed_by`, `customized` and the timestamps never enter the body. The body is then filtered to the keys `PermissionSetSchema` declares. -- **The position mapping.** `{ name, label, description, delegatable }`: `label` falls back to the name unless it is a non-empty string (so `''` is replaced by the name). `description` travels only when it is a non-empty string: `null`, `''`, an absent column and a non-string are absent, and a whitespace-only string travels as stored. `delegatable` is `true` only for a stored `true`, `1`, `'1'` or `'true'`. `active` and `is_default` are row state and never travel. -- **No behaviour changes.** Both functions are pure (one row in, one body out). They produce the same output as the security plugin's own copies, `permissionSetBodyFromRow` and `positionBodyFromRow`, and nothing calls them yet. Neither validates: the metadata door still refuses a body or a name it does not accept. diff --git a/.changeset/pre/22944-runtime-request-kernel-first.md b/.changeset/pre/22944-runtime-request-kernel-first.md deleted file mode 100644 index dbe7ef50399..00000000000 --- a/.changeset/pre/22944-runtime-request-kernel-first.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): on a `kernel-resolver` host, a dispatcher door resolves a service on the request's own environment kernel before the host kernel. `HttpDispatcher.resolveService` used to ask the host kernel's scoped lookup first, and the host kernel's singleton `protocol` / `metadata` / `objectql` answered it whatever the scope. So `POST /api/v1/actions/:object/:action` loaded its subject row through a protocol that registers none of the environment's objects, and it answered `404 RECORD_NOT_FOUND` for a row the caller can read. Hosts with no request kernel of their own (single-environment, SharedProjectPlugin) resolve exactly as before, and an id that names nothing is still refused with 404. - -Clause-②: no diff --git a/.changeset/pre/22957-tree-delete-behavior.md b/.changeset/pre/22957-tree-delete-behavior.md deleted file mode 100644 index 6555443f7b2..00000000000 --- a/.changeset/pre/22957-tree-delete-behavior.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): a `tree` field's `deleteBehavior` now takes effect when its parent record is deleted, and the default clears the reference - -Clause-②: no - -The spec gives a `tree` field (a record's parent in a hierarchy on its own object) a `deleteBehavior`, like a `lookup`. When the field declares none, the parse fills in `set_null`. The engine's referential delete pass only followed `master_detail` and `lookup` fields. So deleting a parent node returned success and left every child pointing at the deleted id. An authored `cascade` or `restrict` on a `tree` did nothing either. - -A by-id delete now applies a `tree` field's behaviour the way it applies a `lookup`'s: - -- `set_null` (the default): the children stay, and their parent field is cleared. -- `cascade`: the whole subtree is deleted, at any depth, and nothing outside it. A cycle in the data (a row whose parent is its own descendant, or itself) ends: each row is deleted once. -- `restrict`: deleting a node that still has children is refused with `DELETE_RESTRICTED` (409), and the dependent named is the tree's own object. A leaf node still deletes. -- On a required `tree` field, `set_null` cannot clear the parent, so deleting a node that has children is refused with `DELETE_RESTRICTED`, as it is for a required `lookup`. - -Deleting a record of an object that has a `tree` field now runs as one transaction when the object is on the default datasource, as every delete that can cascade already does. If a node deep in the subtree refuses its delete (a `beforeDelete` hook, say), the nodes already deleted are restored and the call is refused. `lookup` and `master_detail` behave exactly as before. diff --git a/.changeset/pre/22961-plugin-audit-own-ui-state-no-activity.md b/.changeset/pre/22961-plugin-audit-own-ui-state-no-activity.md deleted file mode 100644 index 6a2db9dcb11..00000000000 --- a/.changeset/pre/22961-plugin-audit-own-ui-state-no-activity.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -fix(plugin-audit): a user's own write to their own `sys_user_preference` row no longer adds a `sys_activity` row. The `sys_audit_log` row is written as before - -Clause-②: no - -The console saves the user's "recently visited" list (`ui.recent`), favourites and palette recents as `sys_user_preference` rows as they navigate. The audit writer mirrored each of those writes into `sys_activity`, and the bell's Activity tab lists the newest activity rows the caller may read. So every navigation came back to the user as `Updated User Preference "ui.recent"`, and a fresh user's bell showed nothing else. - -- **What changed.** For a per-user UI-state object, the writer skips the activity row when the acting user is the row's owner (`user_id`). The list of such objects is closed and declared beside the writer's exclusion list. Today it holds one object, `sys_user_preference`. -- **What stays.** The `sys_audit_log` row for the same write is written exactly as before. Another actor's write to a user's preference row keeps its activity row: an organization administrator editing it from Setup, a system write with no user, or a write that gives the row a different owner. Writes to every other object are unchanged. -- **Existing rows.** Activity rows written before this release are not deleted. They leave the feed when the `sys_activity` retention window (14 days) removes them. -- **Nothing to migrate.** No configuration is needed. diff --git a/.changeset/pre/22972-host-bundle-wins.md b/.changeset/pre/22972-host-bundle-wins.md deleted file mode 100644 index bdae02ec02e..00000000000 --- a/.changeset/pre/22972-host-bundle-wins.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -A host's translation bundle for a platform key now overrides the platform's text, whichever loads first - -Clause-②: no - -`PlatformObjectsPlugin` loaded its translation bundles (the Setup App, the `sys_*` objects, `metadataForms.*`) on `kernel:ready`, and the i18n service merges each load over the last. A bundle a host loaded earlier, during its own `init()` or `start()` (every `defineStack({ translations })` app is one), was overwritten for every key the platform also ships. Measured on both i18n providers: a host zh-CN bundle that set `objects.sys_organization.label` to 「工作区」 still served 「组织」. The docs already said such a bundle "would override the platform's". - -The platform bundles now load UNDER what the service already holds for each locale. - -- **A host's key wins in either order.** A bundle loaded before the platform's is laid back on top of it, and a bundle loaded after it wins as before. -- **Everything the host does not ship still serves the platform's text.** That covers sibling keys of a host key (a host field label leaves the platform's `help` for that field in place), and every locale the host does not ship. -- **Studio-authored translations are unaffected.** The bundles now load in `start()`, as soon as an i18n service exists, which is before the authored overlay is synced at `kernel:ready`. Deleting an authored override of a platform key brings back the platform text, as before. A kernel whose i18n service only appears later in Phase 2 (an `AppPlugin` registering the in-memory fallback on a lean kernel) gets the bundles on `kernel:ready` instead. -- **A provider without `getTranslations`** still gets the platform bundles, loaded over what is there as before, and the boot warns once that host bundles loaded earlier do not override it. - -No platform text changes: zh-CN `sys_organization` is still 「组织」. A host that wants different text ships it in its own bundle. The other platform packages' bundles share no key with this package's, and no example app's bundle repeats a platform key, so nothing else changes what it serves. diff --git a/.changeset/pre/22984-boot-provision-honours-skip-sync.md b/.changeset/pre/22984-boot-provision-honours-skip-sync.md deleted file mode 100644 index 67a725bcb02..00000000000 --- a/.changeset/pre/22984-boot-provision-honours-skip-sync.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/plugin-audit': patch -'@objectstack/service-messaging': patch -'@objectstack/objectql': patch ---- - -Boot-time system-table provisioning honours `OS_SKIP_SCHEMA_SYNC` / `skipSchemaSync` - -Clause-②: no - -`OS_SKIP_SCHEMA_SYNC=1`, or `ObjectQLPlugin`'s `skipSchemaSync` option, declares that a deployment's DDL is managed out-of-band ("Use after running migrations manually"). The engine's boot honoured it, but `AuditPlugin` and `MessagingServicePlugin` still created their system tables at `kernel:ready`. A deployment that set the flag got tables created behind its back while its boot log said no DDL ran, and a database role without DDL rights got a DDL failure it never asked for. - -- **What changes.** With the flag set, `AuditPlugin` (`sys_audit_log`, `sys_activity`, `sys_comment`, `sys_comment_reaction`) and `MessagingServicePlugin` (`sys_notification`, `sys_inbox_message`, `sys_notification_receipt`, `sys_notification_delivery`, `sys_notification_preference`, `sys_notification_subscription`, `sys_notification_template`, `sys_http_delivery`) issue no DDL and no existence probe at boot. Each logs one `info` line naming the objects it did not provision and the flag, and pointing at `os migrate apply`, which creates any that are missing (`os migrate plan` lists them as `create_table`). That path was measured to create all twelve tables, on one database, with the telemetry sibling database, and with the flag set in the migrate run's own environment. -- **Which value is read.** The one `ObjectQLPlugin` resolved: the option, else the environment variable. An explicit `skipSchemaSync: false` therefore still provisions under `OS_SKIP_SCHEMA_SYNC=1`. `ObjectQLPlugin` publishes that value on the engine instance it registers. It is a property of the instance, not a declared member, so no type or export changes. -- **What is unchanged.** With the flag unset, provisioning is exactly as before. `syncObjectSchema` is not gated, so on a flag deployment a runtime publish still gives a new object its table. diff --git a/.changeset/pre/22985-schema-sync-refusal-error.md b/.changeset/pre/22985-schema-sync-refusal-error.md deleted file mode 100644 index ad34658afbb..00000000000 --- a/.changeset/pre/22985-schema-sync-refusal-error.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/service-messaging': patch -'@objectstack/service-datasource': patch -'@objectstack/metadata-protocol': patch ---- - -fix: a refused on-demand schema sync (`syncObjectSchema`) is logged at `error`, naming what was not created and how to fix it (#22985) - -Three places create an object's storage after the boot's own schema sync has run. When the driver refused that DDL, each of them logged a `warn` and carried on, so the deployment looked healthy while a table did not exist. Each now logs one `error` line that says what was not created, that nothing else will look wrong, and what fixes it. The driver's refusal is kept out of the line itself. Nothing else changes: the boot, the connect and the publish still complete as before. - -- **`@objectstack/service-messaging`**: boot-time provisioning of the eight notification tables. A refused table logs `[messaging] could not provision storage — its storage was NOT created. …` at `error`, once per table per boot, with the refusal in the record's meta. The fix it names: resolve the refusal, then restart or run `os migrate apply`. This used to be a `warn`. Under `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` the provisioning still issues no DDL, so this line cannot appear. -- **`@objectstack/service-datasource`**: connecting a declared datasource re-runs the schema sync of every object bound to it. A refused object logs `datasource '': object '' has NO working storage here — …` at `error`, once per object per connect, with the refusal in meta. On a logger that has no `error`, the same line is logged at `warn`. The fix it names: resolve the refusal, then restart. This used to be a `warn` reading `syncObjectSchema('') failed`. -- **`@objectstack/metadata-protocol`**: publishing an object whose storage sync is refused logs `[Protocol] object '' was published but its storage was NOT created or altered — …` with `console.error`. The driver's refusal is the second argument. The fix it names: resolve the refusal, then publish the object again or restart. The publish still answers success. This replaces the `console.warn` line `[Protocol] table sync failed for object '': …`, so a log search for that text now finds nothing. diff --git a/.changeset/pre/22989-config-boot-api-items.md b/.changeset/pre/22989-config-boot-api-items.md deleted file mode 100644 index 1f9a60f2f71..00000000000 --- a/.changeset/pre/22989-config-boot-api-items.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -A stack's declared `apis` endpoints are served on a config boot whose metadata service has an endpoint matcher, as they already were on an artifact boot - -Clause-②: no - -The endpoint matcher reads only the metadata service's own `api` items. The artifact door registers them there. A config boot (`os serve objectstack.config.ts` with no compiled artifact) handed them to the engine registry alone, so every declared path answered `404 ENDPOINT_NOT_FOUND`. The built artifact of the same stack matched the same path and answered `401 UNAUTHENTICATED` to a caller with no session. Measured on a stack with no plugin instances that declares one `authRequired` endpoint: `GET` of its path answered 404 from source and 401 after `os build`. - -`AppPlugin` now registers each declared `api` item into the metadata service's in-memory registry. It uses the route and the package stamp (`_packageId` / `_packageVersion` / `_provenance`) the code-defined datasources already take, and it takes the items from the boot's one forward-converted copy. The same boot now answers 401 from source. The stored item equals the artifact door's copy of it. On a boot that runs the artifact door (`os dev`, an artifact boot), the door stays the only registrar, so each item is registered once. - -One case is unchanged: a composition whose metadata service is the kernel's in-memory fallback has no endpoint matcher. A config whose `plugins` holds plugin instances, booted with `os serve`, is that composition. It now lists the items and still serves none. diff --git a/.changeset/pre/22989-host-config-metadata-door.md b/.changeset/pre/22989-host-config-metadata-door.md deleted file mode 100644 index 15fc780d957..00000000000 --- a/.changeset/pre/22989-host-config-metadata-door.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/runtime': patch ---- - -`os serve` of a host config serves the stack's declared `apis` endpoints, and a composition that still cannot serve them says so once - -Clause-②: no - -A host config is a stack whose `plugins` holds plugin instances: `examples/app-showcase` and every `create-objectstack` scaffold. On `os serve objectstack.config.ts` (and on `os start`, whose `serve` child boots the same way) such a config never got `@objectstack/metadata`'s `MetadataPlugin`, so its `metadata` service was the kernel's in-memory fallback, which has no endpoint matcher. Every declared `apis` path answered `404 ENDPOINT_NOT_FOUND` there, while `os dev` of the same stack matched it. Measured on the showcase with no session: `GET /api/v1/apps/showcase/tasks` answered 404 and now answers `401 UNAUTHENTICATED`; an undeclared path under the same namespace still answers 404. - -`os serve` now composes a `MetadataPlugin` with no artifact on a host-config boot that has none (when `os dev`'s artifact door does not compose and `plugins` holds no `MetadataPlugin` of its own). What an upgrading host author meets on that boot, measured on the showcase before and after: - -- `GET /api/v1/meta/TYPE` answers byte-for-byte the same for every type it served before (22 types read, including `app`, `view`, `page`, `dashboard`, `flow`, `position` and `permission`). -- `GET /api/v1/openapi.json` now documents the declared endpoints the boot serves; before, it listed none of them. -- No new database table: the `sys_metadata` storage tables were already provisioned, and no row is written at boot. -- The door writes nothing into the project directory at boot. Its metadata repository is rooted at the served app's directory, not at the directory the command was run from. -- The door reads none of the stack and primes nothing from the filesystem (`bootstrap: 'lazy'`), so `positions` / `permissions` / `capabilities` / `sharingRules` keep exactly one registrar. -- The dev-only `GET /api/v1/dev/metadata-events` stays unmounted unless `NODE_ENV=development`. Under `os serve --dev` with no compiled artifact it is now mounted, as it is on every other config's dev boot. -- With a remote cluster driver, the metadata service's cross-node cache invalidation (`metadata.changed`) now attaches; the boot warning that it was disabled is gone. -- Boot time and memory did not measurably move (median of 7 boots each: 8.47 s → 8.41 s to listening, 461 MB → 459 MB resident). - -A composition that still has no endpoint matcher (for example a kernel composed without `MetadataPlugin`) now logs one warning at boot per app that declares `apis`, with the count, naming the remedy: compose `@objectstack/metadata`'s `MetadataPlugin`. diff --git a/.changeset/pre/22994-app-plugin-converted-collections.md b/.changeset/pre/22994-app-plugin-converted-collections.md deleted file mode 100644 index 66d4c31b2ac..00000000000 --- a/.changeset/pre/22994-app-plugin-converted-collections.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -`GET /api/v1/meta/` now lists an artifact's items in their converted shape, the shape `GET /api/v1/meta//` serves - -Clause-②: no - -A compiled artifact built before a spec retirement still carries the retired key, and the ADR-0087 forward conversion rewrites it when the artifact boots. The artifact door converted it, so the item read served the converted copy. `AppPlugin` handed the RAW bundle to the engine registry, which is what the list read, `getMetaItemsForExecution` and the email template loader read. So the list served the retired key with `_diagnostics.valid: false`, and a reader of the list (the email loader is one) was handed an item the current schema refuses. Measured with `os dev -a dist/objectstack.json --fresh` on an artifact that declares no `engines.protocol` and carries a dashboard with `refreshInterval: 300`: the item read served `refreshIntervalSeconds: 300`, valid, and the list served `refreshInterval: 300`, invalid. - -`AppPlugin` now runs one forward conversion per boot, after the protocol handshake, over the same definition its security registrar already converted. The registry and that registrar both register from its result. An artifact authored in the current spellings registers exactly what it did before, because a conversion that changes nothing returns its input. - -The security seeders read the same registry copy first, so a boot with an engine now seeds what the conversion produced. Measured on `createStandaloneStack` with a `>=17.1.0` artifact: a sharing rule declared with the legacy recipient type `role` is now seeded as `position`, where it used to be skipped as an unmappable recipient. A seeded permission set no longer stores the retired `allowRestore` / `allowPurge` or the row-level-security `priority`. - -One case is unchanged: a bundle that carries `packages[]` still hands its package bodies over as authored. No conversion reaches those bodies, at the artifact door either. diff --git a/.changeset/pre/23000-branding-logo-describe.md b/.changeset/pre/23000-branding-logo-describe.md deleted file mode 100644 index b4b4d0dba58..00000000000 --- a/.changeset/pre/23000-branding-logo-describe.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The description of `branding.logo` on an app now states the shape of the one asset it takes: a 1:1 square app icon, SVG preferred, at least 64 px, which each surface scales to the size it needs, with no second size and no wordmark slot. Text only; no schema changes: the key stays an optional string, the accept set is byte-identical, and a non-square value is not refused because a URL string does not carry its ratio. diff --git a/.changeset/pre/23001-prerelease-artifact-window.md b/.changeset/pre/23001-prerelease-artifact-window.md deleted file mode 100644 index 2a8422852e2..00000000000 --- a/.changeset/pre/23001-prerelease-artifact-window.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/metadata-core': minor -'@objectstack/metadata': patch ---- - -fix(metadata-core): an artifact declaring `engines.protocol: '^18'` that was built during the v18 prerelease boots on a later runtime that retired one of its shapes — the artifact door replays every retired conversion of protocol 18's step for a floor of `18.0.0` - -Clause-②: yes - -**What an operator saw.** A compiled artifact (`os build` → `dist/objectstack.json`) that declares `engines.protocol: '^18'` and was built before a protocol-18 retirement landed carries the retired shape, for example a defaulted key that every earlier parse emitted. Booted on a runtime that enforces the retirement, the metadata plugin's startup refused it at the strict parse and the boot rolled back. The artifact door compared the floor `18.0.0` with each retirement's `retiredAfter` stamp, and every retirement that lands during the v18 prerelease is stamped with the last 17.x release, below that floor, so nothing was replayed. - -**What an operator sees now.** The same artifact boots. One warning per conversion names it and its site count: `[MetadataPlugin] artifact 'LABEL' declares the first release of protocol 18 (authored engines.protocol floor 18.0.0), which a build from any 18.0.0 prerelease declares too, so it may predate retirements in the conversion step into protocol 18 that this runtime enforces (runtime spec VERSION) — converted N site(s) forward via ADR-0087 conversion 'ID' …`. The artifact file itself is unchanged, so it converts again on every boot until it is rebuilt with `os build`. An artifact built after the retirement boots exactly as before: the replay finds nothing to convert, and nothing is logged. - -- **`@objectstack/metadata-core`.** `applyArtifactForwardConversions` opens its window on a third condition. A declared floor of `M.0.0` at or above the runtime label replays every retired entry whose `toMajor` is `M`, except the default flips the door already refuses. An artifact cannot say which of those entries it predates: it carries no field naming the spec or CLI that built it. Each entry rewrites only its own old shape, so the replay changes nothing in an artifact that lacks that shape. `ArtifactForwardConversionVerdict` gains `'converted-major-step'`, so a consumer that switches exhaustively over the verdict adds that arm. `replayedRetirements` still lists only the entries the per-entry rule opened. The rule holds for every 18.0.0 prerelease label and at 18.0.0 itself. From 18.0.1 on, the label half replays the whole chain for such an artifact, as before. -- **`@objectstack/metadata`.** The artifact door places the new verdict on the open side of its window table and gives it its own clause, so the summary never claims that a `^18` artifact "predates" a runtime labelled below it. The bare-root form-predicate notice uses the same window. - -**Unchanged.** The protocol handshake still refuses a `^17` artifact on a protocol-18 runtime and prescribes `objectstack migrate meta --from 17`. A floor above `M.0.0` (such as `^18.0.1`) and a 17.x floor keep their previous verdicts. diff --git a/.changeset/pre/23004-empty-state-link.md b/.changeset/pre/23004-empty-state-link.md deleted file mode 100644 index 3e3502a3094..00000000000 --- a/.changeset/pre/23004-empty-state-link.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): an empty state names its one next step — `emptyState.link: { label, url }` — and a list page carries its own `interfaceConfig.emptyState` - -Clause-②: yes (widening: two new accepted keys) - -- **`EmptyStateSchema.link`.** An empty state takes one optional link beside `title`, `message` and `icon`: `{ label, url }`, both required and nothing else, for example `link: { label: 'How to publish a package', url: '/docs/publish-a-package' }`. It opens a page or an address and runs no action, flow or script. The shape is closed: a missing `label` or `url`, any other key and a non-object are refused at parse. -- **The targets a `type: 'url'` action opens, and no others.** `link.url` admits an in-app path that starts with `/` or `./`, an address that opens with `http://`, `https://` or `mailto:`, and a template that opens with `${…}` (such as `${ctx.origin}/docs`), which is judged when it is clicked. Every other scheme (`javascript:`, `data:`, `tel:`, …), a protocol-relative `//host`, `/` followed by a backslash, a tab, line feed or carriage return, and a bare relative target such as `docs/x` are refused at `link.url` with the reason and the fix, in the same sentences a url action's `target` is refused with. An empty `url` is refused as empty. -- **Every door that carries the empty state takes the link:** a list view's `emptyState` (and so an object's `listViews` and a view document's), the `object-grid` page block's `emptyState`, and a list page's new `interfaceConfig.emptyState`. -- **`interfaceConfig.emptyState`.** A `type: 'list'` page takes its own empty state, the list view's shape itself: `{ title, message, icon, link }`, refused at parse exactly as the list view's is. -- **Guidance.** `action` or `button` written on an empty state is now answered with the link, and still with the list view's `addRecord` block for an add-record entry point; a `url` or `href` written flat on the empty state is pointed into `link`. -- **Not rendered yet.** No console renderer draws the link or a list page's own empty state today. Both parse and are not drawn, and their schema descriptions say so (`[PLANNED — not yet rendered]`). Until the console reads it, a list page still draws its source view's empty state, if any. -- **Published JSON Schema.** `link.url` publishes as a string with `minLength: 1`. The target rule is a parse-time refinement the published file does not express; the file names its position under `x-dropped-refinements`. diff --git a/.changeset/pre/23008-org-seed-claim-skip-automations.md b/.changeset/pre/23008-org-seed-claim-skip-automations.md deleted file mode 100644 index 4e0821fa2f9..00000000000 --- a/.changeset/pre/23008-org-seed-claim-skip-automations.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/organizations': patch ---- - -The organization seed ownership claim no longer fires app hooks or record-change flows - -Clause-②: no - -When an organization's owner is established (for example, `ensureDefaultOrganization` binding the platform admin as the default organization's owner), `claimOrgSeedOwnership` hands that organization's owner-less seeded rows to the owner. That write ran under a bare system context, so every claimed row went through the full write pipeline: hooks bound from app metadata fired, record-change flows ran, and the approvals and notifications those flows start went out for a bookkeeping write. The single-tenant claim, `claimSeedOwnership` in `@objectstack/plugin-security`, already wrote without automation; the organization claim did not. - -The claim's write now runs with `{ isSystem: true, skipAutomations: true }`, the same context the single-tenant claim uses. - -- **App hooks no longer fire for the organization seed ownership claim.** No hook bound from metadata runs on the claim's owner change, and no record-change flow is dispatched for it, so the claim opens no approval and sends no notification. -- **Still runs.** Hooks that plugins register in code still run on every claimed row: the audit writer, capability gates and the sharing projection. Every claimed row's `owner_id` is the organization owner, as before. -- **The read is unchanged.** The claim's per-object read is still a plain system read. -- **If an app relied on it.** An app hook or flow that reacted to the claim's owner change no longer sees it, just as it never saw the seed's own writes. diff --git a/.changeset/pre/23009-rls-deny-sentinel-composed-readable-ids.md b/.changeset/pre/23009-rls-deny-sentinel-composed-readable-ids.md deleted file mode 100644 index 5bd26623bbd..00000000000 --- a/.changeset/pre/23009-rls-deny-sentinel-composed-readable-ids.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/plugin-security": patch ---- - -The RLS deny sentinel and the composed `ReadableIds` leaf are declared once in `@objectstack/spec/data`, and every place in `@objectstack/plugin-security` that handed out a copy of the sentinel now returns the object itself (ADR-0139 D2) - -Clause-②: yes (widening: new exported members of the published spec `data` entry) - -- **`RLS_DENY_FILTER` moves into the spec.** FROM `@objectstack/plugin-security`'s own frozen literal TO `RLS_DENY_FILTER` exported by `@objectstack/spec/data`. The value is unchanged: `{ id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }`, an `id` equality no record satisfies, so a consumer that never heard of it still reads zero rows. `@objectstack/plugin-security` keeps exporting `RLS_DENY_FILTER` under the same name and type; it is now the spec's object, not a second copy. Nothing to change in code that imports it from either package. -- **One object, compared by identity.** `getReadFilter` (`ISecurityService`) now returns the sentinel itself, not a copy, for a principal-less caller, a sharing scope that cannot be resolved, an on-behalf-of caller, a caller with no permission set and a resolution failure. It also returns the sentinel itself when the only layer that constrains is the tenant wall's deny, the RLS compiler's deny (every applicable policy dropped), or the deny of a `controlled_by_parent` object whose master cannot be resolved. Before this release most of those returned a spread copy, which matched the same zero rows but was a different object. Not every zero-row answer is the sentinel: a deny ANDed with another layer is returned as the composed `$and`, with the sentinel inside it, and the record-sharing layer's own deny, `{ id: '__deny_all__' }`, is returned unchanged when sharing is the only layer that constrains. ADR-0139 D2 step 2 has the engine's `ReadableIds` composition recognise the deny by `scope === RLS_DENY_FILTER` and answer it `$in: []`; any other zero-row answer composes like any scope and still matches no row. The object is frozen: code that edited the filter `getReadFilter` returned now gets a `TypeError` instead of silently changing a deny. -- **The composed leaf.** `ReadableIds.compose(leaf, scope)` builds the frozen value `{ object, scope }` the engine hands a driver in place of a `ReadableIds` leaf (D2 step 3). It is branded under `COMPOSED_READABLE_IDS_BRAND` (`Symbol.for('objectstack.filter.readableIds.composed')`), so a JSON round-trip, a structured clone or a spread drops the brand. `isComposedReadableIds(value)` recognises it. The type `ComposedReadableIds` is exported. `scope` is `undefined` for "no row restriction"; `compose` throws a `TypeError` for anything but a `ReadableIds.of` leaf, for a scope that is neither `undefined` nor a non-null, non-array object, and for `RLS_DENY_FILTER` itself, which D2 step 2 answers `$in: []`. -- **`isReadableIds` answers as before.** It answers `false` for a composed leaf, so it still means "an uncomposed leaf", and a driver refuses on it. The comparand-shape door's `admitReadableIds` admits the uncomposed leaf only: a composed leaf handed to the engine from outside is refused `INVALID_FILTER` / 400 as a non-list comparand. diff --git a/.changeset/pre/23010-url-action-target-scheme-set.md b/.changeset/pre/23010-url-action-target-scheme-set.md deleted file mode 100644 index b9a20bef787..00000000000 --- a/.changeset/pre/23010-url-action-target-scheme-set.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: a `type: 'url'` action's `target` declares its closed scheme set — `http://`, `https://`, `mailto:` and in-app paths — and refuses every other target at parse - -Clause-②: no (narrowing) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. - -`target` was a free string on every action type, so a url action whose target the console's action runner refuses — `javascript:`, `data:`, `tel:`, a protocol-relative `//host` — parsed clean, published, and failed only at the click. The scheme set is now stated once in the contract and the runtime opens exactly that set. On both url-action doors, `ActionSchema` (a registered action) and `InlineActionSchema` (an inline action, such as `element:button`'s `action`, including the legacy `type: 'navigation'` + `to` spelling), a `type: 'url'` target must be one of: - -- an `http://` or `https://` URL (the scheme compared case-insensitively); -- a `mailto:` address; -- an in-app path that starts with `/` or `./`; -- a template that opens with `${` (`${ctx.origin}/x`, `${param.url}`), judged by the runner after interpolation, at the click. - -Every other target is refused at `target`, with a message that names the reason and the admitted set. `target` on every other action type (`script`, `flow`, `modal`, `api`, `form`) names a handler, flow, page, endpoint or form and is untouched, and so are interpolation and `openIn`. - -### FROM → TO - -| before | what to write instead | -| --- | --- | -| `target: 'javascript:…'` or `target: 'data:…'` | no url target runs or renders content. Move the work to a `type: 'script'` action (an inline `body`), or link to an `https://` page. | -| `target: 'tel:+1…'`, `target: 'ftp://…'` or any other scheme | an `https://` page that offers it, or a `mailto:` address. `tel:` is not admitted. | -| `target: 'https:example.com'` (no `//`) | `target: 'https://example.com'` | -| `target: '//cdn.example.com/x'` (protocol-relative) | `target: 'https://cdn.example.com/x'` for an external link, or `target: '/x'` for an in-app path | -| `target: '/\example.com'` (`/` then a backslash) | `target: 'https://example.com'`, or an in-app path written with forward slashes | -| a target holding a tab, line feed or carriage return | the same target without them; percent-encode one that belongs in the address (`%09`, `%0A`, `%0D`) | -| `target: 'thanks'`, `'../thanks'`, `'?tab=2'`, `'#top'` | the rooted in-app path, `target: '/thanks'`, or a `./` path | - -**The one-line fix: give every `type: 'url'` action an `http(s)://` URL, a `mailto:` address, or an in-app path that starts with `/` or `./`.** `os migrate meta --from 17` lists the D3 entry `ui-action-url-target-scheme-set-closed`; the rewrite is by hand, because which address a refused target meant is the author's call. - -**What an author now sees.** `os validate`, `os build`, `defineAction` and `defineStack` refuse such a target at `target`. For example, `javascript:alert(1)` gets: ``A `type: 'url'` action's `target` opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. Every admitted target parses unchanged. - -**Measured producers: none outside tests.** Every url-action target written outside tests is admitted: 9 sites in this repository at `df7a137923` (the showcase's `https://objectstack.ai` among them), and 8 in objectui at both its pin `9646e2ed2bf3` and its main `7578323`. One test fixture in `@objectstack/spec` gave the url arm of an all-types loop a bare `test_handler`; it now writes `/test_handler`. diff --git a/.changeset/pre/23012-lookup-snake-tolerance-text.md b/.changeset/pre/23012-lookup-snake-tolerance-text.md deleted file mode 100644 index ecc967308ab..00000000000 --- a/.changeset/pre/23012-lookup-snake-tolerance-text.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Clause-②: no - -The docblock above `FieldSchema`'s lookup picker keys no longer says that objectui reads -`lookupColumns`, `lookupPageSize` and `allowCreate` with a snake_case alias first. objectui -retired those reads (objectui#12157), so the picker reads all seven keys (`displayField`, -`descriptionField`, `lookupColumns`, `lookupPageSize`, `lookupFilters`, `dependsOn`, -`allowCreate`) in camelCase only. A stored definition that spells one of them in snake_case is -folded onto the declared key when the console ingests it. The six lookup picker rows in -`liveness/field.json` carry the same reading, dated and pinned to the objectui commit it was taken -on. Where a reader outside the picker still falls back to a snake_case spelling, the row names -that reader. No key, schema or accepted value changes. diff --git a/.changeset/pre/23013-section-heading-count.md b/.changeset/pre/23013-section-heading-count.md deleted file mode 100644 index e93bddd3a24..00000000000 --- a/.changeset/pre/23013-section-heading-count.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/lint": minor ---- - -A `record:details` section heading can show how many values a multi-value field holds on the current record: `sections[].count: { field }`, with the section's `label` writing the `{{ count }}` hole where the number goes. - -Clause-②: yes (widening) - -```ts -{ - name: 'plan_gap', - label: '{{ count }} features need a higher plan', - fields: ['capabilities_not_on_plan'], - count: { field: 'capabilities_not_on_plan' }, -} -``` - -- **`@objectstack/spec`.** `RecordDetailsProps.sections[]` accepts `count`, a closed object whose one key, `field`, is a bare machine name (snake_case, no dotted path). A section that declares `count` must write the `{{ count }}` hole in its `label`, in every locale of an inline label map. It is refused when it has no `label`, when the label has no hole, when the label writes any other `{{ … }}` hole (another variable, or the count with a formatter), when it writes the single-brace `{count}` / `${count}`, and beside `group` (a group-referencing section's heading is the group's own label). A translated `objects.._sections..label` writes the same `{{ count }}` hole, and `os i18n extract` seeds it from the authored label. `SECTION_HEADING_COUNT_HOLE` exports the hole's grammar (inner whitespace is free: `{{count}}` is the same hole). -- **`@objectstack/lint`.** The page field-binding pass resolves `count.field` against the bound object, at error severity, on `os validate`, `os lint` and `os build`: a field the object does not declare is `page-field-unknown`, and a declared field that holds one value is the new `page-section-count-not-multi-value`. A multi-value field is `multiselect`, `checkboxes` or `tags`, or a `select`, `lookup`, `user`, `file` or `image` field with `multiple: true`. A section's `fields[]` miss stays a warning. -- **Not rendered yet.** The key is declared ahead of its renderer and its description carries the `[EXPERIMENTAL — not enforced]` marker: until the record detail renderer reads it, the hole renders as literal text. -- **Nothing to migrate.** No stack that parsed before is refused: every new refusal needs the new key. A `{{ count }}` hole in a section that declares no `count` is still accepted. diff --git a/.changeset/pre/23015-one-default-record-page-per-object.md b/.changeset/pre/23015-one-default-record-page-per-object.md deleted file mode 100644 index e76797b7fbc..00000000000 --- a/.changeset/pre/23015-one-default-record-page-per-object.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/objectql': minor -'@objectstack/metadata-protocol': minor -'@objectstack/platform-objects': patch ---- - -feat(spec)!: at most one `isDefault: true` record page per object per deployment — a second is refused at `defineStack` / `os validate`, at package registration and at metadata save, naming both pages (#23015) - -Clause-②: yes (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. - -`PageSchema.isDefault` now states the rule (maintainer ruling A on #23015): at most one `isDefault: true` record page may exist for an object in a deployment. The record view opens that page with no tie-break, so with two defaults for one object the page that opened used to be whichever plugin initialised first — an accident no author wrote. A record page is `type: 'record'` (the default when `type` is left out) bound to an `object`; `isDefault` on any other page is outside the rule. - -**What is refused, and where.** Every door that can add a second default refuses it, naming both pages: - -- **`defineStack`** (and so `objectstack validate`, `objectstack build` and a source boot) — two default record pages for one object inside one stack, `STACK_CROSS_REFERENCE_INVALID` (422), one finding per object naming the object, every page and its position (`stack.pages[0]`). **`composeStacks`** refuses the pair across the stacks it composes, in the same envelope; under `manifest: 'preserve'` one page name in two stacks is two packages' pages. -- **Package registration** (`@objectstack/objectql`'s `SchemaRegistry`, at `installPackage` ahead of every mutation and at the `registerItem` seam) — a package whose default record page meets another package's default for the same object, an environment's stored default, or a second default in its own manifest, `DEFAULT_RECORD_PAGE_CONFLICT` (422, exported as `DEFAULT_RECORD_PAGE_CONFLICT_CODE`). Two packages' pages under one name are two pages and are refused too. `OS_METADATA_COLLISION=warn` does not soften it. -- **Metadata save** (`@objectstack/metadata-protocol`'s `saveMetaItem`, so `PUT /api/v1/meta/page/:name`) — a page saved as the default record page of an object a stored page or a packaged page already holds, `DEFAULT_RECORD_PAGE_CONFLICT` (422), in draft and publish mode, before anything is written. - -**Unchanged.** A save under the existing default's own name is that page (an ADR-0005 overlay by name), not a second default. One default per object, a second record page that is not a default, and two objects with one default each are accepted. The same package registering again — a reload, or an upgrade that moves its default to another of its pages — is one holder. A save under the name of a page a package ships is still answered by the packaged-base refusal (`NOT_OVERRIDABLE`, 403), as before. A stored page reaching the registry from `sys_metadata` (hydration, write-through) is not refused again. - -## FROM → TO - -| door | FROM | TO | -|:--|:--|:--| -| `defineStack` / `objectstack validate` | `pages: [{ name: 'account_a', type: 'record', object: 'crm_account', isDefault: true }, { name: 'account_b', type: 'record', object: 'crm_account', isDefault: true }]` accepted; the boot kept both and the record view opened whichever registered first | refused, `STACK_CROSS_REFERENCE_INVALID` 422 — set `isDefault: false` on all but one | -| `composeStacks` | two stacks each shipping a default record page for one object composed clean | refused in the cross-reference envelope, naming both stacks' pages | -| package registration | a second package (or a plugin's `manifest.register`) shipping a default record page for an object another package holds registered; init order decided the page | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, naming both pages; keep one default, or compose the deployment without the package that ships the other | -| metadata save | `PUT /api/v1/meta/page/my_page` with `isDefault: true` for an object that already has a default record page stored or shipped saved | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, naming both pages; save it with `isDefault: false`, or first demote the stored default | - -**The one-line fix: keep `isDefault: true` on the one record page per object that should open the record view, and set `isDefault: false` on every other.** To replace a page a package ships, compose the deployment without that package's page; shipping a second default is not an override path. - -No D2 conversion rewrites it: which page stays the default is the author's call. Its D3 record is the semantic entry `page-default-record-page-one-per-object-refused` (protocol 18). - -**`@objectstack/spec`.** `defaultRecordPageObject(page)` (exported from `@objectstack/spec/ui`) is the one reading of "which object is this page the default record page of" that the three doors share. `PageSchema.isDefault` gains its `.describe()`. `DEFAULT_RECORD_PAGE_CONFLICT` joins the error-code ledger under `@objectstack/objectql` and `@objectstack/metadata-protocol`. - -**`@objectstack/objectql`.** The registration refusal, `SchemaRegistry.defaultRecordPageHoldersOf(object)` (every page holding the object's default — the reading the save door meets with the stored pages) and the `DEFAULT_RECORD_PAGE_CONFLICT_CODE` constant. - -**`@objectstack/metadata-protocol`.** The save-door refusal. - -**`@objectstack/platform-objects`.** The page form's `isDefault` help text, which said "Set as default page for this page type" — a rule nothing enforced — now states the one-default-per-object rule, in all four locales. - -**Who is affected, measured.** In this repository every object with a default record page has exactly one: `sys_organization` and `sys_user` (`@objectstack/plugin-auth`), `sys_position` (`@objectstack/plugin-security`), `sys_approval_request` (`@objectstack/plugin-approvals`), and `showcase_project`, `showcase_task` and `showcase_preference` (`examples/app-showcase`). A deployment that also composes a package shipping its own default record page for one of those objects is refused at boot until one of the two pages stops being a default. diff --git a/.changeset/pre/23016-page-element-api-data-source.md b/.changeset/pre/23016-page-element-api-data-source.md deleted file mode 100644 index 10e2903da11..00000000000 --- a/.changeset/pre/23016-page-element-api-data-source.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): a page element's `dataSource` can name an API endpoint — `{ provider: 'api', read: { url }, path? }` - -Clause-②: yes (widening: a new accepted shape) - -- **What is new.** `PageComponentSchema.dataSource` takes a second shape beside the object binding: the API binding, exported as `ElementApiDataSourceSchema` (type `ElementApiDataSource`). It reads one endpoint on the deployment's own origin and binds its answer, so a product page composes from a summary that is not an object — `{ provider: 'api', read: { url: '/api/v1/cloud/home' }, path: 'data.environments' }`. `path` is an optional dotted path into the `{ success, data }` response; omitted, the element binds the whole response. -- **Same-origin only, refused at parse.** `read.url` must open with a single `/`. Five classes are refused with the reason and the fix, at `dataSource.read.url`: an absolute URL of any scheme (`https:`, `http:`, `mailto:`, `javascript:`, `data:`, …), a protocol-relative `//host`, a backslash the browser reads as a slash (`/` then a backslash, or a leading backslash), a space or ASCII control character (a tab, line feed or carriage return anywhere is deleted by the URL parser, so `/`, a tab, `/host` is `//host`), and a relative path such as `./home` or `api/v1/x`, which resolves against the page, never the API. The rule is a `pattern` in the published JSON Schema too, so a validator reading `json-schema/` refuses what the parse refuses. -- **No credentials in metadata.** The console reads the endpoint with its own signed-in fetch. The read block declares `url` and `method` only; `headers`, `credentials`, `auth`, `authorization`, `token` and `apiKey` are refused with that prescription, and so are `params` and `body`. -- **Reads only.** `read.method` is optional and accepts `GET` alone, the method the console's API adapter sends for every read. There is no `write` block. -- **The object binding is unchanged.** `ElementDataSourceSchema` accepts exactly what it did and gains no `provider` key; the two shapes meet as a `z.union` at `PageComponentSchema.dataSource`. One visible difference: a refused object binding parsed through `PageComponentSchema` now arrives as one `invalid_union` issue at `dataSource`, whose object branch carries the same issue as before. `os validate` and the API error envelope print one `Invalid input` line for the union at `dataSource`, then the object branch's issue at the same path as before (for example `dataSource.filter`). -- **Not rendered yet.** No console block reads an API binding today: the generic stat, usage-bar and checklist blocks that will are a console release still to come. Until then the binding parses and no block reads it; its schema description says so (`[PLANNED — not yet rendered]`). diff --git a/.changeset/pre/23022-sys-packages-managed-then-flag.md b/.changeset/pre/23022-sys-packages-managed-then-flag.md deleted file mode 100644 index 963711b4337..00000000000 --- a/.changeset/pre/23022-sys-packages-managed-then-flag.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/service-package': patch -'@objectstack/cli': patch ---- - -`os migrate plan` / `apply` cover `sys_packages`, and the boot honours `OS_SKIP_SCHEMA_SYNC` for it - -Clause-②: no - -`sys_packages`, the package registry's table, is created by `PackageServicePlugin.start()` with its own DDL, because it keys on `(id, version)` and a declared object cannot carry that key. So it was never on the managed migration path: `os migrate plan` did not list it and `os migrate apply` did not create it. A deployment that sets `OS_SKIP_SCHEMA_SYNC=1` ("Use after running migrations manually") still had it created at boot, and had no documented way to create it otherwise. - -- **The migration path covers it.** `PackageServicePlugin.init()` declares the table as its `package.storage` kernel service: a read-only catalog probe (SQLite, PostgreSQL and MySQL each ask their own catalog) and the boot's own create statements. `os migrate plan` lists `sys_packages` as `create_table` when it is absent, and `os migrate apply` creates it, with the same statements the boot runs, so the table and its `idx_packages_latest` index come out exactly as a boot creates them. Whether the flag is set does not matter to either command. Where the table's presence cannot be read (a driver that names no SQL dialect), the run says it did not examine the table, and neither lists nor creates it. -- **Then the boot honours the flag.** With the flag set, `PackageServicePlugin` issues no DDL and no existence probe at boot, and logs one `info` line naming `sys_packages` and the flag, and pointing at `os migrate apply`. The `package` service is still registered. The value read is the one `ObjectQLPlugin` resolved (the `skipSchemaSync` option, else the environment variable), as `plugin-audit` and `service-messaging` read it. On a flag deployment, run `os migrate apply` before the first boot that needs the table. -- **What is unchanged.** With the flag unset, the boot creates the table exactly as before. An existing `sys_packages` table is not altered by either command; the plan lists nothing for it. No type or export changes. diff --git a/.changeset/pre/23026-retired-component-prop-refused.md b/.changeset/pre/23026-retired-component-prop-refused.md deleted file mode 100644 index d97ae066c35..00000000000 --- a/.changeset/pre/23026-retired-component-prop-refused.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/lint': major ---- - -A retired page-component prop is refused at `objectstack validate`, `objectstack build` and `objectstack lint`. Before this change, a key that `ComponentPropsMap` tombstones with `retiredKey()` (`object-grid` `defaultFilters`, `element:record_picker` `displayField`, `page:header` `breadcrumb` and the rest) drew only a `component-props-invalid` warning. The command printed "✓ Validation passed" and built the artifact, while the conversion registry told authors the key is refused. It is now a `component-props-invalid` error carrying the tombstone's prescription, and the stack does not pass until the key is gone. - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface (the `component-props-invalid` rule of `objectstack validate`, `objectstack build` and `objectstack lint`). - -**What is refused.** A value verdict a tombstone raises: the tombstone's own issue, wherever the key sits in the props bag, an array entry's key included. A tombstoned key inside a union member is refused when that member is the only one judging keys (`object-form` `sections[].fields[]` `publicPicker`). A retired element's props row (`user:profile`, `ai:chat_window`) raises the same issue and is refused the same way. - -**What stays a warning.** Every other `component-props-invalid` finding: a wrong type, a missing required prop, a closed-set refusal (a url action's `target` scheme, a `record:details` section's `count`), a retired enum value and a missing `dataSource.object` binding. Every `component-props-unknown-key` finding also stays a warning. - -**Not judged here.** The runtime publish door (the Studio, REST `/meta`, MCP) does not run this rule, so a page saved there is not refused for a retired prop. `defineStack` and the stack schema keep the open `properties` bag as written. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `object-grid` `properties: { defaultFilters: [...] }` with an empty `filter` | `properties: { filter: [...] }` | -| `element:record_picker` `properties: { displayField: 'title' }` | `properties: { labelField: 'title' }` | -| a retired prop whose prescription says to delete it, e.g. `page:header` `breadcrumb` | nothing: delete the key | - -**The one-line fix: apply the prescription the error prints.** Where an ADR-0087 conversion covers the key, `objectstack migrate meta --from ` lists the mechanical edits for existing sources. - -**Who is affected, measured.** At objectstack `21c86444e`, this rule ran over `examples/app-crm`, `app-todo`, `app-multi-package` and `app-showcase` (through `objectstack validate`), over the five shipped pages outside them (`@objectstack/platform-objects` ×3, `@objectstack/plugin-approvals`, `packages/qa/downstream-contract`) and over the `create-objectstack` template, which authors no page. Result: 0 newly refused, and 0 findings of this rule of any kind. Deployed metadata and other repositories were not measured. diff --git a/.changeset/pre/23027-package-body-conversion.md b/.changeset/pre/23027-package-body-conversion.md deleted file mode 100644 index ec32a6b82d0..00000000000 --- a/.changeset/pre/23027-package-body-conversion.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/metadata-core': patch -'@objectstack/runtime': patch ---- - -fix(metadata-core, runtime): a multi-package artifact built before a retirement boots at `os dev -a` and `os start`. The ADR-0087 forward conversion walks every `packages[].manifest` body, and the runtime's artifact load converts the bundle once, before any reader sees it - -Clause-②: no - -**What an operator saw.** A multi-package release artifact (ADR-0130 D4: `os build` of a `composeStacks(…, { manifest: 'preserve' })` project) carries its collections in `packages[i].manifest`. Take such an artifact built before a retirement, for example with a dashboard still spelled `refreshInterval`. `os dev -a dist/objectstack.json` and `os start` exited at boot with "`packages[1]` is not a package entry (ADR-0130 D4): manifest.dashboards.0.refreshInterval: `dashboard.refreshInterval` was renamed to `refreshIntervalSeconds`…". The metadata plugin's artifact door refused the same artifact at its strict parse. The ADR-0087 conversion converted that dashboard when it sat in the artifact's own `dashboards`, but it never read a package body, and the runtime read the bundle raw before any conversion ran. - -**What an operator sees now.** The same artifact boots at `os dev -a` and at `os start`. Each body's items are served converted (`refreshIntervalSeconds: 300`, valid), each owned by its own package. The boot log names each conversion once, with the body it found it in: `[MetadataPlugin] artifact '…' … converted 2 site(s) forward via ADR-0087 conversion 'dashboard-refresh-interval-to-refresh-interval-seconds' (first at packages[0].manifest.dashboards[0].refreshIntervalSeconds) …`. The artifact file itself is unchanged, so it converts again on every boot until it is rebuilt with `os build`. A body built after the retirement converts nothing and logs nothing. An artifact authored past every retirement is still refused with the tombstone inside its body. - -- **`@objectstack/metadata-core`.** `applyArtifactForwardConversions` runs the same conversion over each package body as over the artifact's own collections. It uses the same registry and the same window, decided once from the artifact's own `engines.protocol`; a body's own `engines.protocol` is not read. A notice from a body is located from the artifact root (`packages[1].manifest.…`). -- **`@objectstack/runtime`.** `loadArtifactBundle` converts the loaded artifact once, before it merges the runtime module and before the stack, `AppPlugin` or the `manifest` service read it. The database resolver reads the artifact file itself, and it runs the same conversion on what it reads. So a multi-package artifact that declares its default datasource in a package body opens that database, not the unified default. - -**Unchanged.** An artifact without `packages[]` converts exactly as before, with the same notices, paths and messages, and it is served the same way. diff --git a/.changeset/pre/23029-app-plugin-converted-readers.md b/.changeset/pre/23029-app-plugin-converted-readers.md deleted file mode 100644 index d3bb9a05087..00000000000 --- a/.changeset/pre/23029-app-plugin-converted-readers.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -A job's `timeout`, a turso datasource's `config.timeout` and a memory datasource's `config.persistence.autoSaveInterval`, authored by 17.x tooling, are honoured again when the bundle reaches `AppPlugin` raw - -Clause-②: no - -Protocol 18 renames these three duration keys so their unit is in their name: `jobs[].timeout` to `timeoutMs`, turso `config.timeout` to `config.timeoutMs`, memory `config.persistence.autoSaveInterval` to `autoSaveIntervalMs`. An artifact built earlier still carries the old spelling, and the boot's ADR-0087 forward conversion renames it. `AppPlugin` registered that converted copy with the `manifest` service and the security registrar, but its job scheduling and its datasource registration and auto-connect read the RAW bundle. So the scheduler and the drivers found no `*Ms` key and fell back to their defaults, with no notice. - -`loadArtifactBundle` converts at load, so an artifact booted with `os start --artifact` or `os dev -a` already arrived converted. A bundle in a host config's `plugins[]` array does not go through it: `os serve` and `os migrate` wrap it into `new AppPlugin(bundle)` as authored. Measured through that route on a bundle that declares no `engines.protocol`: the job was scheduled with no timeout at all, and both datasources were registered and handed to `connectDeclared` with the retired keys. - -Job scheduling, the code-defined datasource registration, its reserved-name check and `connectDeclared` now read the boot's one forward conversion, the copy every other registrar takes. A bundle authored in the current spellings is read exactly as before: a conversion that changes nothing returns its input, and the registered datasources carry the same `_packageId` / `_packageVersion` / `_provenance` stamps. A handler job's `ctx.bundle` is that converted copy as well. diff --git a/.changeset/pre/23032-page-header-actions-whole-list.md b/.changeset/pre/23032-page-header-actions-whole-list.md deleted file mode 100644 index b8e81764f1f..00000000000 --- a/.changeset/pre/23032-page-header-actions-whole-list.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`PageHeaderProps.actions` now says what its list is: the whole set the header draws. An action that declares `record_header` (or `record_more`) in its `locations` but is not named in an authored `page:header`'s `actions` is not drawn in that header, and a header with no `actions` draws none of the object's own actions; a named id whose action declares neither location renders nowhere, and the host's own Edit / Share / Delete chrome is separate from the list. The `.describe()` was "Action IDs to show in header", which read as a filter over the declared placements. Text only: the accepted set of `actions` is unchanged, and no rule or renderer behaviour moves. diff --git a/.changeset/pre/23033-metadata-row-actor.md b/.changeset/pre/23033-metadata-row-actor.md deleted file mode 100644 index 9cedf181038..00000000000 --- a/.changeset/pre/23033-metadata-row-actor.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-protocol): a metadata row records who created it and who last changed it - -Clause-②: no - -- **What was wrong.** A metadata write that named its author stored no author on the `sys_metadata` row. `created_by` and `updated_by` read null on every row, though the repository's own schema mapping declared `actor → updated_by` and its read side reads that column. Only the history row's `recorded_by` named the writer, so "who changed this object?" could be answered from history alone. -- **What it does now.** Every metadata write stamps the row from the write's actor: `created_by` when the row is first written, and `updated_by` on every write. Studio saves, `PUT /api/v1/meta/:type/:name`, publishing a draft, restoring a version and an in-process `saveMetaItem({ actor })` all write through this one path. A write with no actor (a boot sync, a migration with no user, an unauthenticated internal call) stores null in both columns, never a placeholder user. A save that changes nothing writes nothing, so it leaves both columns as they were. -- **What an operator or a consumer sees.** A row written from now on names its creator and its last writer, so a consumer (such as cloud's AI build agent, whose staged drafts name their author) can read the author from the row itself. The pending-changes list (`listDrafts`' `updatedBy`) names the draft's last writer. Rows written before this release keep their null columns until their next write. History's `recorded_by` is unchanged. diff --git a/.changeset/pre/23036-visiblewhen-no-data-root.md b/.changeset/pre/23036-visiblewhen-no-data-root.md deleted file mode 100644 index 6a7716780c0..00000000000 --- a/.changeset/pre/23036-visiblewhen-no-data-root.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): a page component's `visibleWhen` describe no longer says the renderer binds `data` — the shipping renderer mounts no `data` root there (#23036) - -Clause-②: no - -The `.describe()` on `PageComponentSchema.visibleWhen` said "The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here", and the TSDoc above it said the same. The console's renderer stopped binding `data` on this surface (objectui ruling on the renderer seam's data scope, 2026-09-13): a `data.*` read there now faults, and a faulting `visibleWhen` renders the component, so a gate written from the old text, such as `data.total > 0`, shows the component whether it is true or false. - -The describe now names what the renderer mounts beyond the contract-bound roots (`features` and `os.user`), states that no `data` root is mounted, and says the one declared `data` read on this surface is the PLANNED API binding's (`dataSource: { provider: 'api' }`), once that binding's renderer ships. The `page:tabs` item-level `visibleWhen`, a different evaluator that binds `data` to the record row, is still called out. Nothing an author can write changes: the accept set is the same, and only the description text and the generated reference page move. - -**If you wrote a page component gate on `data.*`:** gate on the page's row as `record.*`, or on page state as `page.`. diff --git a/.changeset/pre/23039-indexing-partial-example-recut.md b/.changeset/pre/23039-indexing-partial-example-recut.md deleted file mode 100644 index dd419959bbc..00000000000 --- a/.changeset/pre/23039-indexing-partial-example-recut.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -docs(skills): the `objectstack-data` indexing rule's partial-index example teaches the rule with an application-shaped index instead of restating the platform's overlay uniqueness key (#23039) - -Clause-②: no - -`skills/objectstack-data/rules/indexing.md`'s "Access methods and partial indexes" section illustrated a partial index with the platform's own overlay-uniqueness DDL and said that was what `ensureOverlayIndex` runs. The quoted key had four parts; the platform's runtime key has carried a fifth, `bundle_member`, since protocol 18, so the sentence stated something the platform no longer runs. The example is re-cut to an application-shaped partial index — one *active* subscription per account, the coarse `{ fields: ['account_id'] }` declared in metadata and the unique partial form built in a migration — so the rule is taught without restating a platform key that changes on its own schedule. The rule itself is unchanged: declare the coarse index (or none) in metadata, build the partial form as raw SQL in a runtime migration, fall back to a plain index on a dialect that rejects the predicate. Nothing an author writes changes. diff --git a/.changeset/pre/23042-retired-after-prerelease-stamp.md b/.changeset/pre/23042-retired-after-prerelease-stamp.md deleted file mode 100644 index aeea67fcd23..00000000000 --- a/.changeset/pre/23042-retired-after-prerelease-stamp.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The TSDoc on `MetadataConversion.retiredAfter` now says what a retirement stamps under a prerelease package label. Under a stable label `x.y.z` it is the label itself, as before. Under a prerelease label `x.y.z-PRERELEASE` (Changesets pre mode, such as `18.0.0-next.N`) it is the newest published stable release, which sits below `x.y.z`: never the prerelease label, which is not a stable `x.y.z`, and never `x.y.z`, the first stable release that carries the entry retired. The old wording, "the package's version label at the moment the retirement lands", had no value that type-checks under a prerelease label. A stable release published later that does not carry the retirement (a `17.x` maintenance release during the 18 prerelease) still accepted the old shape, so it becomes the value. Text only: `retiredAfter` keeps its `${number}.${number}.${number}` type, and the accepted set does not change. diff --git a/.changeset/pre/23047-me-permissions-outer-catch-closed.md b/.changeset/pre/23047-me-permissions-outer-catch-closed.md deleted file mode 100644 index fe102a95f44..00000000000 --- a/.changeset/pre/23047-me-permissions-outer-catch-closed.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/plugin-hono-server': patch ---- - -`GET /api/v1/auth/me/permissions` answers "no capabilities", not "capabilities unknown", when it fails after the caller is known - -Clause-②: no - -Once the handler knows who the caller is, it has two ways to fail. When the permission-set resolution rejects, it already answered a closed body: `systemPermissions: []`, so every capability gate closes. Any other throw inside the handler (the effective-object merge, or a resolver that throws synchronously) was caught by an outer `catch` that answered `{ authenticated: true, userId, objects: {}, fields: {} }`. That body has no `systemPermissions`, which is the shape a deployment predating ADR-0066 sends, and the console reads it as "capabilities unknown" and fails open on it. So a server-side fault offered authoring handles the server then refused. - -The outer `catch` now answers the closed body, built by the same function the success path uses: - -- `authenticated: true`, the caller's `userId`, and `tenantId` (the session's, or `null`), which this body did not carry before; -- `positions`, `permissionSets` and `systemPermissions` as `[]`, and `objects`, `fields` and `tabPermissions` as `{}`. - -The log line is unchanged: `[hono] /auth/me/permissions failed` at `warn`, once. - -**What does not change.** The success body is byte-identical. The resolver-rejection path still reports the session's positions. A stack with no SecurityPlugin or no metadata service still answers its deliberate open body without `systemPermissions`, since there the server enforces nothing either. A caller with no session still gets `{ authenticated: false }`. No accept set, export or `spec` key moves, and the console needs no change. diff --git a/.changeset/pre/23050-s11-form-liveness-citations.md b/.changeset/pre/23050-s11-form-liveness-citations.md deleted file mode 100644 index 4e1baf8e333..00000000000 --- a/.changeset/pre/23050-s11-form-liveness-citations.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The liveness ledger's three public-form rows cite the code that reads their keys where it now lives - -Clause-②: no (only the rows' evidence paths move and every status stays `live`: `ViewSchema` accepts `form.sections`, `form.sharing` and `formViews` exactly as before, and no state count moves) - -- `liveness/view.json`: the `form.sections` and `form.sharing` rows cited `packages/rest/src/rest-server.ts` for the two public form doors, `GET /forms/:slug` and `POST /forms/:slug/submit`. `@objectstack/rest` moved the body of the registrar that mounts them, `RestServer#registerFormEndpoints`, to `packages/rest/src/form-routes.ts` with no behaviour change, so their three path references follow it. -- `liveness/view.json`: the `formViews` row cited the doors' `findPublicFormView`, which moved with that body and reaches every form view through `anonymousFormIntakeCandidates`. The row now cites that rule where it is declared, `packages/spec/src/ui/anonymous-form-intake.ts#anonymousFormIntakeCandidates`, the code on the read path that names `formViews`. -- The quoted code, the verdicts and the notes are unchanged. diff --git a/.changeset/pre/23050-s12-crud-liveness-citations.md b/.changeset/pre/23050-s12-crud-liveness-citations.md deleted file mode 100644 index af91c7eb7d0..00000000000 --- a/.changeset/pre/23050-s12-crud-liveness-citations.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The liveness ledger's CRUD rows cite the code that reads their keys where it now lives - -Clause-②: no (only the rows' evidence paths move and every status stays `live`: `CrudEndpointsConfigSchema` accepts `operations.*` and `dataPrefix` exactly as before, and no state count moves) - -- `liveness/crud_endpoints.json`: the five `operations.*` rows and the `dataPrefix` row cited `packages/rest/src/rest-server.ts#registerCrudEndpoints` for the code that gates and prefixes the CRUD mounts. `@objectstack/rest` moved that registrar's body to `packages/rest/src/crud-routes.ts` with no behaviour change, so those six path references follow it. -- The quoted code, the producers, the verdicts and the notes are unchanged. diff --git a/.changeset/pre/23050-s12-crud-routes-move.md b/.changeset/pre/23050-s12-crud-routes-move.md deleted file mode 100644 index d3eca703f1b..00000000000 --- a/.changeset/pre/23050-s12-crud-routes-move.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -The CRUD doors' registrar body lives in its own module, with no route, handler or answer changed - -Clause-②: no (a move: the six CRUD routes, their order, every handler and every response are unchanged, and the published declarations are byte-identical) - -- `RestServer#registerCrudEndpoints` keeps its name, docblock and place. The body that registers `GET`, `POST` and `POST …/query` on `{basePath}{crud.dataPrefix}/:object`, and `GET`, `PATCH` and `DELETE` on `…/:object/:id`, moved unedited to `packages/rest/src/crud-routes.ts`. The five `crud.operations` switches and `crud.dataPrefix` gate and shape the same mounts. -- Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. diff --git a/.changeset/pre/23050-s13a-meta-collection-liveness-citations.md b/.changeset/pre/23050-s13a-meta-collection-liveness-citations.md deleted file mode 100644 index 446b444ce43..00000000000 --- a/.changeset/pre/23050-s13a-meta-collection-liveness-citations.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The liveness ledger's `/meta` type-list, per-type-list and maintenance switch rows cite the code that reads their keys where it now lives - -Clause-②: no (only the rows' evidence paths move and every status stays `live`: `MetadataEndpointsConfigSchema` accepts `endpoints.types`, `endpoints.items` and `endpoints.maintenance` exactly as before, and no state count moves) - -- `liveness/metadata_endpoints.json`: the `endpoints.types`, `endpoints.items` and `endpoints.maintenance` rows cited `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner` for the gates on their mounts. `@objectstack/rest` moved those three route groups to `packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes` with no behaviour change, so those three path references follow them. -- The quoted code, the producers, the verdicts and the notes are unchanged. The `prefix` row keeps its citation: `metaPath` is still built in the inner method's head. diff --git a/.changeset/pre/23050-s13a-meta-collection-routes-move.md b/.changeset/pre/23050-s13a-meta-collection-routes-move.md deleted file mode 100644 index 726a958726b..00000000000 --- a/.changeset/pre/23050-s13a-meta-collection-routes-move.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -The `/meta` type list, whole-store maintenance and per-type list routes live in their own module, with no route, handler or answer changed - -Clause-②: no (a move: the seven routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical) - -- `RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head. The statements that register `GET {metaPath}` and `GET {metaPath}/types`, `GET {metaPath}/diagnostics`, `GET {metaPath}/_drafts`, `POST {metaPath}/_migrate-stored` and `GET {metaPath}/:type` moved, re-indented, to `packages/rest/src/meta-collection-routes.ts`, and the method calls them at their old position, inside the umbrella's guarded registration. The `metadata.endpoints.types`, `.maintenance` and `.items` switches gate the same mounts. -- Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. diff --git a/.changeset/pre/23050-s13b-meta-item-read-liveness-citations.md b/.changeset/pre/23050-s13b-meta-item-read-liveness-citations.md deleted file mode 100644 index 7abf014e1cf..00000000000 --- a/.changeset/pre/23050-s13b-meta-item-read-liveness-citations.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The liveness ledger's `/meta` cache-flag, per-item switch, doc and book rows cite the code that reads their keys where it now lives - -Clause-②: no (only the rows' evidence paths and notes move and every status stays `live`: `MetadataEndpointsConfigSchema`, `DocSchema` and `BookSchema` accept exactly what they accepted before, and no state count moves) - -- `liveness/metadata_endpoints.json`: the `enableCache` row and the `if (metadata.endpoints.item !== false)` half of the `endpoints.item` row cited `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`. `@objectstack/rest` moved the per-item reads to `packages/rest/src/meta-item-read-routes.ts#registerMetaItemReadRoutes` with no behaviour change, so those references follow them. The `registerPerItemRoute` half of the `endpoints.item` row keeps its citation: that call still lives in the inner method's head. -- `liveness/doc.json`: the `content` row's single-item branch, and the `_note`'s book-tree and single-item branches, cite the new module. The `doc` list branch keeps its citation. -- `liveness/book.json`: the `_note` names the new module for the `book/:name/tree` branch. -- The quoted code, the producers, the verdicts and the row notes are unchanged. diff --git a/.changeset/pre/23050-s13b-meta-item-read-routes-move.md b/.changeset/pre/23050-s13b-meta-item-read-routes-move.md deleted file mode 100644 index 0fa2411fc64..00000000000 --- a/.changeset/pre/23050-s13b-meta-item-read-routes-move.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -The `/meta` per-item read routes live in their own module, with no route, handler or answer changed - -Clause-②: no (a move: the four routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical) - -- `RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head. The statement that registers `GET {metaPath}/:type/:name/references`, `GET {metaPath}/:type/:name/layers`, `GET {metaPath}/book/:name/tree` and `GET {metaPath}/:type/:name` moved, re-indented, to `packages/rest/src/meta-item-read-routes.ts`, and the method calls it at its old position, inside the umbrella's guarded registration. The `metadata.endpoints.item` switch gates the same mounts. -- Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. diff --git a/.changeset/pre/23050-s13c-meta-item-write-routes-move.md b/.changeset/pre/23050-s13c-meta-item-write-routes-move.md deleted file mode 100644 index 169f98249d3..00000000000 --- a/.changeset/pre/23050-s13c-meta-item-write-routes-move.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -The `/meta` per-item write routes live in their own module, with no route, handler or answer changed - -Clause-②: no (a move: the two routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical) - -- `RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head, its per-item registrar included. The two statements that register `PUT {metaPath}/:type/:name` (the item save) and `DELETE {metaPath}/:type/:name` (the reset to the artifact default) moved, re-indented, to `packages/rest/src/meta-item-write-routes.ts`, and the method calls them at their old position, inside the umbrella's guarded registration. They still register through that per-item registrar, so the `metadata.endpoints.item` switch gates the same mounts. -- Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. diff --git a/.changeset/pre/23050-s13d-meta-item-history-routes-move.md b/.changeset/pre/23050-s13d-meta-item-history-routes-move.md deleted file mode 100644 index b3155182593..00000000000 --- a/.changeset/pre/23050-s13d-meta-item-history-routes-move.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -The `/meta` history family routes live in their own module, with no route, handler or answer changed - -Clause-②: no (a move: the five routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical) - -- `RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head, its per-item registrar included. The five statements that register `GET {metaPath}/:type/:name/history` (the change log), `GET …/audit` (the audit trail), `POST …/publish` (the promotion of a pending draft), `POST …/rollback` (the restore of an earlier version) and `GET …/diff` (the comparison of two stored versions) moved, re-indented, to `packages/rest/src/meta-item-history-routes.ts`, and the method calls them at their old position, inside the umbrella's guarded registration. They still register through that per-item registrar, so the `metadata.endpoints.item` switch gates the same mounts. -- Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. diff --git a/.changeset/pre/23050-s13e-meta-object-state-published-move.md b/.changeset/pre/23050-s13e-meta-object-state-published-move.md deleted file mode 100644 index 0e6a45a1efa..00000000000 --- a/.changeset/pre/23050-s13e-meta-object-state-published-move.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -The `/meta` object state-machine read and the published read live in their own modules, with no route, handler or answer changed - -Clause-②: no (a move: the two routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical) - -- `RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head, its per-item registrar included. The statement that registers `GET {metaPath}/object/:name/state/:field` (the legal next states of an object field's state machine) moved, re-indented, to `packages/rest/src/meta-object-state-routes.ts`, and the block that registers `GET {metaPath}/:type/:name/published` (an item's published version) moved to `packages/rest/src/meta-item-history-routes.ts`, beside the history family. The method calls each at its old position, inside the umbrella's guarded registration. The state read still answers to `api.enableMetadata` alone, and the published read still registers through the per-item registrar, so the `metadata.endpoints.item` switch gates the same mounts. -- Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. diff --git a/.changeset/pre/23054-org-seed-claim-paged.md b/.changeset/pre/23054-org-seed-claim-paged.md deleted file mode 100644 index 6d0f85ec38c..00000000000 --- a/.changeset/pre/23054-org-seed-claim-paged.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/organizations': patch ---- - -The organization seed ownership claim now owns every owner-less row, not only the first 10,000 of each object - -Clause-②: no - -`claimOrgSeedOwnership` hands an organization's owner-less seeded rows to the organization's owner. It read at most 10,000 owner-less ids per object and updated them one at a time, and nothing read again. The default-organization bootstrap runs the claim once (the ADR-0093 D7 once-gate), so on an object with more than 10,000 owner-less rows in one organization the rest stayed owner-less for good. Measured on a real engine over the SQLite WASM driver: 10,500 owner-less rows, one pass, 10,000 owned and 500 left. - -The claim now re-owns each object's owner-less rows with one predicate write (`owner_id = owner` where `organization_id = org` and `owner_id` is null), the shape the single-tenant claim `claimSeedOwnership` in `@objectstack/plugin-security` already uses. - -- **Every owner-less row is owned after one pass.** When an object has more rows than one write may fire per-row hooks over (the engine's per-row hook ceiling, 10,000), the engine refuses the write and nothing is written. The claim then re-owns one page of 5,000 rows and tries the whole remainder again, until no owner-less row is left. -- **The returned count is every row claimed.** `count` in each `{ object, count }` entry is the sum of the affected-row counts of every write the claim made for that object. -- **Same scope and the same write context.** Only this organization's rows with no owner are written, a page write included. Every write still runs with `{ isSystem: true, skipAutomations: true }`: no hook bound from app metadata and no record-change flow runs, and hooks that plugins register in code (audit, capability gates, the sharing projection) still run once per claimed row. -- **The claim reads only when it pages.** Below the ceiling it issues no read: the write's own predicate selects the rows. The page read, when there is one, is a plain system read. -- **A failure now costs the write, not one row.** A hook or validation rule that refuses a predicate write refuses all of it, so that object's rows (or that page) stay owner-less and the claim logs a warning naming the object and how many rows it had already claimed. Other objects are still claimed. diff --git a/.changeset/pre/23065-referenceless-tree.md b/.changeset/pre/23065-referenceless-tree.md deleted file mode 100644 index 9c997c8f556..00000000000 --- a/.changeset/pre/23065-referenceless-tree.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/objectql': patch ---- - -fix(spec, objectql): a `tree` field with no `reference` points at its own object, so its `deleteBehavior` takes effect when a parent is deleted - -Clause-②: yes (widening) - -A `tree` field is a record's parent in a hierarchy on its own object. `reference` is optional on it, and when you write one it must be the object's own name — the object schema refuses any other target and tells you to drop the key. But a `tree` with no `reference` behaved as if it pointed at nothing. Deleting a parent answered success and left every child holding the deleted id, whatever `deleteBehavior` said (`set_null`, `restrict` or `cascade`). The same field also could not be expanded (`expand=parent` answered "declares no target object"), could not be filtered through (`{ parent: { title: … } }` was refused), and accepted a parent id that does not exist. - -Now a `tree` with no `reference` is a reference to the object that declares it, exactly as if you had written the object's name: - -- **`@objectstack/spec`.** `ObjectSchema` and `ObjectExtensionSchema` write the object's own name (an extension's `extend`) into such a field's `reference` when they parse it. `defineStack` and `ObjectSchema.create()` output, and built artifacts, now carry the object's name in `reference` on those fields, byte-identical to a field that wrote it. Nothing you could write before is refused now. A new export, `materializeTreeSelfReferences(objectName, fields)`, is the one helper both the parse and the engine use. -- **`@objectstack/objectql`.** The object registry writes the same self-reference when an object is registered. That covers an object saved through the metadata API (`PUT /meta/object/:name` stores the body as written) and a direct `registerObject` call, neither of which goes through the parse. - -What changes on a parent delete: - -- `set_null` (the default) clears the children's parent field and keeps the children. -- `restrict` refuses the delete with `DELETE_RESTRICTED` (409), naming the tree's own object as the dependent. -- `cascade` deletes the whole subtree. - -`expand`, nested relation filters, the write-time check that a parent id exists, the dangling-reference audit and `os verify --delete-behaviors` now treat the field as a self-reference too. A `tree` that already wrote its own name, and every `lookup` and `master_detail`, behave exactly as before. diff --git a/.changeset/pre/23068-migrate-actor.md b/.changeset/pre/23068-migrate-actor.md deleted file mode 100644 index ab266477d58..00000000000 --- a/.changeset/pre/23068-migrate-actor.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch -'@objectstack/metadata-core': patch -'@objectstack/rest': patch -'@objectstack/runtime': patch -'@objectstack/cli': patch ---- - -fix(metadata-protocol): a stored-metadata migration records the user who ran it, or nobody, never a label - -Clause-②: no - -- **What was wrong.** The stored-metadata migration pass (`os migrate meta --stored`, `POST /api/v1/meta/_migrate-stored`, the runtime dispatcher's `POST /metadata/_migrate-stored`, and `migrateStoredMetadata()` called in process) recorded a label as the writer of every row it rewrote: `'os migrate meta --stored'`, `' (POST /api/v1/meta/_migrate-stored)'`, `'POST /api/v1/meta/_migrate-stored'`, or `'migrate-stored'`. The writer lands in `sys_metadata_history.recorded_by` and in the row's `updated_by`. Both columns are references to `sys_user`, so the label pointed at no user. Nothing refused it, because metadata writes run as the system. -- **What it does now.** The pass records the id of the user who ran it, exactly as every other metadata write does. A REST or runtime call by a signed-in user with `manage_metadata` records that user's id. A run with no user records `null`, which means "system-initiated". That covers the CLI, which opens the database directly with no session, and a system caller. The pass still names itself on each history row's `source` (`migrate-stored`), as it did before. -- **What an operator sees.** History rows and rewritten rows written by a migration from this release on name the person who ran it, or nobody. They never name a command or a route. Rows a migration rewrote before this release keep the label they were given. -- **Contract text.** `MetadataItem.authoredBy`'s description now gives the same rule as `PutOptions.actor`: the acting user's id, or `null` for a system-initiated write, never a label such as `"system"` or `"cli"`. The schema accepts the same values as before. diff --git a/.changeset/pre/23072-dispatcher-meta-query-decision.md b/.changeset/pre/23072-dispatcher-meta-query-decision.md deleted file mode 100644 index c654826d898..00000000000 --- a/.changeset/pre/23072-dispatcher-meta-query-decision.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/rest': minor -'@objectstack/runtime': minor ---- - -fix(runtime)!: the runtime dispatcher's by-name `/meta` doors ask the query decision `RestServer` asks, so `?member=` is honoured on the item read and refused where it is not read, never dropped - -`@objectstack/rest` exports one portable decision for the query a by-name `/meta` door accepts, `metaItemQueryRefusal(door, query)`, and the bundle member selector's one reading, `metaMemberSelector(query)`. Both transports call them, so a door's closed set, its sentences and their order are one answer. On a host whose `/meta` is answered by the runtime dispatcher alone (the `@objectstack/hono` `createHonoApp` catch-all), the item read, `PUT`, `/layers` and `/published` declared no closed query set: `GET /meta/email_template/x?member=zh-CN` was answered with the canonical member, a misspelling such as `?memeber=` the same, and `PUT ?member=zh-CN` beside an `en-US` body wrote `en-US`. - -Clause-②: yes (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -What each runtime dispatcher door accepts now, and what to do when it refuses. The answers are `RestServer`'s, in the dispatcher's own envelope, `{ success: false, error: { code: 'VALIDATION_ERROR', message } }`, and the message names the refused parameter: - -- `GET /meta/:type/:name` accepts `layers`, `state`, `preview`, `package`, `locale` and `member`, the six names `RestServer`'s item read accepts. `?member=` now reads that member of a bundled type (an `email_template`'s `zh-CN`); a member the bundle does not hold is `404`, and a selector on a type with no members is the protocol's located `400`. A single-valued name supplied twice is refused, and `?member=` beside `?layers=` is refused (the layered view shows the canonical member's layers). Read one member with `?member=` alone. -- `PUT /meta/:type/:name` accepts `package` and `mode`. It takes no `force`: this door never honoured one, and its `409` remedy already says so. Drop `?force=`, and send a body that keeps what the stored item carries. A save is addressed by its body: to save one member of a bundled type, put that member's own discriminator in the body and drop `?member=`. -- `GET /meta/:type/:name/layers` accepts `package`. Read one member with `GET /meta/:type/:name?member=…`. -- `GET /meta/:type/:name/published` accepts no query parameter. Send none. - -A request that sends only names the door reads is answered as before, except that `?member=` on the item read is now honoured. `RestServer`'s item read asks the same decision, and its answers are byte for byte what they were. Every name the `@objectstack/client` SDK and the Studio metadata client send to these doors is accepted, except `force` on a save: the SDK sends it only when its caller passes `force: true`, and Studio only to retry a save after a `409 DESTRUCTIVE_CHANGE`. On a dispatcher-only host that retry already failed with the same `409`, and it now answers the located `400` that names `force`. diff --git a/.changeset/pre/23072-meta-doors-closed-sets.md b/.changeset/pre/23072-meta-doors-closed-sets.md deleted file mode 100644 index cc0a67f5821..00000000000 --- a/.changeset/pre/23072-meta-doors-closed-sets.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest)!: `PUT` and the five by-name `/meta` sub-doors declare closed query sets, so `?member=` is refused with a located `400 VALIDATION_ERROR`, never dropped - -Six by-name `/meta` doors now refuse a query parameter they do not read, with a located `400 VALIDATION_ERROR`, instead of dropping it. Each read the names it knew and ignored the rest, so a request carrying the bundle member selector `?member=` — which `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` accept — was answered for the canonical member with a `200`, and a save wrote the body's member. - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -What each door accepts, and what to do when it refuses: - -- `PUT /meta/:type/:name` accepts `force`, `package` and `mode`. A save is addressed by its body: to save one member of a bundled type, put that member's own discriminator in the body (an `email_template`'s `locale`) and drop `?member=`. -- `GET /meta/:type/:name/layers` accepts `package`. It shows the canonical member's layers. Read one member with `GET /meta/:type/:name?member=…`. -- `GET /meta/:type/:name/audit` accepts `limit`. The trail is the item's, with every member's attempts in it. Drop `?member=`. -- `GET /meta/:type/:name/diff` accepts `from`, `to`, `fromVersion` and `toVersion`. It compares the canonical member's versions. Read one member's lineage with `GET /meta/:type/:name/history?member=…`. -- `GET /meta/:type/:name/published` and `GET /meta/:type/:name/references` accept no query parameter. Send none. Their refusal ends "This endpoint takes no query parameters." where the other doors list the names they accept. - -The refusal body is the nested envelope the five member doors already answer for a name they do not know, `{ error: { code: 'VALIDATION_ERROR', message } }`, and the message names the refused parameter. A request that sends only names the door reads is answered exactly as before. The `@objectstack/client` SDK and the Studio metadata client send only accepted names. diff --git a/.changeset/pre/23073-list-member.md b/.changeset/pre/23073-list-member.md deleted file mode 100644 index dbb47bd9b8a..00000000000 --- a/.changeset/pre/23073-list-member.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-protocol': minor ---- - -feat(spec, metadata-protocol): the pending-drafts list and a package's commit timeline tell two languages of one email template apart — each entry of a template carries `member` (#23073) - -Clause-②: yes (widening: a new declared response key) - -**What changed for a reader of the two lists.** An email template kept in several languages is one item per language: two languages of `acme.welcome` are two drafts, and two items of one commit. Until now `GET /api/v1/meta/_drafts` and `GET /api/v1/packages/:id/commits` answered them as two entries with the same `type` and `name`, so a reader could not tell which entry was which language, and could not address the second one. - -- Each entry of `GET /meta/_drafts` (`ListDraftsResponseSchema`) and each item of a commit in `GET /packages/:id/commits` (`ListPackageCommitsResponseSchema`) now carries an optional `member` for a type whose identity is a pair (today only `email_template`, identified by `(name, locale)`). It is the language the entry is: `zh-CN`, and `en-US` for the canonical language, spelled out rather than left blank. Two languages of one template are therefore two entries with different `member` values. -- `member` is present on every entry of such a type and absent on every other type, so the lists are unchanged for every type whose identity is its name alone. -- The value is the one the by-name doors take back as their `member` selector: `GET /api/v1/meta/email_template/acme.welcome?state=draft&member=zh-CN` reads the `zh-CN` draft a list entry names, and `?member=` on publish, history, revert and reset addresses it the same way. For a commit item, `member` is the language the stored revert plan records, the one a revert of that commit acts on. - -**For code that uses these packages directly** (`@objectstack/metadata-protocol`): `ObjectStackProtocolImplementation.listDrafts` entries and `listCommits` items have the optional `member` described above. The types `ListDraftsResponse` and `ListPackageCommitsResponse` (`@objectstack/spec/api`, which `@objectstack/client`'s `meta.listDrafts` and `packages.listCommits` return) gain the same optional field. Code that does not read it behaves as before. diff --git a/.changeset/pre/23074-member-envelope.md b/.changeset/pre/23074-member-envelope.md deleted file mode 100644 index 638aa287f17..00000000000 --- a/.changeset/pre/23074-member-envelope.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch -'@objectstack/objectql': patch ---- - -A language the environment added to an email template, which the package does not ship, is no longer treated as packaged code - -Clause-②: no - -An `email_template` is identified by its name and its `locale`, so the languages of one template form a bundle. When an administrator customizes a template in a language its package does not ship (for example a `zh-CN` version of a template the package ships in `en-US` only), that version is the environment's own item. Until now it was registered and served wearing the package's protection envelope, taken from the shipped `en-US` version: - -- the registry's artifact lookup answered the environment's `zh-CN` version as the package's own `zh-CN` artifact; -- `GET /meta/email_template/:name?member=zh-CN` and the `/meta/email_template` list served it with `provenance: 'package'`, the package's id and its version; -- the registry heal that runs after `DELETE ?member=zh-CN` refused to retire it as packaged code. - -- `@objectstack/metadata-protocol`: the artifact envelope (`_packageId`, `_packageVersion`, `_provenance`, and an artifact lock) is copied only onto a version of the same language as the shipped artifact. Each place that copies it looks up the artifact of the body's own language first. A language the package does not ship is registered and served as the environment's item. A language the package does ship keeps its envelope, its lock and its reset exactly as before. Types without languages are unchanged. -- `@objectstack/objectql`: `SchemaRegistry.removeRuntimeShadow(type, name, member)` lifts a named language's shadow only when the package ships that language. For any other language it declines, and the heal's last step (`removeOverlayEntry`) retires the entry. Naming no language still sweeps the whole bundle, as before. - -A lock still binds the template name, at the read and at the write gate alike: a template the package locks stays locked in every language. diff --git a/.changeset/pre/23077-url-target-doors-scheme-set.md b/.changeset/pre/23077-url-target-doors-scheme-set.md deleted file mode 100644 index 6ce51d2f2b4..00000000000 --- a/.changeset/pre/23077-url-target-doors-scheme-set.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: the page action rows' url `target`, a dashboard header action's `actionUrl` and an action's `onSuccess.navigate` declare the closed url scheme set — `http://`, `https://`, `mailto:` and in-app paths — and refuse every other target at parse - -Clause-②: no (narrowing) - - - -**BREAKING** — an accept-set narrowing on three published authoring surfaces, shipped as `minor` under the launch-window convention for accept-set narrowings. - -All three were free strings, so a target the console's action runner refuses — `javascript:`, `data:`, `tel:`, a protocol-relative `//host`, a bare relative path — parsed clean, published, and failed only at the click or after the action succeeded. A `type: 'url'` action's `target` already closed this set; the scheme set is stated once in the contract and the runtime opens exactly that set, so these doors now apply the same grammar, with no second one: - -- **The page action rows' `target`** on the url executor: an `action:button` or `action:icon` with `actionType: 'url'`, and an `action:group` / `action:menu` member with `type: 'url'`. On every other executor `target` names a script, flow, page, endpoint or form and is untouched, and a row with no `target` is not judged. A page's `properties` bag is outside the stack parse; `os validate` judges it through its props gate, which parses the bag against these same rows and so reports the refusal at the row's `target` with the same text, as a `component-props-invalid` finding (that gate's one tier, a warning). -- **`dashboards[].header.actions[].actionUrl`**, when `actionType` is `'url'` or omitted. An omitted `actionType` is judged as the url route, because the two readers that give it a meaning (the console's dashboard renderer and `os validate`'s dashboard action check) both read it as `'url'`. On `'flow'`, `'script'`, `'modal'`, `'api'` and `'form'`, `actionUrl` names the flow, script, page, endpoint or form and is untouched. -- **`actions[].onSuccess.navigate`**, on every value. - -The admitted set, at both doors: - -- an `http://` or `https://` URL (the scheme compared case-insensitively); -- a `mailto:` address; -- an in-app path that starts with `/` or `./` (`/apps/crm/tasks/${result.id}`); -- a template that opens with `${` (`${result.redirectUrl}`), judged by the runner after interpolation. - -Every other value is refused at the key, with a message that names the reason and the admitted set: `javascript:`, `data:`, `tel:` and every other scheme; `http:` or `https:` without the `//`; a protocol-relative `//host`; `/` followed by a backslash; a tab, line feed or carriage return anywhere; a bare relative value such as `forecast`, `../forecast` or `tasks/${result.id}`; and the empty string. - -### FROM → TO - -| before | what to write instead | -| --- | --- | -| `target: 'javascript:…'` / `actionUrl: 'javascript:…'` / `navigate: 'javascript:…'`, or `data:…` | no url target runs or renders content. For a page action row or a header action, declare the executor that does the work (`'script'`, `'flow'`, `'api'`), or link to an `https://` page; for `onSuccess`, drop it or navigate to an `https://` page. | -| `'tel:+1…'`, `'ftp://…'` or any other scheme | an `https://` page that offers it, or a `mailto:` address. `tel:` is not admitted. | -| `'https:example.com'` (no `//`) | `'https://example.com'` | -| `'//cdn.example.com/x'` (protocol-relative) | `'https://cdn.example.com/x'` for an external link, or `'/x'` for an in-app path | -| `'/\example.com'` (`/` then a backslash) | `'https://example.com'`, or an in-app path written with forward slashes | -| a value holding a tab, line feed or carriage return | the same value without them; percent-encode one that belongs in the address (`%09`, `%0A`, `%0D`) | -| `actionUrl: 'forecast'` with `actionType` omitted, meant as a flow or script | `actionType: 'flow'` (or `'script'`, …) beside it — the declared type takes `actionUrl` off the url route | -| `'forecast'`, `'../forecast'`, `'?tab=2'`, `'#top'`, `navigate: 'tasks/${result.id}'` | the rooted in-app path, `'/forecast'` / `'/tasks/${result.id}'`, or a `./` path | -| `target: ''` / `actionUrl: ''` / `navigate: ''` | the address the button or the hop was meant to open; drop an `onSuccess` that navigates nowhere | - -**The one-line fix: give every url page action row, every url header action (`actionType: 'url'` or omitted) and every `onSuccess.navigate` an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` — or, for a row or a header action meant to run something, the executor that names it.** `os migrate meta --from 17` lists the D3 entries `ui-page-action-row-url-target-scheme-set-closed`, `ui-dashboard-header-action-url-scheme-set-closed` and `ui-action-on-success-navigate-scheme-set-closed`; the rewrite is by hand, because which address a refused value meant is the author's call. - -**What an author now sees.** `os validate`, `os build`, `defineStack` and `defineAction` refuse such a value at `header.actions[].actionUrl` or `onSuccess.navigate`; on a page action row, `os validate`'s props gate reports it at the row's `target` (`properties.target`, or `properties.actions.N.target` for a member). For example, `javascript:alert(1)` on a header action gets: ``A dashboard header action's `actionUrl`, a url target when `actionType` is `'url'` or omitted, opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. Every admitted value parses unchanged. - -**Measured producers: none outside tests.** Every url-executor page action row and every header action outside tests in this repository and in objectui (at its pin `7578323` and its main `7af0ab37`) names an admitted target or declares a non-url `actionType`, and the only `onSuccess.navigate` outside tests is the documented in-app route template `/apps/crm/tasks/${result.id}`. diff --git a/.changeset/pre/23082-view-data-api-provider-retired.md b/.changeset/pre/23082-view-data-api-provider-retired.md deleted file mode 100644 index 2f7dbaba967..00000000000 --- a/.changeset/pre/23082-view-data-api-provider-retired.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: retire the view-level `api` data source — a view binds its object or static rows, and a metadata-declared API call is the page element's binding (#23082) - -**BREAKING** — `provider: 'api'` on a view's `data` (`ViewDataSchema`, with its `read` / `write` request blocks) is now refused at parse. The arm took any `url` and any `headers`, so a credential or a cross-origin call written into a view was stored as metadata, served to every client that read the view and sent by the console as written. The page element's API binding (`ElementApiDataSourceSchema`, `dataSource` on a page component) is the one door for a metadata-declared API call: same-origin, no credential key, read with the console's own signed-in fetch. One operation with two implementations that behave differently keeps the governed one — the view-level source is re-homed on the page element and removed, not aligned. - -Clause-②: no (narrowing) - -Measured before removal: no producer wrote `provider: 'api'` on a view outside documentation in objectstack (`bcebd8409`), objectui (`origin/main` `f228d710` — the console's approval-requests source builds `ApiDataSource` in code from an `ApiDataSourceConfig`, never a `ViewData`) or hotcrm (`0f84c9c`), each with a lit `provider: 'object'` control. A renderer reader existed in objectui, measured at `7578323a544e` (`ListView` routed the block to `ApiDataSource`; `ObjectGantt` built one from `read` / `write`), and is now unreachable from authored metadata. - -## FROM → TO - -| you wrote (17.7 and earlier) | write instead | -| --- | --- | -| `data: { provider: 'api', read: { url, method?, params?, headers? } }` on a list or form view | `data: { provider: 'object', object: '' }` (or `{ provider: 'value', items: [...] }`), and the read as a page component's `dataSource: { provider: 'api', read: { url: '/api/...' }, path? }` | -| `data: { provider: 'api', write: { url, method, body? } }` on a form view | nothing — a form submits to its object; a submit-to-endpoint is not a view concern | -| `read.headers: { Authorization: '...' }` | nothing — a credential never lives in metadata; the page element binding refuses a credential key and the console sends its signed-in fetch | - -**The one-line fix:** bind every `provider: 'api'` view to its object or to static rows, and move the endpoint read onto a page component's `dataSource`. - -**What an author who still writes it sees.** `tsc` fails at the authoring site (`ViewData` no longer has the arm), and the parse — `defineView()`, `defineStack({ views })`, `os validate`, `PUT /api/v1/meta/view/:name` — refuses it at `data.provider` with the prescription: - -> `view.data.provider: 'api'` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — a view-level API source took any `url` and any `headers`, so a credential or a cross-origin call written there was stored with the view, served to every client that read it and sent by the console as written; a metadata-declared API call now has one door, the page element's binding, which is same-origin and credential-free. Bind the view to its object — `data: { provider: 'object', object: '' }` — or to static rows — `data: { provider: 'value', items: [...] }` — and move the API read onto a page component: `dataSource: { provider: 'api', read: { url: '/api/...' } }` (with an optional `path` into the response) under a page's `regions[].components[]`, which the console reads with its own signed-in fetch. - -## The retirement kit - -- **A value retirement, not a key**: `data` survives one arm lighter, so there is no `retiredKey()` tombstone and no `RETIRED_KEYS_BY_MAJOR` row. The prescription rides the discriminated union's own `error` map, keyed on the input's `provider === 'api'` — the retired bare-string `sort` clause's shape — so every other unknown provider keeps zod's own report, which lists the live arms (`object`, `value`, `schema`). -- **No D2 conversion, deliberately.** The replacement lives on a different metadata type (a page element), and the only mechanical edit a conversion could make — deleting the view's `data` so the view falls back to querying its bound object — would change what the view shows without telling anyone. An authored source or a stored `sys_metadata` row still carrying the arm meets the prescription at parse instead. -- **The D3 entry `view-data-api-provider-retired`** asks the author, per such view, which page composes the endpoint it read and what the view itself should now bind. -- **`HttpRequestSchema` / `HttpMethodSubsetSchema` stay exported** from `@objectstack/spec/shared` and `@objectstack/spec/ui`, unchanged. After this change no schema in the package composes `HttpRequestSchema`; that orphan is recorded, not acted on here. -- **Ledger:** `view.list.data` and `view.form.data` stay `live` (their surviving arms are read) and carry the arm's retirement record. -- **Docs and the published skill** no longer teach a view-level `api` source; both point at the page element binding. -- **No deprecation window**, per the project's startup-stage posture. - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, and `objectstack-ai/cloud`'s own view metadata was not readable from this change's session; a cloud view carrying an `api` source is refused at parse with the prescription above. - - diff --git a/.changeset/pre/23083-openapi-read-decorations.md b/.changeset/pre/23083-openapi-read-decorations.md deleted file mode 100644 index 0bedbcd0197..00000000000 --- a/.changeset/pre/23083-openapi-read-decorations.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -fix(rest): `GET /api/v1/openapi.json` documents a valid declared endpoint that the served read decorated, and stops logging it as a declaration to fix (#23083) - -Clause-②: no - -**What was wrong.** On a composition with no endpoint matcher (no `metadata` service with `matchEndpoint`, for example `@objectstack/verify`'s `bootStack`), the document is built from the `api` items `getMetaItems` serves. That read adds `_diagnostics` to every item, and `ApiEndpointSchema` is closed, so the document's own parse refused every valid declaration by that key. The endpoints were left out of the document. For each one, the server logged at `error` that the declaration did not satisfy `ApiEndpointSchema` and should be fixed. Measured on the showcase booted through `bootStack`: 0 of its 2 declared endpoints in the document, and 2 such `error` lines. - -**What changes.** - -- The document's parse first removes the read-time decorations (`METADATA_READ_DECORATIONS` from `@objectstack/spec/kernel`: `_diagnostics`, `_draft`). It is the same strip the seed door applies. On that boot the document now lists both endpoints, and no line about them is logged. -- The ADR-0010 envelope keys (`_packageId`, `_provenance`, …) are allowlisted by the schema and parse as before. Any other unknown key is still refused. -- A declaration that really is invalid is still left out of the document and logged at `error`. The log line no longer says that the endpoint matcher excludes the item too. The document's parse does not know that: the matcher judges its own stored copy after removing the storage envelope, and where no matcher is reachable there is none to ask. - -**Upgrading.** Nothing to change. `ApiEndpointSchema` is unchanged. diff --git a/.changeset/pre/23086-actor-family.md b/.changeset/pre/23086-actor-family.md deleted file mode 100644 index 6279cbf8bd1..00000000000 --- a/.changeset/pre/23086-actor-family.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/metadata-fs': patch -'@objectstack/runtime': patch ---- - -fix(metadata-fs, runtime): metadata writes record the acting user's id or nobody, never the label `'fs'`, and a signed-in save through the runtime dispatcher records the user - -Clause-②: no - -- **What was wrong (filesystem repository).** `FileSystemRepository` named the label `'fs'` as the writer whenever no user was given. That covered every edit it saw on disk, every file it found that no recorded write produced, and an actor-less `put()`, which read back as `'fs'`. `MetadataPlugin` attaches the repository without naming a user, so this happened on every host that boots it. The contract for these fields (`PutOptions.actor`, `MetadataItem.authoredBy`, `MetadataEvent.actor`) is the acting user's id, or `null` for "nobody". -- **What it does now.** With no user named, the repository records `null`. A write that names a user records that user, and a write that records `null` reads back as `null`. The `fsActor` option keeps its type. When it is set, it is the user id reported for changes the repository did not write itself, and it no longer replaces a writer that a write recorded. Every event still names its origin on `source: 'fs'`, as before. -- **What was wrong (runtime dispatcher).** `PUT /api/v1/meta/:type/:name` served by the runtime dispatcher (the `@objectstack/hono` catch-all) saved without the caller's identity, so a signed-in author's save recorded `null` in `sys_metadata.updated_by` / `created_by` and `sys_metadata_history.recorded_by`. `RestServer`'s route recorded the user. -- **What an operator sees.** Filesystem-backed metadata history and events from this release on no longer name `'fs'` as the writer. A save through the runtime dispatcher records the signed-in user's id, as the REST route does, and a system caller with no user records `null`. Entries written before this release keep what they recorded. diff --git a/.changeset/pre/23088-verify-bootstack-metadata-plugin.md b/.changeset/pre/23088-verify-bootstack-metadata-plugin.md deleted file mode 100644 index 7b48d4fdd5f..00000000000 --- a/.changeset/pre/23088-verify-bootstack-metadata-plugin.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/verify': patch ---- - -fix(verify): `bootStack` composes the artifact-less `MetadataPlugin` that `objectstack serve` gives a host configuration, so a stack's declared `apis` endpoints answer under the harness as they answer under `serve` (#23088) - -Clause-②: no - -`bootStack`'s `metadata` slot was the kernel's in-memory fallback, which has no endpoint matcher (`matchEndpoint`). So every `apis` endpoint a stack declared answered `404 ENDPOINT_NOT_FOUND` in a test boot while `objectstack serve` of the same stack served it, and the boot logged `AppPlugin`'s "no endpoint matcher" warning. The harness now composes `@objectstack/metadata`'s `MetadataPlugin` the way `serve` composes it for a host configuration: no artifact, `bootstrap: 'lazy'`, and `rootDir` set to `BootOptions.hostRoot`. It reads none of the stack, so `AppPlugin` stays the single registrar of the stack's metadata. - -- A declared endpoint answers as it does under `serve`: `401` without a session where it is `authRequired`, and its declared answer with one. An undeclared path under the same mount keeps the bare `404`. -- A `MetadataPlugin` you pass in `BootOptions.extraPlugins`, or one your app declares in its own `plugins`, takes the composed one's place. Pass your own to boot an artifact-backed one. -- Two other answers of a test boot move with the slot: `GET /api/v1/discovery` reports the `metadata` service as `available` instead of `degraded`, and `GET /api/v1/dev/metadata-events` (the development-only metadata reload stream, mounted because `bootStack` boots in the development posture) answers `200` instead of `404`, as under `objectstack dev`. The `GET /api/v1/meta/TYPE` answers are unchanged, measured over 22 types on the showcase and CRM examples. diff --git a/.changeset/pre/23090-app-plugin-hooks-translations.md b/.changeset/pre/23090-app-plugin-hooks-translations.md deleted file mode 100644 index 6403a2968f3..00000000000 --- a/.changeset/pre/23090-app-plugin-hooks-translations.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -A hook's `timeout` authored by 17.x tooling bounds the hook again, and the three retired translation keys are no longer served, when the bundle reaches `AppPlugin` raw - -Clause-②: no - -Protocol 18 renames a hook's `timeout` to `timeoutMs`, and retires three translation keys: a per-app `settings` group, a page component's `submitLabel` and a dashboard widget's `subCaption`. An artifact built earlier still carries the old spellings, and the boot's ADR-0087 forward conversion rewrites them. `AppPlugin` bound its hooks and loaded its translation bundles from the RAW bundle, though. So the engine's hook wrapper found no `timeoutMs` and ran the hook with no time limit and no notice, and the i18n service served all three retired keys; the per-app `settings` group still filled the gaps in the platform's own settings copy. - -`loadArtifactBundle` converts at load, so an artifact booted with `os start --artifact` or `os dev -a` already arrived converted. A bundle in a host config's `plugins[]` array does not go through it: `os serve` and `os migrate` wrap it into `new AppPlugin(bundle)` as authored. Measured through that route on a bundle that declares no `engines.protocol`: the hook reached the engine as `{ timeout: 5000 }` and never timed out, and `getTranslations('zh-CN')` served all three retired keys. - -The hook binder and `loadTranslations` now read the boot's one forward conversion, the copy every other `AppPlugin` registrar takes. Which lists are read (the stack's own, a legacy `manifest.hooks` or `manifest.translations` list) is decided as before, and a list the conversion lifted from the manifest is read once, converted. A bundle authored in the current spellings is read exactly as before: a conversion that changes nothing returns its input, so the binder and the i18n service are handed the authored hooks and bundles themselves. - -One more behaviour change, measured on the same `plugins[]` route. A hook body or a script action's body that declares `capabilities: ['crypto.hash']`, reaching `AppPlugin` raw, was refused at bind: an `invalid hook.body shape` or `invalid action.body shape` warn, the hook never fired and the action was not registered. Read from the converted copy, where `hook-body-crypto-hash-removed` (the capability was retired at major 17) strips the token, it now binds and runs, as the `manifest` service already registered it. A body that calls `ctx.crypto.hash` throws when it runs, because the sandbox never installed that call. The remedy: drop the `ctx.crypto.hash` call. diff --git a/.changeset/pre/23093-cbp-middle-master-floor.md b/.changeset/pre/23093-cbp-middle-master-floor.md deleted file mode 100644 index 3c03cc26d41..00000000000 --- a/.changeset/pre/23093-cbp-middle-master-floor.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -A `controlled_by_parent` master that is itself a detail decides who may write its details the way its own record update is decided: whoever can edit a quote can edit its lines - -Clause-②: no - -In a two-level master-detail chain (a quote line under a quote under an account, every level below the account `controlled_by_parent`), one person got two answers about the same quote. An organization member who could edit a quote they had not created was allowed to update the quote itself, but every insert, update and delete of its lines was refused `403 PERMISSION_DENIED` ("requires edit access to its master record … not editable by this user (row-level security)"). Only the quote's creator could change its lines. The quote's own update hands the platform's creator-only floor (`owner_only_writes`, positions `['org_member']`) to the master-detail write check, by the 2026-08-15 ruling; the check that a line write runs on the quote kept that floor. - -Now the line write asks the same question the quote's own update asks. By the maintainer's ruling (letter A), a line write under a middle master is admitted exactly when the same person may update that middle master directly: - -- **Admitted now:** an organization member who can update the quote (and the account above it) may insert, update and delete its lines, whoever created the quote. -- **Still refused:** the account above is not editable or not readable; the quote is not editable through its object permission; or an app-authored update rule on the quote refuses the person. -- **Also refused now:** the quote's creator, writing the lines of a quote that an app-authored update rule refuses them. The quote's own update already refused them. The line write had admitted them, because the floor and the app's rule each admitted a row on its own. -- **Unchanged:** a write made on behalf of another user keeps both principals' floors at every level. A master that governs its own rows, a single-level detail, the cycle guard and the depth bound are untouched. - -`security/explain` and the parent gates of `sys_attachment` and `sys_comment` ask the same check, so they give the same answers. diff --git a/.changeset/pre/23096-save-door-protection.md b/.changeset/pre/23096-save-door-protection.md deleted file mode 100644 index cb2f3f45f7c..00000000000 --- a/.changeset/pre/23096-save-door-protection.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/spec': patch ---- - -fix(metadata-protocol)!: the metadata save door refuses a `protection` block with a located `400 VALIDATION_ERROR` instead of storing a lock that binds nothing (ADR-0010 §3.5, Phase 4.3) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -`protection` is the package author's lock declaration. ADR-0010 states one door for it: a code package's loader translates it into the item's private lock envelope when the package loads, and strips it (Phase 4.3). An item saved through the metadata API is authored in the environment, and ADR-0010 §3.5 states that such an item takes no package lock. - -**This entry supersedes one sentence of the entry `15213-unmanaged-lock-strip`, in this same release.** That entry says a malformed `protection` block is not stripped and "the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back". What holds now: on a type whose schema declares `protection`, an item the unmanaged install hands on with its block still in it (a malformed block, or a block beside a `_lock*` value its declaration refuses) meets this entry's door refusal, `400 VALIDATION_ERROR`, and the install is still rolled back. An item carrying a refused `_lock*` value and no block, and a block on a type that does not declare one (`action`, `hook`, `page`), still answer `422 INVALID_METADATA`. Where the two entries disagree, this one is current. - -**FROM.** `PUT /api/v1/meta/{type}/{name}` with a `protection` block answered `200` and stored the block verbatim. The item was then served with `protection.lock: 'full'` while it read `lock: 'none'`, `editable: true` and `deletable: true`, and a second save and a delete both answered `200`. Measured on the showcase for an `app` and a `dashboard`. An author, or an AI, reading that item was told it was protected when anyone holding the authoring capability could change or delete it. - -**TO.** The same save answers `400 VALIDATION_ERROR` and writes nothing. The message names the block and the door that does honour it, and the envelope's `issues[]` carries `protection` as the path, so the Studio designer can highlight it. - -- **Every type that declares the block**, in draft and in publish mode, for a new item and for an overlay of a packaged one. A malformed block is refused the same way, because the block is not accepted at this door at all. On a type whose schema does not declare `protection`, the schema's own `422 INVALID_METADATA` answers, as before. -- **Every authoring write**: the REST and dispatcher saves and the domain doors that save through them. The unmanaged install already drops a block it can read before it saves a copy, and a malformed one is refused here. -- **Server-side rewrites strip it.** A row stored before this change may carry the block. When the server itself rewrites such a row (`os migrate meta --stored --apply`, the stored flow credential move, package duplication), it drops the block instead of failing the rewrite: the block never bound, so nothing changes but the stored bytes. -- **Unchanged.** A body without `protection` saves as before. A packaged item's lock still binds: its loader translated the block at load time, and a save against it still answers `403 ITEM_LOCKED`, or `403 NOT_OVERRIDABLE` on a type with no environment overlay, before this refusal is asked. A row stored before this change keeps its bytes. -- **Package export.** `GET /packages/:id/export` copies a writable base's rows into a manifest, so a block saved into a base row rode into that manifest, where the package loader translates it on install. A block can no longer be saved into a base row. Declare it in the manifest you install instead. -- **Remedy.** Delete the `protection` block from the body you send to the metadata API. To lock an item, declare `protection` on it in a code package's source. - -`@objectstack/spec`: the `protection` TSDoc on every type that mounts it (app, view, dashboard, report, dataset, object, flow, webhook, agent, tool, skill, position, permission set, email template) now says that only the package loader translates the block, and that the metadata API's save door refuses it. diff --git a/.changeset/pre/23100-cbp-one-required-master.md b/.changeset/pre/23100-cbp-one-required-master.md deleted file mode 100644 index e139e4dc3b6..00000000000 --- a/.changeset/pre/23100-cbp-one-required-master.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/lint": minor -"@objectstack/plugin-security": minor ---- - -feat(spec)!: a `controlled_by_parent` object derives its master from exactly one required `master_detail` — the parse refuses anything else, and the runtime's two fallback tiers retire (#23100) - -Clause-②: no (narrowing) - -ADR-0055 §1 says a `controlled_by_parent` object "must declare exactly one required `master_detail` field … Validation error otherwise (fail closed)". Until now neither the parse nor the runtime kept it. `ObjectSchema` accepted such an object with no master at all. The security runtime derived a master through a three-tier fallback the record never stated: a required `master_detail`, then any `master_detail`, then a required `lookup`, taking the first match by field order within a tier. The maintainer ruling (letter B) enforces the record, under ADR-0049 (enforce-or-remove). - -**BREAKING — what moves for consumers.** - -- **`@objectstack/spec`.** `ObjectSchema` (raw `.parse()` / `.safeParse()`, `ObjectSchema.create()`, `defineStack`, `os validate` and the metadata save door) refuses an object declaring `sharingModel: 'controlled_by_parent'` unless exactly one of its fields is a `master_detail` declared `required: true`. With none, the issue sits at `sharingModel` and names each field that relied on a retired tier. With two or more, it sits at `fields` and names them. A plain object literal is judged like a raw parse: an omitted `required` on its `master_detail` is refused, while `ObjectSchema.create()` keeps forcing an omitted `required` to `true`. The step-18 semantic migration entry `cbp-master-relation-tiers-retired` carries the prescription for `os migrate meta`. The step-18 conversion `cbp-master-detail-required-defaulted` covers the one lossless shape: a `controlled_by_parent` object whose one `master_detail` omits `required`, which Studio's field designer writes. It gets `required: true`, exactly what `ObjectSchema.create()` does. It runs on stored rows, built artifacts and `os migrate meta --from 17`. A live authoring parse still refuses the shape and names the fix. -- **`@objectstack/plugin-security`.** The relation resolver derives a master from that one field and nothing else. An object that declares a non-required `master_detail` only, a required `lookup` only, or two required `master_detail` fields resolves no master. Every read of it is denied, and every write and insert is refused with `422 INVALID_METADATA`. The plugin reports such an object once per object (`[security/controlled-by-parent] … resolves no master relation`), naming the shape it relied on. The `422` message now says "not exactly one required master_detail field". -- **`@objectstack/lint`.** `security-controlled-by-parent-no-relation` now refuses the non-required `master_detail` and required `lookup` shapes as well. `security-controlled-by-parent-ambiguous-relation` reports two or more required `master_detail` fields as "no master is derived", since the runtime no longer picks one by field order. Two required `lookup` fields are now a no-relation object, not a tie. `security-master-detail-ungranted` no longer treats a lookup-bound `controlled_by_parent` object as a detail. - -**FROM → TO — HotCRM's three objects.** These are the only producers measured on the retired third tier: the read-only HotCRM clone at `0f84c9c`, 8 `controlled_by_parent` objects, 5 of them already on one required `master_detail`. HotCRM's pinned engine is unaffected until it bumps past the release that carries this change. - -| object | FROM | TO | -|---|---|---| -| `crm_campaign_member` | `crm_campaign: Field.lookup('crm_campaign', { required: true })` | `crm_campaign: Field.masterDetail('crm_campaign', { required: true, deleteBehavior: 'restrict' })` | -| `crm_event_attendee` | `crm_event: Field.lookup('crm_event', { required: true })` | `crm_event: Field.masterDetail('crm_event', { required: true, deleteBehavior: 'restrict' })` | -| `crm_article_feedback` | `crm_knowledge_article: Field.lookup('crm_knowledge_article', { required: true, deleteBehavior: 'cascade' })` | `crm_knowledge_article: Field.masterDetail('crm_knowledge_article', { required: true, deleteBehavior: 'cascade' })` | - -`deleteBehavior: 'restrict'` keeps what the two lookups do today: a campaign or an event with members or attendees cannot be deleted. Without it a `master_detail` cascades, and a detail's rows are deleted with their master. An object whose rows must outlive their parent is not a detail. It leaves `controlled_by_parent` for `private`, `public_read` or `public_read_write`. - -**Remedy, in one line.** On every object with `sharingModel: 'controlled_by_parent'`, declare exactly one `master_detail` field with `required: true`, pointing at the master. Every other parent is a `lookup`. - -**Unchanged.** - -- An object with one required `master_detail` resolves and derives exactly as before, chain walk included. So does a required `master_detail` beside other `lookup` fields, or beside a `master_detail` that is not required. Every in-tree `controlled_by_parent` object is in that set: `examples/app-crm` (1), `examples/app-showcase` (2), the CLI's eval corpus (4) and the dogfood fixtures. -- Stored metadata whose one `master_detail` merely omits `required` keeps serving, converted as above. A stored row in any other retired shape is not rewritten: it still registers, under its `[metadata_spec_invalid]` diagnostic, and is denied until it is corrected and saved. Those shapes are an explicit `required: false`, two masters, or a required `lookup`. None of them is converted: turning a `lookup` into a `master_detail` changes what deleting the master does to every row, choosing between two masters is the pick ADR-0055 §1 refuses, and leaving `controlled_by_parent` is the author's decision. -- No export, signature or error code is added or removed. - - diff --git a/.changeset/pre/23103-page-action-row-on-success-and-executor.md b/.changeset/pre/23103-page-action-row-on-success-and-executor.md deleted file mode 100644 index 0a93691a00d..00000000000 --- a/.changeset/pre/23103-page-action-row-on-success-and-executor.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: a page action row's `onSuccess` is `ActionSchema`'s own post-success block, so its `navigate` is judged by the url scheme set, and the row's executor key refuses `'navigation'`, the runner's alias of `'url'` - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on two keys of 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 its action keys to the console's action runner. Two of those keys still let a row carry a target the runner refuses at the click: - -- **`onSuccess`** was any value (`z.unknown()`). The runner reads only `ActionSchema`'s post-success block, `{ navigate, openIn }`, and classifies `navigate` with the url scheme set. So `onSuccess: { navigate: 'javascript:alert(1)' }` parsed clean, and so did a misspelled key or a callback shape, which the runner gives no reading at all. The rows now hold `ActionSchema.onSuccess`'s own block, the same instance by reference, so a row's verdict on any `onSuccess` value is the action's: the scheme set on `navigate`, the rename prescriptions for `url` / `redirectUrl` / `to` / `route` / `path` / `target` / `navigateTo` / `href`, and the closed `openIn` with its `'self'` default. -- **The executor key** (`actionType` on a node, `type` on a member) is a free string, and the rows judged a url `target` only when it read `'url'`. The runner also dispatches `'navigation'`, its local alias of `'url'`, to the same navigator, so `actionType: 'navigation'` with `target: 'javascript:…'` parsed clean. The url executor now has one spelling on the rows: `'navigation'` is refused at the executor key, with the rename onto `'url'`. The key otherwise stays open: a handler a host registers under another name (`navigate_create`, `navigate_edit`, …) is still dispatched by name. - -A page's `properties` bag is outside the stack parse. `os validate` judges it through its props gate, which parses the bag against these same rows, so it reports each refusal at the row's own path with the same text, as a `component-props-invalid` finding (an error, like every finding of that gate). A stored page still loads; the metadata save door runs the same gate, so the next save of a page carrying a refused row is refused with the same text. - -### FROM → TO - -| before | what to write instead | -| --- | --- | -| `onSuccess: { navigate: 'javascript:…' }` (or `data:`, `tel:`, any other scheme) | an `https://` page or an in-app route, or drop the `onSuccess` — no hop runs a script or renders content | -| `onSuccess: { navigate: '//cdn.example.com/x' }` | `'https://cdn.example.com/x'`, or `'/x'` for an in-app path | -| `onSuccess: { navigate: 'tasks/${result.id}' }` (a bare relative template) | the rooted route, `'/tasks/${result.id}'`, or a `./` path | -| `onSuccess: { navigate: '' }`, or `onSuccess: {}` | the address the hop was meant to open, or drop the `onSuccess` | -| `onSuccess: { url: '/x' }` (or `redirectUrl`, `to`, `route`, `path`, `target`, `navigateTo`, `href`) | `onSuccess: { navigate: '/x' }` | -| `onSuccess: { navigate: '/x', openIn: 'new-tab' }` | `openIn: 'newTab'` — the block's spelling, not the top-level url switch's | -| `onSuccess: { type: 'notify', … }`, an array of callbacks, or a bare string | drop it: the runner reads only the navigation block, so these never ran | -| `actionType: 'navigation'` on an `action:button` / `action:icon` | `actionType: 'url'`, with the same `target` and `openIn` — `os migrate meta --write` rewrites it, and a stored row is rewritten when it is read | -| `type: 'navigation'` on an `action:group` / `action:menu` member | `type: 'url'`, with the same `target` and `openIn` — `os migrate meta --write` rewrites it, and a stored row is rewritten when it is read | - -**The one-line fix: give every page action row's `onSuccess` the action's block, `{ navigate, openIn? }`, with a `navigate` that is an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` — and spell the url executor `'url'`.** The rename from `'navigation'` is lossless on a row: the runner sends both spellings to one navigator, and the alias's own keys (`to`, `redirect`, `external`, `newTab`, `replace`) are not row keys. The executor rename is mechanical: `os migrate meta --from 17 --write` performs it through the ADR-0087 D2 conversion `page-action-row-navigation-executor-to-url`, which is retired from the load path, so an author meets the refusal while stored rows and migrated sources are rewritten. It lists the D3 entries `ui-page-action-row-navigation-executor-refused` (which names that conversion) and `ui-page-action-row-on-success-shape-closed`. An `onSuccess` rewrite stays by hand, because which address a refused `navigate` meant is the author's call. - -**What an author now sees.** `os validate`'s props gate reports a refused `navigate` at `properties.onSuccess.navigate` (or `properties.actions.N.onSuccess.navigate` for a member) with the action's own refusal: ``An action's `onSuccess.navigate` opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. The alias is reported at `properties.actionType` (or `properties.actions.N.type`): ``An `action:button`'s `actionType: 'navigation'` is the console runner's local alias of `'url'`, refused on page action rows since @objectstack/spec 18, and the url executor has one spelling.``, followed by the rename. A row's parsed `onSuccess` now carries `openIn: 'self'` when it was omitted, as an action's does; the runner reads an omitted `openIn` the same way. - -**Measured producers: none refused.** No page action row in this repository or in objectui (at its pin `1fb1ced3`) writes an `onSuccess` outside the block or the executor `'navigation'`. objectui's row tests write `{ navigate: '/app/records/${result.id}' }`, admitted, and every `type: 'navigation'` there is an `element:button` inline action or a runner test, not a page action row. Host-registered executor names are kept: this package's own released guidance prescribes `actionType: 'navigate_edit'` on an `action:button`. diff --git a/.changeset/pre/23119-save-door-lock-envelope.md b/.changeset/pre/23119-save-door-lock-envelope.md deleted file mode 100644 index 9a4293c84f4..00000000000 --- a/.changeset/pre/23119-save-door-lock-envelope.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol)!: the metadata save door never sets, changes or removes an item's lock: a body's `_lock*` family that would do so is refused with a located `400 VALIDATION_ERROR`, an echo of the served lock is accepted and ignored, and the row keeps the lock it stores (ADR-0010 §3.5, §4.1) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The `_lock` family (`_lock`, `_lockReason`, `_lockDocsUrl`, `_lockSource`) is the runtime's private lock envelope. A code package's loader stamps it from the package's `protection` block, and an install or a migration's re-save carries a stored one. ADR-0010 strips it on write and reattaches it on load (§4.1), and an item saved through the metadata API is authored in the environment, which takes no package lock (§3.5). - -**This entry supersedes one sentence of the entry `23096-save-door-protection`, in this same release.** That entry says, under its unchanged behaviour, "A body without `protection` saves as before". What holds now: a body without `protection` saves as before only when it also sends no `_lock*` key, or sends back exactly the `_lock*` family a read of that item serves; a body whose `_lock*` family would set, change or remove the item's lock answers `400 VALIDATION_ERROR` and writes nothing, as this entry states. Where the two entries disagree, this one is current. - -**FROM.** `PUT /api/v1/meta/{type}/{name}` stored a body's `_lock*` family verbatim. A hand-written `{ "_lock": "full" }`, or one an author or an AI copied from another item's served body, answered `200`, and the item then read `lock: 'full'`, `editable: false`, `deletable: false`: its next save and its delete both answered `403 ITEM_LOCKED`, and no door unlocks it. Any holder of the authoring capability could make an environment item permanently uneditable and undeletable. The other way round, an author save that sent no `_lock*` key dropped a lock the platform had stamped on the row, so a `no-delete` stopped refusing the delete. - -**TO.** An author's save keeps the lock family the row it writes already stores. - -- **No `_lock*` key in the body.** The save goes through, and the row keeps its stored lock. A body that says nothing about the lock does not remove it. -- **An echo is accepted and ignored.** Studio and the AI authoring surfaces send the served body back on every edit, so a family equal to one a read of the item serves passes: the family the item read, the layered read's `effective` and the list serve (the item's resolved lock), or the family stored on the row the save writes (what the draft read and the layered read's `overlay` layer serve). The row still keeps its own stored family, so an echo of a packaged item's lock on its first overlay stores none in the environment row; the package's lock still binds. -- **Anything else is refused**, `400 VALIDATION_ERROR`, and nothing is written: a family that would set a lock (on a new item, or on one that has none), change one (its state or its prose) or remove one (`_lock: 'none'`, or a partial echo that drops part of it), and a family copied from another item. The message names each `_lock*` key sent and the door that does lock an item, and `issues[]` carries each key as a path, so the Studio designer can highlight it. -- **Every authoring write**: the REST and dispatcher saves, draft and publish, the external-table import, the unmanaged install, and the domain doors that save through them (the flow and permission-set clones, the permission-set and position write-throughs, the package publish's app flip). A type with no schema (`external_catalog`) is judged too. -- **Unchanged.** A `_lock*` value the type's declaration refuses (`_lock: 'locked'`, a non-string reason) is still the schema's `422 INVALID_METADATA`, so the unmanaged install's refusal of such an item does not move. The server-side rewrites (`os migrate meta --stored --apply`, the stored flow credential move, package duplication) keep the family their body carries. A packaged item's lock still binds, and a save against a locked item still answers `403 ITEM_LOCKED` first. No lock stored today is released. -- **Remedy.** Delete the `_lock*` keys from the body you send to the metadata API, or send them back exactly as a read of that item served them. To lock an item, declare `protection` on it in a code package's source, where the package loader translates it into the item's lock. diff --git a/.changeset/pre/23120-server-composed-protection-drop.md b/.changeset/pre/23120-server-composed-protection-drop.md deleted file mode 100644 index 6a9aa1c0c33..00000000000 --- a/.changeset/pre/23120-server-composed-protection-drop.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): cloning a flow, and publishing a package's drafts, no longer fail on a `protection` block left in a row stored before the metadata save door refused it - -Clause-②: no - -Two runtime doors re-save a body the server builds from a stored row: the flow clone (`POST /api/v1/automation/:name/clone`), and the visibility flip of `POST /api/v1/packages/:id/publish-drafts`, which re-saves each of the package's apps still stored with `_unpublished: true`. The metadata save door now refuses a `protection` block with `400 VALIDATION_ERROR` (entry `23096-save-door-protection`). A row stored before that refusal can still carry a block, and both doors copied it into the body they saved. - -**FROM.** A clone of such a flow answered `400 VALIDATION_ERROR` about a block the admin never sent. The flip of such an app was refused the same way: the publish still answered `200`, the app stayed `_unpublished`, and the refusal was reported only under `unhideError`. - -**TO.** Both doors drop the `protection` block from the body they save, as the permission-set clone already does. The clone and the flip save, and the saved row carries no block. Nothing else in either body changes, and neither door states a `source`, so every other save-door check still applies. The block never bound anything: only a code package's loader translates `protection` into a lock, so dropping it changes no lock and no verdict. - -**Unchanged.** A flow or an app with no block is saved exactly as before. diff --git a/.changeset/pre/23122-outbox-claim-credential-read-back.md b/.changeset/pre/23122-outbox-claim-credential-read-back.md deleted file mode 100644 index b630c5020db..00000000000 --- a/.changeset/pre/23122-outbox-claim-credential-read-back.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/service-messaging': patch ---- - -The notification and HTTP outboxes read their claim credential back from the store, so `ack()` lands on a Postgres table whose epoch-ms columns are still `real` - -Clause-②: no - -Tables created while the `number` field type still mapped to `real` keep those columns as IEEE-754 binary32. Newer tables get an exact numeric column, and existing columns are not migrated. A binary32 `claimed_at` stores a rounding of the claim's `now`, to the nearest 131 072 ms at today's magnitude, and reads back a nearby decimal. `SqlNotificationOutbox` and `SqlHttpOutbox` handed out the in-memory `now` as the claim credential, so it never equalled the stored value. Every `ack()` was refused with `DELIVERY_NOT_ELIGIBLE` as a lost claim, and the dispatcher absorbed the refusal. The row stayed `in_flight` with `attempts = 0`, and every reap handed it to the next claim. The notification was re-delivered about every 131 s, with no end. - -- **What changes.** `claim()` and `claimDigest()` return `claimedAt` as the `claimed_at` value the store returned after the claiming update. The value written is unchanged. The rows a claim took are identified by candidate id, holder and status. `claimed_at` is no longer compared with the client's `now` in that read. `ack()`'s ownership check and its compare-and-set are unchanged. They now compare that stored value with the stored value, which is exact on any numeric column. -- **A stuck row heals by itself.** A row left `in_flight` by a refused ack is reaped by the visibility timeout, claimed again, sent once and acked. No operator act is needed. On a `real` column the reap still runs at binary32 granularity, up to about 131 s after `claimTtlMs`. -- **What is unchanged.** A genuinely lost claim is still refused. If another node, or this node's own later claim, reaped and re-claimed the row while a send ran, the late ack gets `DELIVERY_NOT_ELIGIBLE` and writes nothing. The `ClaimedDeliveryRecord` and `HttpClaimCredential` types are unchanged. Existing `real` columns are not migrated, and duplicate inbox rows that were already delivered stay as they are. diff --git a/.changeset/pre/23124-comment-edit-stamp.md b/.changeset/pre/23124-comment-edit-stamp.md deleted file mode 100644 index 57c311f1ba9..00000000000 --- a/.changeset/pre/23124-comment-edit-stamp.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/plugin-audit': minor ---- - -`sys_comment` edit marks are platform-written: an update that changes a comment's `body` stamps `is_edited: true` and `edited_at` from the server clock, and a client can no longer set or clear either field. - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -- **Stamped by the platform.** The comment's `beforeUpdate` sets both fields when the update's `body` differs from the stored body, for every caller, the way the engine stamps `updated_at`. An update that does not name `body`, or sends the stored text again beside new `mentions`, is not an edit and stamps nothing. A create is not an edit either. -- **Read-only at the data door.** `is_edited` and `edited_at` now declare `readonly: true`. On an update that edits the body, the stamp replaces whatever the client sent; on any other update a non-system caller's value is dropped and reported in the response's `droppedFields`; on a create the platform sets both to "not edited" over a client value. Nothing is refused for carrying the fields — a client that still sends them on every edit, as the console released before this change does, keeps working and its values are ignored. Read both fields back from the row instead of writing them. -- **Predicate updates.** A `multi: true` update is stamped per matched row. One whose matched rows disagree — some bodies change, some already hold the new text — is now refused whole with `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` (400) and writes nothing, because one SET clause cannot mark some rows and not others. Narrow the predicate to the rows whose text changes, or update by id. -- A `preserveAudit` (historical import) update keeps a mark it supplies, as it keeps `updated_at`. diff --git a/.changeset/pre/23134-related-list-parent-data-source.md b/.changeset/pre/23134-related-list-parent-data-source.md deleted file mode 100644 index d723b58fecb..00000000000 --- a/.changeset/pre/23134-related-list-parent-data-source.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): `record:related_list` declares its parent record with a binding of its own, `parentDataSource`, beside the child-row `dataSource` (#23134) - -Clause-②: yes (widening) - -- **What is new.** `ComponentPropsMap['record:related_list']` (`RecordRelatedListProps`) accepts `parentDataSource`: the PARENT record the list hangs off, bound by a query. Its shape is the element data-source object binding `{ object, view?, filter?, sort?, limit? }` (`ElementDataSourceSchema`, referenced, not copied), the same binding `dataSource` takes on every page component. A binding the data-source shape refuses (no `object`, an unknown key, the MongoDB-style `filter` record, the `api` arm) is refused here too, at `parentDataSource`. -- **The single-record rule.** The parent is the FIRST row of the bound set by the binding's `sort`; declare `limit: 1` to say one row is meant. When no row answers, the block renders a loud absence, never an empty list. `relationshipValueField` is read on that row. This is the rule `record:details`, `record:highlights` and `record:quick_actions` already take for their own `dataSource`. -- **The fallback.** Without `parentDataSource`, the record page's record context remains the parent, as before. Every related list that parsed before parses unchanged. -- **`dataSource` keeps its meaning.** The node-level `dataSource` on `record:related_list` is still the CHILD rows the list draws (it fills `objectName`, `columns`, `filter`, `sort` and `limit`) and is never read as the parent. -- **Planned, not yet rendered.** The key's describe opens with `[PLANNED — not yet rendered]`: no objectui renderer reads it yet. objectui reads it once a release carrying this key is installable there; until then a related list on an `app` page still finds no parent. -- **Refused elsewhere.** Every other `ComponentPropsMap` row refuses `parentDataSource` as an unknown key, and `os validate` reports it as `component-props-unknown-key`. - -```ts -{ - type: 'record:related_list', - properties: { - objectName: 'contact', - relationshipField: 'account_id', - parentDataSource: { - object: 'account', - filter: [{ field: 'is_primary', operator: 'equals', value: true }], - limit: 1, - }, - }, -} -``` diff --git a/.changeset/pre/23142-settings-anonymous-floor.md b/.changeset/pre/23142-settings-anonymous-floor.md deleted file mode 100644 index c64771be017..00000000000 --- a/.changeset/pre/23142-settings-anonymous-floor.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/service-settings': minor ---- - -fix(service-settings)!: the settings REST family refuses an anonymous caller with the shared 401 - -Clause-②: no — it narrows the anonymous accept set to the ADR-0056 D2 floor; no published schema or export moves. - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -`registerSettingsRoutes` mounts the settings REST family. Until now it ran no anonymous floor: a caller with no session was answered by the family's own capability logic, with a status other than `401`. ADR-0056 D2 denies anonymous callers by default, and ADR-0138 D2 says a surface outside its door classes answers `401`. - -- **Refused now:** every route of the family answers a caller with no user `401` with code `UNAUTHENTICATED`, in the declared error envelope, before the settings service is consulted. The decision is the platform's shared one (`shouldDenyAnonymous`), mounted once for the whole family. -- **Unchanged:** a signed-in caller is served exactly as before, and the per-namespace read and write capabilities still decide what it may see and change. An authorization-store outage still answers its `503`. - -What changes for you. Sign in before calling the settings routes. If you mount `registerSettingsRoutes` yourself with your own `contextFromRequest`, it must put the caller's `userId` on the context it returns: a context without one is anonymous now, whatever capabilities it carries. diff --git a/.changeset/pre/23143-storage-token-refusal-401.md b/.changeset/pre/23143-storage-token-refusal-401.md deleted file mode 100644 index 38703347675..00000000000 --- a/.changeset/pre/23143-storage-token-refusal-401.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/service-storage': patch ---- - -fix(service-storage): a presigned token the local adapter refuses answers `401 INVALID_TOKEN` on both raw byte routes, never a `500` - -Clause-②: no - -`PUT` and `GET /api/v1/storage/_local/raw/:token` are the local adapter's presigned byte routes. Each now answers every token it will not honour with `401 INVALID_TOKEN`, the status, code and message (`Invalid or expired token`) the error catalog already published for this code. The four cases are a malformed token, a forged one, one minted for the other verb, and an expired one. - -- **Before:** a malformed token, or one minted for the other verb, answered `500 INTERNAL`, which reads as a server fault. A forged or an expired token answered `403`, with a message naming which check failed. A genuine fault whose text happened to contain "expired" or "signature" was answered as a refused token. -- **Now:** every refusal gets the same status, code and message, so the answer never tells a forger whether the signature or the expiry failed. Any other failure behind a valid token is still `500 INTERNAL`, whatever its text says. -- **A 5xx withholds its message text.** Per the platform rule, a 5xx on the raw byte routes answers with the route's generic text and not the fault's own; the original error stays in the server log. -- **Token signatures are compared in constant time.** -- **Unchanged:** a valid token's upload or download, the `501 NOT_IMPLEMENTED` answer when no adapter can verify tokens, and every other storage route. - -What changes for you: a stale or tampered signed URL now answers `401` where it answered `403` or `500`. To recover, request a fresh URL (`GET /api/v1/storage/files/:fileId/url`, or a new presigned upload). The SDK's `storage.upload` and the console's upload adapter only check that the response is OK, so neither needs a change. diff --git a/.changeset/pre/23145-tombstone-removal-version.md b/.changeset/pre/23145-tombstone-removal-version.md deleted file mode 100644 index 24f85895fb3..00000000000 --- a/.changeset/pre/23145-tombstone-removal-version.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Retirement prescriptions for shapes `@objectstack/spec` 17.7.0 still accepts now name 18 as the release that removed them - -Clause-②: no - -17.7.0, the newest 17.x release (npm `latest`), still accepts every shape in the two tables below. No 17.x release removes them: the 18 line does. `18.0.0-next.0` is the first release that refuses the shapes in this first table. Their refusals, and their `[REMOVED]` JSON Schema descriptions, named a different release: - -| Surface | Was | Now | -| --- | --- | --- | -| `element:number` `object` / `filter`; `element:record_picker` and `element:repeater` `object` / `filter` / `sort` / `limit` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | -| `object-grid` `defaultFilters` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | -| `email_template.category`, `email_template.isSystem` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | -| a package manifest's `permissions` written as a list of permission strings | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | -| `public` in `StorageScope` | removed in @objectstack/spec 17.8.0 | removed in @objectstack/spec 18 | -| `MetadataTypeRegistryEntry.allowOrgOverride` → `allowEnvironmentOverlay` | renamed (no release named) | renamed in @objectstack/spec 18 | - -The `18.0.0-next.0` release notes quoted the two `email_template` prescriptions with the earlier version, 17; from this release on they name 18. - -`18.0.0-next.0` still accepts the shapes in this second table, so the first release that refuses them is the one after it. Their refusals named 17, or no release at all: - -| Surface | Was | Now | -| --- | --- | --- | -| `view.data.provider: 'api'` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | -| `'navigation'` as a page action row's executor (`actionType` on `action:button` / `action:icon`, member `type` on `action:group` / `action:menu`) | no release named | refused on page action rows since @objectstack/spec 18 | -| a `controlled_by_parent` object with no `master_detail` declared `required: true` | no release named | the one-required-master rule holds since @objectstack/spec 18 | - -One prescription was wrong the other way. 17.4.0 accepts the sub-day `TimeUpdateInterval` members `second`, `minute` and `hour`, and 17.5.0 refuses them, but the refusal said "retired in protocol 18". It now says "removed in @objectstack/spec 17.5.0". - -The `os migrate meta --from 17` sentence in each prescription is unchanged and was already right: `--from 17` replays the protocol-18 conversions. A prescription for a key that a 17.x release already refuses keeps naming 17. Text only: no key, value or shape is accepted or refused differently. diff --git a/.changeset/pre/23147-component-props-every-finding-refused.md b/.changeset/pre/23147-component-props-every-finding-refused.md deleted file mode 100644 index 6a2cbbbbc19..00000000000 --- a/.changeset/pre/23147-component-props-every-finding-refused.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/lint': major -'@objectstack/spec': patch ---- - -Every finding of the component-props gate is now an error at `objectstack validate`, `objectstack build` and `objectstack lint`. A page component whose `properties` carry a key its `ComponentPropsMap` row does not declare, a value that row rejects, or no `dataSource.object` binding where the element reads only that binding drew a warning: the command printed "✓ Validation passed" and built the artifact, and the renderer ignored the key or drew nothing. Each is now a `component-props-unknown-key` or `component-props-invalid` error, and the stack does not pass until it is fixed. A retired prop was already refused, and still is. - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface (the `component-props-invalid` and `component-props-unknown-key` rules of `objectstack validate`, `objectstack build` and `objectstack lint`). - -**What is refused now.** Every verdict of the gate: - -- `component-props-unknown-key`: a key the component's props row does not declare, at the bag's own level, on a strict object or union arm below it, or on a strip-mode object below it. -- `component-props-invalid`: a value the props row rejects (a wrong type, a missing required prop, a closed-set refusal such as a url action's `target` scheme or a `record:details` section's `count`, a retired enum value), and a missing `dataSource.object` on `element:record_picker`, `element:number` and `element:repeater`. - -**Not judged here.** `defineStack` and the stack schema keep the open `properties` bag as written. A component `type` that `ComponentPropsMap` carries no row for (a `custom.*` or plugin type) is not judged. - -`ComponentPropsSeverity`, the type `@objectstack/lint` exports for these findings, narrows from `'error' | 'warning'` to `'error'`. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `page:header` `properties: { titel: 'Deals' }` | `properties: { title: 'Deals' }`, the declared spelling the error names | -| `record:related_list` `properties: { limit: 'five' }` | `properties: { limit: 5 }`, the type the error names | -| `element:number` with no `dataSource` | `dataSource: { object: 'deal' }` on the component node, beside `type` | -| a key the component does not read | nothing: delete the key | - -**The one-line fix: apply what the error names.** An unknown-key error names the declared spelling when one is close, a value error names what the row expects, and the binding error names where the object goes. If the component really does read the key or value, the props row is what is wrong: correct the declaration in `@objectstack/spec`, not the page. - -The `@objectstack/spec` change is text only: the reason of each D3 migration entry named above said the gate reported its refusal as an advisory finding, and now says it is an error. - -**Who is affected, measured.** At objectstack `8323cad97`, this rule ran at its own input tier over `examples/app-crm`, `app-todo`, `app-multi-package` and `app-showcase`, and over the eight shipped pages outside them (`@objectstack/platform-objects` ×3, `@objectstack/plugin-approvals`, `packages/qa/downstream-contract`, `@objectstack/cloud-connection` ×2, `@objectstack/mcp`): 37 pages, 177 components judged. It also ran over `objectstack-ai/hotcrm` at `0f84c9c72`: 8 pages, 50 components judged. Result: 0 findings of every class in both. A planted undeclared key and a planted wrong-typed prop each drew one finding on every population. The `create-objectstack` template authors no page. Deployed metadata and other repositories were not measured. diff --git a/.changeset/pre/23149-view-container-reset-heals-expansions.md b/.changeset/pre/23149-view-container-reset-heals-expansions.md deleted file mode 100644 index 94609c3c34d..00000000000 --- a/.changeset/pre/23149-view-container-reset-heals-expansions.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -Resetting a published overlay of a view container now stops serving the views it expanded to, without a restart - -Clause-②: no - -- A view container (a `defineView` document, such as a package's `showcase_task` views) expands to one view per entry, registered under `.` (`showcase_task.default`, `showcase_task.in_progress`, …). A published overlay of the container registers its own expanded views under those names in the process registry. -- `DELETE /api/v1/meta/view/NAME` on such an overlay answered `reset: true`, but its registry heal restored only the container's own name. `GET /api/v1/meta/view` and `GET /api/v1/meta/view?object=OBJECT` kept listing the overlay's expanded views next to the packaged ones until the process restarted, and the end-user list kept rendering the overlay's columns and label. The by-name read of an expanded view served the overlay's copy too wherever no MetadataService answered first. -- The heal now also heals every view name the container registered when it was published, through the same steps as the container's own name. A packaged view of that name is served again, a view only the overlay added is removed, and a later publish of the container is served as before. -- Unchanged: an organization-scoped delete still touches no registry entry, re-registration still happens on a control-plane kernel only, and a view name another package ships, which the publish never registered, is never touched by the reset. Dashboards and every other type reset as before. -- No public export, signature, schema or accept-set change. The built entry declarations gain three `private static` member names on `ObjectStackProtocolImplementation`. diff --git a/.changeset/pre/23152-import-invite-redirect-to.md b/.changeset/pre/23152-import-invite-redirect-to.md deleted file mode 100644 index 4eb32262408..00000000000 --- a/.changeset/pre/23152-import-invite-redirect-to.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -The identity import's automatic invitation email links to the Console's set-password page, no longer to an `INVALID_TOKEN` error - -Clause-②: yes (widening) - -- **Before.** For every row an admin imported under the Automatic (or Invite) password policy with email delivery, `POST /api/v1/auth/admin/import-users` requested the password reset with no `redirectTo`. better-auth then emailed a link ending in an empty `callbackURL=`, and its `GET /reset-password/:token` refused that link with a redirect to `?error=INVALID_TOKEN`, which the Console shows on its sign-in page. The token in the link was still valid, but the invited user had no page to use it on. -- **Now.** The reset request carries `redirectTo` set to the Console's set-password page (`${origin}${uiBasePath}/reset-password`, by default `/_console/reset-password`), the page the Console's own forgot-password flow lands on. The emailed link opens `/_console/reset-password?token=…`, and better-auth's origin check accepts it because it is the deployment's own origin. -- **The two new members.** Both are additions; nothing existing changes shape. - - `AuthManager.getConsoleUrl(path)` is a public reader of the absolute URL of a Console page. It returns what the class already composes for every email and SMS link it sends. - - `IdentityImportDeps.consoleResetPasswordUrl?()` is optional. `AuthPlugin` wires it to `getConsoleUrl('/reset-password')`. A host that calls `runAdminImportUsers` itself should pass it to get a working invitation link. A caller that omits it sends the request exactly as before (`{ email }`), so the link keeps the empty `callbackURL`. -- **Unchanged.** The SMS invitation, the temporary-password policy, and the email copy (the invitation still uses the password-reset template). diff --git a/.changeset/pre/23157-server-authored-ui-text-names-what-and-who.md b/.changeset/pre/23157-server-authored-ui-text-names-what-and-who.md deleted file mode 100644 index 94a9e905762..00000000000 --- a/.changeset/pre/23157-server-authored-ui-text-names-what-and-who.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch -"@objectstack/plugin-security": patch ---- - -fix: the approval request actions' success toasts name the request, and Explain access names the principal as a person - -Clause-②: no - -Server-authored strings that the console renders verbatim now name what and who they are about. - -- `@objectstack/plugin-approvals`: every action on `sys_approval_request` had a bare-verb `successMessage` (`Approved.`, `Rejected.`, `Reassigned.` and so on). Each now names the request through `${result.request.record_title}`. Each action's route answers `{ request, … }` with the read-back request and its display enrichment, so `record_title` is the title the inbox shows: the target record's display field, else its payload snapshot. The new copies: - - Approve and Reject use the inbox row's inline wording: `Approved "TITLE"` and `Rejected "TITLE"`. - - The others read `Reassigned "TITLE"`, `Sent "TITLE" back for revision`, `Information requested on "TITLE"`, `Reminder sent for "TITLE"`, `Recalled "TITLE"`, `Resubmitted "TITLE"` and `Reply posted on "TITLE"`. - - The zh-CN, ja-JP and es-ES values of the same keys carry the same token. No key, route or response shape changes. -- `@objectstack/plugin-security`: the explain engine's principal layer said `Principal USER_ID holds position(s) …`. It now names the person first and the id second, for example `Principal Ada Lovelace (ada@example.com; id usr_1) holds position(s) …`. Three other users in the same layer are named the same way: - - an on-behalf-of delegator; - - the user an ADR-0091 D3 delegated position came from, in the clause; - - that same user in the position's contributor attribution (`delegation from …`). - - Each user row is read with the asker's own context, so row-level and field-level security apply. An asker whose read returns no row gets the bare id, as before, and a field its read masks is left out. As a result, two askers can now be shown different names for the same user. The decision's `principal` object and every verdict are unchanged. diff --git a/.changeset/pre/23158-ai-store-caller-context.md b/.changeset/pre/23158-ai-store-caller-context.md deleted file mode 100644 index 86f4a27583e..00000000000 --- a/.changeset/pre/23158-ai-store-caller-context.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -`IAIConversationService.get` / `list` and `IAIService.listPendingActions` take the caller's execution context as an optional trailing parameter (ADR-0096 D5) - -Clause-②: yes (widening: an optional parameter on three published store-contract methods) - -- **FROM → TO.** - - `get(conversationId)` → `get(conversationId, context?: ExecutionContext)` - - `list(options?)` → `list(options?, context?: ExecutionContext)` - - `listPendingActions?(filter?)` → `listPendingActions?(filter?, context?: ExecutionContext)` -- **What an implementation does with it.** When a caller supplies `context`, the implementation passes it unchanged as the `context` of every engine read the method makes (`BaseEngineOptions.context`), so the read runs as that caller. Under ADR-0096 D5 strict mode an engine read with no principal is refused with 403, so without a caller an implementation can only read as system. The type and the position are the ones `ISharingService`, `IApprovalService`, `IShareLinkService` and `IAnalyticsService` already use. -- **Nothing to migrate.** The parameter is optional. A caller that omits it gets the read the implementation made before, and an implementation written against the shorter signatures still satisfies both interfaces. To read as the caller, pass the request's `ExecutionContext` and forward it to your engine reads. -- `list`'s `options.userId` stays a filter on the conversation's owner. It is not the caller's identity. -- Whether an ordinary member is admitted to their own rows depends on the stored objects' access and sharing, which these contracts do not declare. diff --git a/.changeset/pre/23165-direct-save-resolves-package.md b/.changeset/pre/23165-direct-save-resolves-package.md deleted file mode 100644 index c33759778b2..00000000000 --- a/.changeset/pre/23165-direct-save-resolves-package.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/spec': minor -'@objectstack/rest': minor ---- - -fix(metadata-protocol)!: a metadata save or delete that names no package addresses the row of the one package the item belongs to, and a name two or more packages hold is refused `409 RESOURCE_CONFLICT`; the delete door can name its 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 one item can hold one row per package. A save that names a package (`?package=` on the REST door, `packageId` in process) writes that package's row. A save that named none always wrote the package-unbound row, even for an item a package owns, and a delete named no package at all: it removed whichever row of the item the store returned first. The draft save already resolved this: a draft that names no package is stored in the package of the item's active row. - -**FROM.** Save an item into a package (`PUT /api/v1/meta/app/my_app?package=com.acme.app`), then save it again without `?package=`: the second save answered `200` "Saved app 'my_app' (env-wide, state=active)" and wrote a second, package-less row beside the package's row. The item read kept serving the package's row, so nothing the save carried took effect, and a console "Disable" reported success while the app stayed enabled. The same held for the API, the CLI and agents, and for an overlay row bound to a code package. A `DELETE /api/v1/meta/app/my_app` of an item two packages hold removed one of the two rows, the other was then served, and the receipt said "it no longer exists". Uninstalling a package, or discarding its drafts, deleted each of its items by name the same way, so it could remove another package's row of the same name and leave its own. - -**TO.** A direct save, or a delete, that names no package resolves its address from the item's rows (the active rows for a save and a delete, the draft rows for a draft discard): - -- **Bound to one package.** The save writes that package's row, exactly as a save naming the package does, and the item read serves it next; the delete removes that row. The receipts name the package: "Saved app 'my_app' (env-wide, package=com.acme.app, state=active)" and "Deleted app 'my_app' (package=com.acme.app) — it no longer exists." Every check on the row the save writes reads that package: its stored lock, a stored credential carried forward, the optimistic lock, and the writable-base rule. The item read that names no package serves that row's `version`, so a read followed by a save or a delete with the served `If-Match` is accepted. -- **Bound to two or more packages.** The save or delete is refused `409 RESOURCE_CONFLICT` and changes nothing. The message names the packages and the remedy: name one with `?package=`. -- **Bound to none.** The save writes, and the delete removes, the package-unbound row, as before, and the receipts read as before. - -The delete door gains the address a save has: `DELETE /api/v1/meta/:type/:name?package=` (`packageId` in process, `DeleteMetaItemRequestSchema.packageId`) removes that package's row and no other; `all` and an empty value name no package, as on the save door. A repeated `?package=` is refused `400`. A delete receipt now says when another row of the item is still stored, naming it, instead of claiming the item is gone or back at its artifact default. Uninstalling a package and discarding its drafts name the package, so each removes only its own rows. - -**Also refused now**, each as the same save naming the package is refused: a package-less save of an item held by a read-only package (a code package's runtime-only item, or an installed `system`/`cloud` package's) answers `422 WRITABLE_PACKAGE_REQUIRED`; and a package-less save asserting a create (`If-None-Match: *`) of an item a package holds answers `409 METADATA_CONFLICT`. - -`packageId: null` in process names no package too, as the request contracts state. The server-side rewrites keep the row they name: `os migrate meta --stored --apply` and the stored flow credential move pass `null` for a package-unbound row they rewrite, and that row is still the one written; package duplication names its target package. - -**Unchanged.** A save or a delete that names a package exactly. A draft save, which keeps its own inheritance. An item a code package ships with no stored row: the package-less save still writes the package-unbound overlay, and the read serves it. Package-less rows already stored beside a package's row stay as they are; none is converted. A package-less delete of such an item removes the package's row and its receipt says the package-less row remains. - -**Remedy.** For an item two packages hold, send the save or the delete with `?package=` naming the package whose row it edits or removes. A receipt now names the package when the write addressed a package's row; a client that compares receipt text should expect `, package=` after `env-wide` on a save and ` (package=)` after the item on a delete. - -`@objectstack/spec`: the `SaveMetaItemRequestSchema.packageId` description now states what an `active` save that names no package writes, where it said the env-local overlay; `DeleteMetaItemRequestSchema` declares the optional `packageId`. `@objectstack/rest`: `package` joins the reset door's closed query set (`META_ITEM_RESET_PARAMS`). diff --git a/.changeset/pre/23168-page-action-row-name-leg-url-target.md b/.changeset/pre/23168-page-action-row-name-leg-url-target.md deleted file mode 100644 index 22d1ff44103..00000000000 --- a/.changeset/pre/23168-page-action-row-name-leg-url-target.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: a page action row's url `target` is judged on the executor the console's action runner actually runs, so a row with no executor key whose `name` is `url` or `navigation` has its `target` judged by the url scheme set - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on one key of 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[]`). The console's action runner dispatches a row on `type || actionType || name`, and every row forwards its executor key (`actionType` on a node, `type` on a member) as `type` beside its `name`. So a row with no executor key runs the handler its `name` names, and `name: 'url'` or `name: 'navigation'` runs the url navigator. The rows judged `target` by the url scheme set only when the executor KEY read `'url'`, so such a row parsed with a target the click refuses. - -The rows now judge `target` against the row's effective executor: the executor key, or, when that key is omitted or empty, the `name`, exactly as the runner resolves it. When it resolves to the url navigator, `target` is judged by the same url scheme set as an `actionType: 'url'` row, with no second grammar. A declared executor key still outranks the `name`. `name` is not refused as an executor spelling, and a row whose `name` names any other handler (`navigate_edit`, `convert_lead`, …) is untouched. An `actionType: 'url'` row's refusal is unchanged. - -### FROM → TO - -| before | what to write instead | -| --- | --- | -| `{ name: 'url', target: 'javascript:…' }` with no `actionType` (or no `type` on a member), or `data:…`, `tel:…` or any other scheme | an `https://` page, a `mailto:` address, or an in-app path that starts with `/` or `./`; or, for a button meant to run something, the executor that does the work (`actionType: 'script'`, `'flow'`, `'api'`) | -| the same with `name: 'navigation'` | the same; the `name` is the runner's url alias, so the target is judged as a url | -| `{ name: 'url', target: 'forecast' }`, `'../forecast'`, `'?tab=2'` | the rooted in-app path, `'/forecast'`, or a `./` path; or declare the executor the row was meant to run | -| `{ name: 'url', target: '//cdn.example.com/x' }` | `'https://cdn.example.com/x'`, or `'/x'` for an in-app path | -| `{ name: 'url', target: '' }` | the address the button was meant to open | - -**The one-line fix: give every page action row that has no executor key and is named `url` or `navigation` an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` as its `target`, or declare the executor key it was meant to run.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-name-leg-url-target-judged`; the rewrite is by hand, because which address a refused target meant is the author's call. - -**What an author now sees.** `os validate`'s props gate reports the refusal at the row's `target` (`properties.target`, or `properties.actions.N.target` for a member). For example, `{ name: 'url', target: 'javascript:alert(1)' }` on an `action:button` gets: ``An `action:button`'s `target` (`name: 'url'` and no `actionType`, so the runner runs the url navigator) opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. Every admitted target parses unchanged. - -**Measured producers: none.** No page action row in this repository, in objectui (at its pin `1fb1ced36243` and its main `866763bd`) or in hotcrm (at `0f84c9c`) is named `url` or `navigation`. diff --git a/.changeset/pre/23168-page-action-row-on-success-beside-executor.md b/.changeset/pre/23168-page-action-row-on-success-beside-executor.md deleted file mode 100644 index 48ed2cf446c..00000000000 --- a/.changeset/pre/23168-page-action-row-on-success-beside-executor.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: a page action row's `onSuccess` is refused beside a console runner built-in other than `api` / `script`, and beside a row with no executor; `ActionSchema`'s `onSuccess` refusal now states its true reason - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on one key of 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 `onSuccess` to the console's action runner, which performs the post-success hop after every executor that succeeds. `ActionSchema` refuses `onSuccess` beside any `type` but `api` and `script`; the rows judged it beside no executor at all. They now carry the action's rule on the vocabulary the action's closed `ActionType` can see, read against the row's effective executor (the executor key, or the row's `name` when that key is omitted or empty, as the runner resolves it): - -- refused beside the runner built-ins `url`, `navigation`, `modal`, `flow` and `form`; -- refused on a row with no effective executor (no executor key, or an empty one, and no `name`); -- accepted beside `api` and `script`, and beside any other name: that is a handler a host registers, the runner hops after it too, and only the host knows whether its handler navigates. - -`ActionSchema`'s own reach is unchanged (`url`, `modal`, `flow` and `form` are refused, as before). Its message used to say that on those types the key "would parse clean and never run" and that "no renderer reads a post-success hop today". Both were false: the runner hops after every executor. The action and the rows now print one refusal text with the true reason for each executor. - -### FROM → TO - -| before | what to write instead | -| --- | --- | -| `{ actionType: 'url', target: '/tasks', onSuccess: { navigate: '/done' } }` (or `type: 'url'` on a member, or `name: 'url'` with no executor key) | the destination in `target`, the tab in `openIn`, and no `onSuccess`: the url action is itself the navigation, so the hop navigated a second time | -| the same with `navigation` | the same, with `actionType: 'url'` (the alias is refused at the executor key as well) | -| `{ actionType: 'form', target: 'task_form', onSuccess: … }` | no `onSuccess`; declare where a submitted form goes in the form view's `submitBehavior` | -| `{ actionType: 'modal', … , onSuccess: … }` or `{ actionType: 'flow', … , onSuccess: … }` | no `onSuccess`, or move the hop onto an `api` or `script` action, whose result it reads | -| `{ label: 'Go', onSuccess: { navigate: '/tasks' } }` (no executor key and no `name`) | declare the executor whose success the hop follows, `actionType: 'api'` or `actionType: 'script'` (`type` on a member), or drop `onSuccess` | - -**The one-line fix: keep `onSuccess` only on a page action row whose executor is `api`, `script` or a host-registered handler, and declare that executor.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-on-success-beside-executor-refused`; the rewrite is by hand, because where the hop belongs is the author's call. - -**What an author now sees.** `os validate`'s props gate reports the refusal at the row's `onSuccess` (`properties.onSuccess`, or `properties.actions.N.onSuccess` for a member). For example, `{ actionType: 'url', target: '/tasks', onSuccess: { navigate: '/done' } }` on an `action:button` gets: ``An `action:button`'s `onSuccess` (`actionType: 'url'`) would navigate a second time: a `url` action is itself the navigation, and the console's action runner performs the post-success hop after every executor that succeeds.``, followed by what the hop is declared beside and the prescription. The same row without `onSuccess` parses unchanged. - -**Measured producers.** None in this repository or in hotcrm (at `0f84c9c`). In objectui (at its pin `1fb1ced36243` and its main `72594f29`), one test row with no executor and no `name` carries `onSuccess` and is asserted accepted; its remedy is to declare `actionType: 'api'` on that row. A host-handler row (`actionType: 'run'`) with `onSuccess` stays accepted. diff --git a/.changeset/pre/23170-rolling-presets-close-on-today.md b/.changeset/pre/23170-rolling-presets-close-on-today.md deleted file mode 100644 index 482ad593187..00000000000 --- a/.changeset/pre/23170-rolling-presets-close-on-today.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/core': patch -'@objectstack/lint': patch -'@objectstack/verify': patch ---- - -fix(spec,core): the rolling `last_7_days`, `last_30_days` and `last_90_days` presets close on today, at the spec's prescription table, the save door's refusal and the analytics preset arm - -Clause-②: no (no accept set widens: the three rolling windows gain the inclusive `{today}` end every calendar preset already has) - -The three rolling windows were the open arm of `DATE_RANGE_PRESET_MACRO_WINDOWS`: `['{30_days_ago}', null]`, prescribed as `{ $gte: '{30_days_ago}' }`. A `$gte`-only window has no upper bound, so on a column that carries future dates (a close date, a due date) "last 30 days" also counted every later day. Measured on a `date` field with one row dated today−5 and one dated today+5: that window counted 2, while the console's preset lowering and the analytics preset arm counted 1. - -- `DATE_RANGE_PRESET_MACRO_WINDOWS` now closes every rolling window on `{today}`: `last_7_days` is `['{7_days_ago}', '{today}']`, and `last_30_days` and `last_90_days` follow the same shape. That is the inclusive last-day end every calendar preset already uses, and the extent the console's lowering already selects. The table's declared type narrows to match, `readonly [start: string, end: string]` (it was `end: string | null`). A reader that compared `end` with `null` still compiles; that branch can no longer be taken. -- The save door's refusal of a bare preset comparand (`bareDateRangePresetComparandMessage`, and the `filter-preset-comparand` lint verdict that quotes it) now prescribes the closed window. For `last_30_days` it says: write `{ $between: ['{30_days_ago}', '{today}'] }`, where it used to say `{ $gte: '{30_days_ago}' }`. -- `@objectstack/core`'s `resolveAnalyticsDateRangePreset`, the preset arm behind `timeDimensions[].dateRange`, selects the same extent. A rolling window now stops before tomorrow begins in the reference timezone, exactly as `today` does, with `endExclusive: true`. It used to end at the reference instant, so it dropped rows stamped later today. Its ends keep this module's own "the day the window stops before" spelling (`tomorrow`). Every preset this resolver returns is now `endExclusive: true`, and a caller's explicit `[a, b]` window keeps its inclusive reading, as before. -- The semantic migration entry `filter-preset-ordering-comparand-refused`, which `spec-changes.json` and `os migrate meta` carry, now names `{ $between: ["{30_days_ago}", "{today}"] }` as the window for `last_30_days` in its replacement, where it named `{ $gte: "{30_days_ago}" }`. - -If you followed the old refusal and authored `{ $gte: '{N_days_ago}' }` for a rolling preset, add `$lte: '{today}'`, or write the `$between` pair above, to stop counting future-dated rows. diff --git a/.changeset/pre/23174-nested-fault-writer.md b/.changeset/pre/23174-nested-fault-writer.md deleted file mode 100644 index 3483df9d94c..00000000000 --- a/.changeset/pre/23174-nested-fault-writer.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/types': minor -'@objectstack/service-storage': patch -'@objectstack/service-settings': patch -'@objectstack/service-i18n': patch -'@objectstack/plugin-sharing': patch ---- - -fix: a route's catch-all `500` answers its own generic text, never the caught error's message, and `@objectstack/types` exports the writer that does it, `sendServerFault` - -Clause-②: yes (widening) — `@objectstack/types` gains one export, `sendServerFault`; nothing is removed, renamed or narrowed, and no response field changes shape. - -A declared 5xx does not carry the text of the error it caught: that text can name server internals, such as a path, a host or a table. The REST layer already withheld it. The storage, settings and i18n route families and the share-link routes answer in the nested `{ success: false, error: { code, message } }` envelope and did not: their catch-all `500` sent the caught error's message to the caller. - -- **What a caller now reads.** The same `status` and `code` as before, with the route's own generic text as `error.message`: `Internal error` on the storage and i18n routes, the route's message on the settings routes (`Failed to list manifests`, `Failed to read namespace`, `Failed to write namespace`, `Action failed`), and the route's `Failed to …` message on the share-link routes. On the storage routes this includes the metadata store's outage sentence, which an operator now reads in the server log. -- **What the operator reads.** The original error, stack included, in one `[5xx]` server-fault line. The storage byte routes, which already withheld the text, logged two lines per fault and now log one. A line names the mounted route pattern when the route passes one, never the request URL. -- **Unchanged.** Every `4xx` answer. The settings `SETTINGS_CRYPTO_UNAVAILABLE` refusal, which keeps its prescription. The authorization-store outage `503`, which keeps its declared code and message. The storage `501 NOT_IMPLEMENTED` answers. On the share-link routes, any error that declares its own `status` keeps the envelope it declares. -- **`sendServerFault(res, status, code, message, thrown, request?)`** (new, `@objectstack/types`): writes `message` in the declared envelope, and hands `thrown` to the server-fault log with its stack, as the fault's one line. Use it for an arm that answers a caught fault. Keep `sendError` for a `4xx` and for a `5xx` whose text you wrote for the reader. `request` is the mounted route pattern, never the request URL. diff --git a/.changeset/pre/23175-dev-seed-workspace-timezone.md b/.changeset/pre/23175-dev-seed-workspace-timezone.md deleted file mode 100644 index 5056a0afa61..00000000000 --- a/.changeset/pre/23175-dev-seed-workspace-timezone.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth): a fresh `os dev` database opens on the console shell, not on the "Set the workspace timezone" dialog — the dev seed gives the workspace it lands its admin in the host's time zone - -Clause-②: no - -**FROM.** The dev admin seed (`NODE_ENV=development`, on unless `OS_SEED_ADMIN=0`) created its admin and left the admin's workspace with `localization.timezone` unset. The console asks an administrator for a zone while that key still resolves from the manifest default, so every fresh `os dev` database (each `--fresh` run, each new database file) opened on the dialog. The console's own workspace-creation paths already send the creator's browser zone; the seed did not go through them. - -**TO.** On the boot that seeds the admin, the seed writes the host's resolved zone (`Intl.DateTimeFormat().resolvedOptions().timeZone`, so `TZ` chooses it) to `localization.timezone` for each organization the admin is a member of. Under the default `single` posture that is the Default Organization. The write goes through the settings service, the same door the dialog's confirm uses. It is one `tenant` row per organization, with the key's IANA zone check applied there. - -**Unchanged.** -- A zone already set on any rung (the organization's, the global one, an `OS_LOCALIZATION_TIMEZONE` override) is never overwritten. -- A host that reports no zone, or reports one the settings door refuses, gets nothing written. The workspace keeps the default and the dialog still asks. -- Boots after the seeding one write nothing, so a database that already exists is not touched. -- Production is not affected: the seed stays gated to `NODE_ENV=development`. -- `sys_organization.timezone` is a separate key, which the dialog does not read. It is not written. - -**Note for in-process test boots.** `@objectstack/verify`'s harness forces `NODE_ENV=development` to get the seeded admin, so its boots now give that admin's workspace the host's zone too. On a host in `UTC` the resolved value is the same as before. On a host in another zone, a suite that relies on the default `UTC` can set `TZ=UTC`, or set the zone through `PUT /api/settings/localization` as it would for any workspace. diff --git a/.changeset/pre/23178-auth-gate-raw-host-families.md b/.changeset/pre/23178-auth-gate-raw-host-families.md deleted file mode 100644 index f530e731bbf..00000000000 --- a/.changeset/pre/23178-auth-gate-raw-host-families.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/service-datasource': minor -'@objectstack/plugin-sharing': minor -'@objectstack/plugin-webhooks': minor -'@objectstack/service-storage': minor ---- - -fix(services)!: the datasource admin, share-link, webhook redeliver and storage families refuse a session the ADR-0069 authentication policy holds - -Clause-②: no (narrowing) — a session ADR-0069 holds out of protected resources is refused by families that answered it; core's existing exports only, no published schema or export moves. - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate. REST and the runtime dispatcher already refuse such a session. Four families of routes mounted straight on the host server never asked, so they served it as an ordinary signed-in caller: - -- **`@objectstack/service-datasource`**: the datasource admin family (`registerDatasourceAdminRoutes`). Its door now asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) about the session it already reads, before the anonymous floor and the capability check. -- **`@objectstack/plugin-sharing`**: the share-link family (`registerShareLinkRoutes`). Every route, the two capability-token routes included, is now mounted through one door that refuses a context carrying `authGate`. `SharingServicePlugin`'s resolver now puts the session's posture on that context, as REST does. -- **`@objectstack/plugin-webhooks`**: the redeliver door (`WebhookOutboxPlugin`), on both of its faces: the route the plugin mounts, and the `webhooks` service's `handleRedeliver`. -- **`@objectstack/service-storage`**: the storage family, the two capability-token raw-transfer routes included. Every route is now mounted through one door. The posture is bound where every other storage gate is bound, so both the plugin's own mount and the host door `mountStorageRoutes` carry it. When a gate feature is active, the door reads the session and refuses a held one. When the gate is active but the session cannot be read, the door answers the declared `503` (`SERVICE_UNAVAILABLE`), as REST does. A direct call of `registerStorageRoutes` mounts the family exactly as before, because like the other storage gates this one is bound by the package's composition. No exported declaration changes. - -- **Refused now:** a held session gets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message`, in each family's declared error envelope, before the family does anything else. This is the answer REST gives the same session. -- **Unchanged:** a session the policy does not hold is served exactly as before, and an API-key caller is unaffected because it has no session to hold. A CORS preflight is answered as before. - -What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before these routes answer them, as on every other protected route. If you mount the storage routes through `mountStorageRoutes`, the gate is bound from the kernel's `auth` service with nothing to configure. If you mount `registerShareLinkRoutes` yourself with your own `contextFromRequest`, set `authGate` on the context it returns for a held session; the door refuses on that field. diff --git a/.changeset/pre/23178-settings-auth-gate.md b/.changeset/pre/23178-settings-auth-gate.md deleted file mode 100644 index 49310dd720a..00000000000 --- a/.changeset/pre/23178-settings-auth-gate.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/service-settings': minor ---- - -fix(service-settings)!: the settings route family refuses a session the ADR-0069 authentication policy holds, and `SettingsContext` declares the posture as an optional `authGate` - -Clause-②: yes (narrowing) — an optional `authGate` on the exported `SettingsContext` widens the public surface; the family door's refusal of a held session narrows what it answers. - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate. REST, the runtime dispatcher and the four sibling families already refuse such a session. The settings family (`registerSettingsRoutes`) resolves its own caller and never asked, so it served a held session as an ordinary signed-in caller. - -- **`SettingsContext.authGate`** (new, optional): the caller's posture, `{ code, message }`, the same shape and meaning as `ExecutionContext.authGate`. Present only while the session is held. -- **The family door** asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) about the context it resolved, on every route, before the anonymous floor and before any settings logic. -- **`SettingsServicePlugin`'s resolver** puts the posture on the context through the shared reader (`normalizeAuthGate`), from the same session read that resolves the caller. - -- **Refused now:** a held session gets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message`, in the family's declared error envelope. This is the answer REST gives the same session. -- **Unchanged:** a session the policy does not hold is served exactly as before, an API-key caller is unaffected because it has no session to hold, an anonymous caller gets the same `401`, and a CORS preflight is answered as before. - -What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before the settings routes answer them, as on every other protected route. With `SettingsServicePlugin` nothing needs configuring. If you mount `registerSettingsRoutes` yourself with your own `contextFromRequest`, a resolver that sets no `authGate` behaves exactly as before; set `authGate` on the context it returns for a held session (`normalizeAuthGate(session.user)` from `@objectstack/core` reads it), and the door refuses on that field. diff --git a/.changeset/pre/23183-app-item-read-by-package-id.md b/.changeset/pre/23183-app-item-read-by-package-id.md deleted file mode 100644 index a7bcf0d62ea..00000000000 --- a/.changeset/pre/23183-app-item-read-by-package-id.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-protocol': minor -'@objectstack/rest': patch ---- - -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')`. diff --git a/.changeset/pre/23189-identity-objects-default-organization.md b/.changeset/pre/23189-identity-objects-default-organization.md deleted file mode 100644 index 7cf17654fb5..00000000000 --- a/.changeset/pre/23189-identity-objects-default-organization.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/plugin-auth': minor -'@objectstack/runtime': patch ---- - -fix(plugin-auth)!: IdentityObjectsPlugin keeps the single posture's Default Organization at boot - -Clause-②: no (narrowing) — it restores ADR-0131 D3's Default Organization boot invariant, which `default-organization-invariant.ts` states as "[ADR-0131 D3] The Default Organization as a BOOT INVARIANT under the single posture", in the kernel that mounts `createIdentityObjectsPlugin()` without `AuthPlugin`; no published schema or export moves. - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -A kernel that mounts `createIdentityObjectsPlugin()` without `AuthPlugin` now holds its Default Organization under the `single` tenancy posture, so its seeds and system writes on tenant-scoped objects are stamped with it instead of refused with "the install holds no organization at all". - -- **Refused now:** a composition that mounts `createIdentityObjectsPlugin()` and publishes its driver after `bootstrap()` (the identity objects' `start()` reads `sys_organization` under `single`, so the boot fails with `No driver available for object 'sys_organization'`); and a kit that inserts its own organization row after boot, which now holds two organizations under `single`, so every system write that names no organization is refused as ambiguous (ADR-0131 D9). -- **Unchanged:** the walled postures (`group`, `isolated`), where `start()` creates nothing; the refusal at `init()` of `createIdentityObjectsPlugin()` mounted beside `AuthPlugin`; every composition that publishes its driver before boot; and a declaration-only boot (`os migrate`), which suppresses `start()`. - -- **Why.** ADR-0131 D3 makes the Default Organization a boot invariant under `single`: it owns every row. Registering `sys_organization` is what turns the engine's and the seed loader's zero-organization branch from "unstamped" into "refused" (D9). `IdentityObjectsPlugin` registers that object, but only `AuthPlugin.start()` created the organization, so the reduced kernel the plugin exists for (ObjectQL, `createIdentityObjectsPlugin()`, `SecurityPlugin`) refused every seed row of an organization-owned object. -- **What changed in `@objectstack/plugin-auth`.** `IdentityObjectsPlugin` gains a `start()` that, under `single`, finds or creates the Default Organization with the same invariant `AuthPlugin.start()` runs. It is idempotent, and a failure fails the boot. The posture is the `tenancy` service's when one is registered, otherwise the requested one (`OS_TENANCY_POSTURE`). Under `group` and `isolated` it does nothing. Nothing new is exported, and the plugin is still refused beside `AuthPlugin`. -- **What changed in `@objectstack/runtime`.** `AppPlugin` orders `com.objectstack.auth.identity-objects` ahead of itself, as it already ordered `com.objectstack.auth`, so the organization exists before the inline seed loads, wherever a host lists the two plugins. -- **If your test kit mounts `createIdentityObjectsPlugin()`.** Its kernel now reads and writes `sys_organization` during `bootstrap()` under `single`. Publish the driver before the boot (a `driver.default` service, or a datasource plugin), not with `registerDriver()` after it. Otherwise the boot fails with `No driver available for object 'sys_organization'`. A kit that inserted its own organization row to stand in for the missing one now holds two, and a system write that names no organization is refused as ambiguous; drop that insert and read the Default Organization (`slug: 'default'`) the boot created. diff --git a/.changeset/pre/23190-auth-gate-cli-sites.md b/.changeset/pre/23190-auth-gate-cli-sites.md deleted file mode 100644 index 994e089e60f..00000000000 --- a/.changeset/pre/23190-auth-gate-cli-sites.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/runtime': minor -'@objectstack/rest': minor -'@objectstack/cloud-connection': minor ---- - -fix(runtime,rest,cloud-connection)!: the declared-endpoint fallback, REST's direct-mounted publish and federation routes, and cloud-connection's session doors refuse a session the ADR-0069 authentication policy holds - -Clause-②: no (narrowing) — a session ADR-0069 holds out of protected resources is refused by doors that answered it; core's existing exports only, no published schema or export moves. - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate. REST's `enforceAuth` and the runtime dispatcher already refuse such a session. Three groups of doors that resolve a session on their own never asked, so they served it as an ordinary signed-in caller: - -- **`@objectstack/runtime`**: the declared-endpoint fallback (app-declared `apis:` endpoints, served by the dispatcher plugin's fallback seam). Its policy chain now asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) before `authRequired` and before the endpoint's target runs, on every declared endpoint, an `authRequired: false` one included, because the target runs as the caller. While a gate feature is active the chain reads the session for this, and when an active gate cannot read the session the request is answered `503` (`SERVICE_UNAVAILABLE`), as REST answers it. No declared path is handed to the allow-list, so no endpoint can exempt itself through the prefix it sits under. -- **`@objectstack/rest`**: the direct-mounted package publish door (`registerPackageRoutes`) and the external-datasource federation family (`registerExternalDatasourceRoutes`). Each refuses on the `authGate` the REST resolver already puts on the execution context it hands them. -- **`@objectstack/cloud-connection`**: `CloudConnectionPlugin`'s seven session doors (bind, poll, unbind, install, and the three installed-state reads) and all five `MarketplaceInstallLocalPlugin` doors (install, list, uninstall, reseed, purge). Each plugin asks the gate once, where it resolves the session, and every door that resolves one refuses on it. - -- **Refused now:** a held session gets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message`, in each door's declared error envelope, before the door does anything else. This is the answer REST gives the same session. -- **Unchanged:** a session the policy does not hold is served exactly as before, an API-key caller is unaffected because it has no session to hold, and an anonymous caller gets the same `401`. `CloudConnectionPlugin`'s status read and `MarketplaceProxyPlugin`'s catalog proxy resolve no session and answer every caller alike, so they are unchanged. - -What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before these doors answer them, as on every other protected route. Nothing needs configuring: the posture comes from the kernel's `auth` service, as it does for REST and the dispatcher. diff --git a/.changeset/pre/23198-container-expansion-reconcile.md b/.changeset/pre/23198-container-expansion-reconcile.md deleted file mode 100644 index 3264ef49019..00000000000 --- a/.changeset/pre/23198-container-expansion-reconcile.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -Republishing a view container without one of its views now stops serving that view, without a restart - -Clause-②: no - -- A view container (a `defineView` document, such as a package's `showcase_task` views) expands to one view per entry, registered in the process registry under `.`. A publish of the container registered the views it expanded to and removed none it no longer expanded to. So after `POST /api/v1/meta/view/NAME/publish` of a container whose `listViews` lost an entry, `GET /api/v1/meta/view`, `GET /api/v1/meta/view?object=OBJECT` and the by-name read of that view kept serving it until the process restarted. -- Every write that changes a view container's stored row now leaves the registry holding the views that row expands to: a publish, a save that publishes, a rollback, a revert, a replica's convergence, a reset and a delete. So does the list read's and the boot's hydration of a stored row. The write answers after this, so a read that follows it serves the published set. -- A view the container no longer expands to is healed the way a reset heals it: where a package ships a view of that name, the package's view is served again, and a name nothing else serves is removed. As for a reset, a name whose MetadataService read fails stays registered until a restart. -- When one container name has two stored rows (one bound to no package, one bound to a package), each row is compared with what that row registered before. A republish of one row removes only the views that row dropped, and the list read leaves both rows' views registered however often it runs. A view both rows expand to, which one row drops, is served from the other row's stored row and registered again by the next list read. A reset or delete of the name heals every row's views, as before. -- Unchanged: an organization-scoped write touches no registry entry, a baseline is re-registered on a control-plane kernel only, and a view name another package ships is never registered or healed for the container. Every other type registers and resets as before. -- No public export, signature, schema or accept-set change. In the built entry declarations, `ObjectStackProtocolImplementation` gains one untyped `private` member name and loses two `private static` ones. diff --git a/.changeset/pre/23198-orphan-adoption-keeps-overlays.md b/.changeset/pre/23198-orphan-adoption-keeps-overlays.md deleted file mode 100644 index bda2ee8fa53..00000000000 --- a/.changeset/pre/23198-orphan-adoption-keeps-overlays.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -Adopting loose items into a base no longer moves an overlay of a packaged item, and the views it does move are served from the base at once - -Clause-②: no - -- `POST /api/v1/packages/ID/adopt-orphans` rebound every metadata row bound to no package into the base. That included a row that overlays an item a package ships, such as a list view edited and saved from the record list, or an edited copy of a package's view container. Once bound to the base, such a row stopped customising the packaged item. After a restart, an adopted overlay of a package's view container added views under its own name, and the package's own view was served again. An adopted overlay of one packaged view was listed twice under its name. -- ADR-0070 keeps an overlay of a packaged item bound to the item it customises, so the adoption now leaves such a row as it is stored. A row is skipped exactly when a save of it would be an overlay (`override-artifact`). An environment whose only rows bound to no package are such overlays now answers `reassignedCount: 0` and `success: false`. -- Each view row the adoption does rebind is now written through to the process registry with the binding it left. So `GET /api/v1/meta/view`, `GET /api/v1/meta/view?object=OBJECT` and the by-name read serve it from the base right away, not after the next restart. -- A view container's views are reconciled the way a publish reconciles them. What the row registered before the move counts as its own, so a later write of a row of that name bound to no package no longer removes the moved row's views. -- Unchanged: the request and the response shape (`success`, `reassignedCount`, `reassigned`, `targetPackageId`). Also unchanged: rows already bound to a package, organization-scoped rows, and rows of other types, which are rebound in storage only. -- There is no public export, signature or schema change. diff --git a/.changeset/pre/23207-related-list-binding-split.md b/.changeset/pre/23207-related-list-binding-split.md deleted file mode 100644 index fe44a95cac9..00000000000 --- a/.changeset/pre/23207-related-list-binding-split.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/lint': patch ---- - -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. diff --git a/.changeset/pre/23210-cbp-extension-merged-master.md b/.changeset/pre/23210-cbp-extension-merged-master.md deleted file mode 100644 index a6fcffbf104..00000000000 --- a/.changeset/pre/23210-cbp-extension-merged-master.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/lint": minor ---- - -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`. diff --git a/.changeset/pre/23214-dispatcher-query-arity.md b/.changeset/pre/23214-dispatcher-query-arity.md deleted file mode 100644 index 2543af5a454..00000000000 --- a/.changeset/pre/23214-dispatcher-query-arity.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/rest': minor -'@objectstack/runtime': minor ---- - -fix(runtime)!: the runtime dispatcher's read points refuse a repeated single-valued query name as their `RestServer` twins do, instead of reading one value, a joined value or an array - -The runtime dispatcher's `/meta`, `/ui`, `/i18n`, `/security`, `/packages` and `/data` read points each read a query name as ONE value. None of them guarded against the name arriving twice, because the only door in front of most of them, the `@objectstack/hono` `createHonoApp` catch-all, has always flattened a repeat to its last value. Handed an array, they answered wrongly: `GET /meta/object/:name/state/:field?from=a&from=b` answered a dead end for the joined state `'a,b'`; `_drafts`, `/ui/view` and the `/data` filter slot passed the array into the protocol call; both `/i18n` query-locale arms threw, which surfaced as a `500`; and the `/meta` list, the book tree, `/security/suggested-bindings` and `/packages` answered an empty or unscoped `200`. Each now refuses the repeat before any protocol or service call. - -`@objectstack/rest` publishes the lists and rules both transports read, so the two cannot drift: `META_QUERY_ARITY_DOORS` (the single-valued names of the `/meta` list, book tree, `_drafts` and state machine doors, which `RestServer`'s twins of those doors now read from), the decision `metaQueryArityRefusal(door, query)`, and `assertFilterParamSuppliedOnce`, the `/data` list's filter-slot rule, on the package entry. - -Clause-②: yes (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -What changes for you: a request that repeats one of the names below to the runtime dispatcher is now refused `400` in the dispatcher's own envelope, `{ success: false, error: { code, message } }`, instead of being answered with one value, a joined value or an array. The `code` is `VALIDATION_ERROR` with the sentence `RestServer` answers ("The "NAME" query parameter was supplied N times…"), except on the `/data` filter slot, which answers `INVALID_FILTER` with `RestServer`'s repeated-filter sentence. Send each name once. - -- `GET /meta/:type`: `package`, `preview`, `object`, `include`, `id`. -- `GET /meta/book/:name/tree`: `package`. -- `GET /meta/_drafts`: `packageId`, `type`. -- `GET /meta/object/:name/state/:field`: `from`. -- `GET /ui/view/:object`: `type`, when the path names no type. -- `GET /i18n/translations` and `GET /i18n/labels/:object`: `locale`, when the path names no locale. -- `GET /security/suggested-bindings`: `status`, `packageId`. A repeated `status` was already refused `400`, as an unknown status; it now gets the multiplicity sentence. -- `GET /packages`: `status`, `type`, beside `enabled`, which was already refused. -- `GET /data/:object`: every spelling of the filter slot (`filter`, `where` and their aliases, `filters`, `$filter`). Genuinely multi-valued names such as `select` and `expand` are untouched. - -A name sent once is answered as before, and one sent once as a one-element array now reads as that one value everywhere. Where a caller meets this today: `GET /packages` through `@objectstack/plugin-hono-server`, whose adapter already keeps repeats as arrays. Every other point is reached on a shipped host only through `createHonoApp`, which still flattens a repeat to its last value, so until that adapter keeps repeats the refusals are visible only to a caller of `HttpDispatcher.dispatch()` that passes an array. The `@objectstack/client` SDK sends each of these names once. diff --git a/.changeset/pre/23214-hono-query-multiplicity.md b/.changeset/pre/23214-hono-query-multiplicity.md deleted file mode 100644 index 6f3e2bbd9fd..00000000000 --- a/.changeset/pre/23214-hono-query-multiplicity.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/hono': minor -'@objectstack/runtime': minor ---- - -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. diff --git a/.changeset/pre/23215-inline-forward-parse.md b/.changeset/pre/23215-inline-forward-parse.md deleted file mode 100644 index 6b2c80cd814..00000000000 --- a/.changeset/pre/23215-inline-forward-parse.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/service-mailbox': minor ---- - -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. diff --git a/.changeset/pre/23216-routes-retire-text.md b/.changeset/pre/23216-routes-retire-text.md deleted file mode 100644 index 86dccd979ca..00000000000 --- a/.changeset/pre/23216-routes-retire-text.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Clause-②: no - -The `manifest.contributes.routes` and `App.apis` retire messages (and the published -`plugin-manifest-contributes-routes-retired` migration text) no longer send app authors to an -imperative `http.server` route registered on `kernel:ready`. An app declares its endpoint under -`defineStack({ apis })` (`type: 'object_operation'` or `'flow'`, ADR-0121), computes in a flow, -and registers code as a stack function (`defineStack({ functions })`) or a connector. The -`http.server` service itself is unchanged: platform plugins keep mounting their own doors on it. -No key is added or removed. diff --git a/.changeset/pre/23217-core-request-execution-context.md b/.changeset/pre/23217-core-request-execution-context.md deleted file mode 100644 index c8383443ad6..00000000000 --- a/.changeset/pre/23217-core-request-execution-context.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/core": minor ---- - -feat(core): export `resolveRequestExecutionContext`, the one composition that turns an inbound request into the caller's `ExecutionContext` - -Clause-②: yes (widening) - -- **New export from `@objectstack/core`'s `.` entry.** `resolveRequestExecutionContext(input)` reads the session through the auth service (the in-process rule: a request carrying a session cookie reads without renewal), classifies the tenancy posture with `classifyAdmissionTenancyPosture`, resolves the principal and its grants with `resolveAuthzContext`, resolves the reference localization, decides the ADR-0069 authentication-policy gate and assembles the envelope with `assembleExecutionContext` or `assembleExecutionContextOrGuest`. Its input type `RequestExecutionContextInput` and the types `RequestAuthService`, `EstablishedRequestSession` and `RequestAdmission` are exported with it. -- **Two choices, made once.** `anonymous: 'fail-closed'` yields no context for a request that resolves no principal (the door answers 401); `anonymous: 'guest'` serves it as the guest principal with the guest anchor's grants. `authGate: 'withhold' | 'resolved' | 'reread-when-active'` says whether the gate's posture rides the envelope, and how it is read. -- **Every other difference between doors is a named, required parameter:** `credentials`, `sessionApi`, `session`, `tenancy`, `localization`, `requestLocale`, `accessToken`, `oauth` and `onAdmission`. A door with no value passes `undefined` or the withholding literal, so no door can drop an input by omitting it. -- **First caller.** The runtime dispatcher's identity door (`@objectstack/runtime`'s `resolveExecutionContext`) now calls it. What that door answers is unchanged: the same guest envelope, the same 503 for a tenancy service that failed to build, the same OAuth agent principal on `/mcp`. -- **Faults propagate.** It catches nothing around the resolver: an unreadable permission store or tenancy service (`AuthzStoreUnavailableError`, 503) and an API key its own lifecycle refuses (`ApiKeyLifecycleRefusalError`, 403) reach the caller, which answers them at its own net. diff --git a/.changeset/pre/23217-s2-rest-door.md b/.changeset/pre/23217-s2-rest-door.md deleted file mode 100644 index 5a559173391..00000000000 --- a/.changeset/pre/23217-s2-rest-door.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -REST's request identity door resolves through `@objectstack/core`'s `resolveRequestExecutionContext`, the one request-to-`ExecutionContext` composition - -Clause-②: no - -- `RestServer` resolves the caller of every data, metadata and package route by calling `resolveRequestExecutionContext` with this door's differences as named parameters: `anonymous: 'fail-closed'`, `authGate: 'reread-when-active'`, `credentials: 'api-key-and-session'`, `sessionApi: 'required'`, `session: 'request'`, its settings lookup for the localization, its own request-locale parse, `accessToken: 'withhold'`, and no OAuth provenance or admission channel. The environment and auth-service resolution, the header normalisation, the data engine, the tenancy wiring, the per-request memo, the `Server-Timing` disclosure and the fail-closed `catch` stay at the door. -- The answers are the ones the door gave before: an anonymous caller gets `401` with the shared body; a session the authentication policy holds gets `403` with its code and message while a gate feature is active; an API key is judged by its own owner's posture; an unreadable permission store, tenancy service or active gate re-read gets `503 SERVICE_UNAVAILABLE`; and an API key its own lifecycle refuses gets its `403` and code. -- One ordering moved, and it shows only when two faults coincide on the single-kernel wiring. The data engine is now resolved before the auth service's session read is asked for. If the configured engine provider fails AND the auth service exposes no session read (or its `getApi()` rejects), the answer is now `503 SERVICE_UNAVAILABLE` (the engine outage) instead of `401`. With a working auth service, the engine outage was already `503`. -- Nothing to do for a consumer: no export, route, configuration key or response shape changed. diff --git a/.changeset/pre/23217-s3-settings-door.md b/.changeset/pre/23217-s3-settings-door.md deleted file mode 100644 index 1f7d3e88920..00000000000 --- a/.changeset/pre/23217-s3-settings-door.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/service-settings': patch ---- - -The settings routes' identity door resolves through `@objectstack/core`'s `resolveRequestExecutionContext`, the one request-to-`ExecutionContext` composition - -Clause-②: no - -- `SettingsServicePlugin` resolves the caller of every `/api/settings` route by calling `resolveRequestExecutionContext` with this door's differences as named parameters: `anonymous: 'fail-closed'`, `authGate: 'resolved'`, `credentials: 'api-key-and-session'`, `sessionApi: 'optional'`, `session: 'request'`, its own way to reach the `tenancy` service, `localization: 'withhold'`, no request locale, `accessToken: 'withhold'`, and no OAuth provenance or admission channel. The engine and auth-service lookups, the header normalisation, the tenancy wiring, the projection to `SettingsContext` and the fail-closed `catch` stay at the door. -- The answers are the ones the door gave before: an anonymous caller, an unrecognised cookie, a failed session read and a refused API key get `401 UNAUTHENTICATED`; a session or an API key owner the authentication policy holds gets `403` with its code and message; an unreadable permission store or tenancy service gets `503 SERVICE_UNAVAILABLE`; and an API key its own lifecycle refuses gets its `403` and code. A one-off probe of 29 fault wirings through the listing route read the same answer on both sides of the change. -- Nothing to do for a consumer: no export, route, configuration key or response shape changed. diff --git a/.changeset/pre/23217-s4-storage-door.md b/.changeset/pre/23217-s4-storage-door.md deleted file mode 100644 index f74217e2e8c..00000000000 --- a/.changeset/pre/23217-s4-storage-door.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/service-storage': patch ---- - -The storage download door resolves its caller through `@objectstack/core`'s `resolveRequestExecutionContext`, the one request-to-`ExecutionContext` composition - -Clause-②: no - -- The download authorizer that `StorageServicePlugin` and `mountStorageRoutes` bind for parent-governed files resolves the caller by calling `resolveRequestExecutionContext` with this door's differences as named parameters: `anonymous: 'fail-closed'`, `authGate: 'resolved'`, `credentials: 'api-key-and-session'`, `sessionApi: 'optional'`, `session: 'request'`, its own way to reach the `tenancy` service, `localization: 'withhold'`, no request locale, `accessToken: 'carry'`, and no OAuth provenance or admission channel. The auth-service lookup, the header normalisation, the tenancy wiring, the verdict reads and the fail-closed `catch` stay at the door. The family's ADR-0069 gate reader, which runs before every storage route, is unchanged. -- The answers are the ones the door gave before: an anonymous caller, an unrecognised cookie, a failed session read with no gate active and a refused API key get `401 AUTH_REQUIRED`; a session or an API key owner the authentication policy holds gets `403` with its code and message from the family's gate; an unreadable permission store or tenancy service gets `503 SERVICE_UNAVAILABLE`; and an API key its own lifecycle refuses gets its `403` and code. A one-off probe of 47 fault wirings through the download route read the same answer on both sides of the change. -- The context the door hands to the owner-record read and to a file-access delegate is now the assembled `ExecutionContext` envelope rather than the resolver's raw output: it also states `isSystem: false` and `principalKind: 'human'`, and no longer carries the internal `credential` marker, which no reader of that context consumes. -- Nothing to do for a consumer: no export, route, configuration key or response shape changed. diff --git a/.changeset/pre/23217-s5-auth-me-door.md b/.changeset/pre/23217-s5-auth-me-door.md deleted file mode 100644 index aaf58d6b9e1..00000000000 --- a/.changeset/pre/23217-s5-auth-me-door.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/plugin-hono-server': patch ---- - -The `/auth/me` endpoints resolve their caller through `@objectstack/core`'s `resolveRequestExecutionContext`, the one request-to-`ExecutionContext` composition - -Clause-②: no - -- `makeExecutionContextResolver`, which `GET /auth/me/permissions`, `GET /auth/me/localization` and `GET /me/apps` call, resolves the caller by calling `resolveRequestExecutionContext` with this door's differences as named parameters: `anonymous: 'fail-closed'`, `authGate: 'withhold'`, `credentials: 'session-only'`, `sessionApi: 'required'`, `session: 'request'`, no tenancy posture, `localization: 'withhold'`, no request locale, `accessToken: 'withhold'`, and no OAuth provenance or admission channel. The per-request service reads, the environment-kernel resolution and the fail-closed `catch` stay at the door. Every platform door that resolves a request's caller now calls the one composition. -- One answer changes. When the auth service's session read carries `session.userId` but no `user.id` (no user object, a user without an id, or `user.id: null`), these endpoints answered as signed out; they now answer as signed in as that user, which is how the runtime dispatcher, REST, the settings routes and the storage download already read it. No session producer yields that shape: in this repository better-auth's session read returns no session when the session's user is missing and plugin-auth passes its read through, and cloud's control plane uses the same plugin-auth read. -- Every other answer is unchanged: no credential, an unrecognised cookie, a failing session read, an API key alone and a host with no auth service get each route's signed-out body; a session the authentication policy holds is still served on these allow-listed routes, with no `authGate` on its context; an unreadable permission store is still the host's `503 SERVICE_UNAVAILABLE`. -- Nothing to do for a consumer: no export, route, configuration key or response shape changed. diff --git a/.changeset/pre/23221-cascade-delete-hook-signal.md b/.changeset/pre/23221-cascade-delete-hook-signal.md deleted file mode 100644 index 79ca05d795a..00000000000 --- a/.changeset/pre/23221-cascade-delete-hook-signal.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/objectql': minor -'@objectstack/runtime': minor ---- - -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. diff --git a/.changeset/pre/23224-guest-grants-channel.md b/.changeset/pre/23224-guest-grants-channel.md deleted file mode 100644 index 60f8a6354e0..00000000000 --- a/.changeset/pre/23224-guest-grants-channel.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/objectql': patch -'@objectstack/plugin-security': patch -'@objectstack/plugin-sharing': patch -'@objectstack/runtime': patch ---- - -feat(security): the guest's grants channel — an anonymous request at a guest-entry door holds the sets bound to the `guest` anchor, in the one organization the deployment resolves for it (ADR-0138 E1, D3/D4) - -ADR-0087 disposition: none. No authorable key, accept set or stored shape moves: no spec schema, metadata type, stored row or published response field is added, removed, renamed or re-shaped. A declared binding that was not enforced becomes enforced (ADR-0090 D9, as ADR-0138 D3 amends it). - -**The bindings that start granting.** On the reference apps, none: `app-showcase`, `app-crm`, `app-multi-package` and `app-todo` declare no definition of the `guest` anchor that names a set. `app-showcase` ships `showcase_guest_portal` as a capability an administrator may bind and binds it to no anchor (its `client_portal_user` position is an ordinary position), and `app-crm`'s `guest_portal` is the public form doors' set, which keeps its own posture. No reference app declares a permission set named `guest`, so no grant stops either. A set starts granting only where a deployment holds an environment definition of the `guest` position that names it. - -What changes: - -- **`@objectstack/core`** — the anonymous branch of `resolveAuthzContext` resolves the guest's grants on a face that takes the explicit guest entry (the new `guestEntry` input). It reads the sets the `guest` position's definition names, through the same position-bound read every position uses, and the activation ledger as for any held set. It resolves them only in the organization the guest entry's organization step derives (`resolveGuestOrganization`), which asks ADR-0131 D9's predicate and no second spelling of it. With nothing bound, the guest holds no set and no read is issued. The predicate itself, `resolveSystemWriteOrganization`, moves to `@objectstack/core` so both of its askers share it. New exports: `resolveGuestOrganization`, `resolveSystemWriteOrganization` (with `SystemWriteOrganizationDecision` and `SystemWriteRefusalReason`), and `GuestOrganizationUnresolvedError` with `isGuestOrganizationUnresolvedError` and its constants. -- **`@objectstack/objectql`** — `resolveSystemWriteOrganization` and its two types are re-exported from `@objectstack/core` as the same bindings. Nothing a caller imports changes. -- **`@objectstack/plugin-security`** — the set resolution treats the guest's sets as any requested set, and no longer folds the guest's position into the request. A permission set that merely shares the anchor's name no longer reaches an anonymous caller, and the baseline and `everyone` never did. At the CRUD gate, a guest that reaches a data operation acting in no organization, on a deployment where the organization step refuses, is refused before the operation runs. -- **`@objectstack/plugin-sharing`** — the read filter answers deny-all for the guest on every object that record sharing scopes, before the sharing-model branch: the guest reaches rows only through share links, never through the object's sharing model (ADR-0138 D3 item 6, as the maintainer's ruling C amends it). `externalSharingModel` is still not read for any caller. Share links are redeemed at their own door and are unchanged. -- **`@objectstack/runtime`** — the runtime face, which serves the guest, asks for the guest's grants. - -What an anonymous caller sees at an `authRequired: false` endpoint of `type: 'object_operation'`: - -- **One organization, a set bound to `guest`:** the operation runs as the guest, under that set, in that organization. A read the set grants is admitted and returns no row, whatever the object's sharing model (`private`, `public_read`, `public_read_write` or `controlled_by_parent`): the guest reaches a record only through a share link. A create the set grants lands in that organization with no owner. -- **Nothing bound:** `403 PERMISSION_DENIED`, as before. -- **A walled deployment, several organizations, or none:** `501 NOT_IMPLEMENTED`, naming the missing site binding (ADR-0138 D5), where it used to be `403 PERMISSION_DENIED` with nothing granted. Nothing that was served stops being served. The operator's log names the reason once. - -Unchanged: share links, which still serve an anonymous caller the one record they name, with `publicSharing.eligibility` checked at the mint and at every redemption; the public form doors, which keep their own posture and organization; the flow door, which runs under the flow's own declared posture; every surface that answers an anonymous request `401`; and every signed-in caller. - -Level: `minor` for `@objectstack/core`, because new exported symbols widen its published index; `patch` for the other four, whose exports do not move. diff --git a/.changeset/pre/23225-d2b-anonymous-elevated-flow-refused.md b/.changeset/pre/23225-d2b-anonymous-elevated-flow-refused.md deleted file mode 100644 index 7431e9b8a17..00000000000 --- a/.changeset/pre/23225-d2b-anonymous-elevated-flow-refused.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata': minor -'@objectstack/metadata-protocol': minor ---- - -feat(spec)!: publish refuses an anonymous flow endpoint whose target flow runs as `system`, in both directions, with the prescription (ADR-0138 D2b) - -Clause-②: yes (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. - -ADR-0138 D2b decides that no anonymous door triggers an elevated flow directly. An `authRequired: false` endpoint of `type: 'flow'` whose `target` flow declares `runAs: 'system'` ran every anonymous request's data steps as the system principal, past row-level security, with the endpoint's rate limit as the only brake. The runtime's own elevated-start refusal covers only self-triggered flow types (`autolaunched`, `record_change`, `schedule`), so a `system` flow of type `api` or `screen` behind such an endpoint executed elevated. Publish now refuses the pair, in both directions, because either item can be the last one edited: the endpoint's publish reads its target's `runAs`, and a flow's publish that sets `runAs: 'system'` reads the anonymous endpoints that target it. Each refusal names the endpoint and the flow and carries the prescription. - -**What is refused, and where.** Every publish door that holds the pair calls one judge, `anonymousElevatedFlowPairs` in `@objectstack/spec/api`: - -- **The stack parse** (`defineStack`, `objectstack validate`, `objectstack build`, the artifact load): a stack whose anonymous flow endpoint names one of its own flows declaring `runAs: 'system'`. One zod issue per pair, `code: 'custom'` at `apis[i].authRequired`; `defineStack` throws its schema refusal, `STACK_SCHEMA_INVALID` (422), carrying the issue. -- **The package publish** (`MetadataManager.publishPackage`, `POST /api/v1/packages/:id/publish`): an `api` item of the package naming a `system` flow anywhere in the manager's index, reported at the endpoint; and a `flow` item of the package declaring `runAs: 'system'` that an anonymous endpoint outside the package names, reported at the flow. The package publishes nothing (`success: false`, the pair in `validationErrors`), and `validate: false` does not turn it off. -- **A flow published through the metadata API** (`saveMetaItem` in publish mode, so `PUT /api/v1/meta/flow/:name`; a draft's publish, `POST /api/v1/meta/flow/:name/publish`; and `POST /api/v1/packages/:id/publish-drafts`): a flow going `active` with `runAs: 'system'` while a declared anonymous flow endpoint names it. `INVALID_METADATA` (422), `issues[0].path: 'runAs'`, nothing written; a publish-drafts batch aborts on it. A draft save is not judged (a draft does not execute; its publish is), and neither is a `migrate-stored` re-save of a row that already exists. - -**Unchanged.** Each item alone publishes as before: an authenticated endpoint on a `system` flow, an anonymous endpoint on a `user` flow (or one that leaves `runAs` at its default), and an anonymous `object_operation` endpoint are all accepted. A stored flow row that already pairs this way keeps its bytes and keeps executing as published; its next publish is refused. A guest-written row that fires a record-change flow is outside the refusal: the record leaves that indirect path undecided, because its trigger is the write, not the door. - -## FROM → TO - -| door | FROM | TO | -|:--|:--|:--| -| `defineStack` / `objectstack validate` | `apis: [{ name: 'acme_intake_api', type: 'flow', target: 'acme_intake', authRequired: false, rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, … }]` beside `flows: [{ name: 'acme_intake', runAs: 'system', … }]` accepted; every anonymous request ran `acme_intake` as the system principal | refused, `STACK_SCHEMA_INVALID` 422, the issue at `apis[0].authRequired` naming both items and the prescription | -| package publish | a package carrying either half of the pair, with the other half anywhere in the manager's index, published | refused, `success: false`, the pair reported at the package's own item | -| metadata API flow publish | `PUT /api/v1/meta/flow/acme_intake` with `runAs: 'system'` while an anonymous endpoint names it saved and armed it | refused, `INVALID_METADATA` 422, the issue at `runAs`, nothing written | - -**The one-line fix: serve the caller another way — an authenticated endpoint (`authRequired: true`) for a partner that can hold a credential, the flow's signed inbound hook (ADR-0041: a start node with `triggerType: 'api'`, a `hookId` and a `secret`, which may keep `runAs: 'system'`) for a partner webhook, or a public form (a form view whose `sharing` sets `allowAnonymous: true`) for anonymous intake.** `runAs: 'automation'` joins that list when ADR-0073 M2 adds the value. - -No D2 conversion rewrites it: which channel replaces the anonymous endpoint depends on who the caller is, and whether the flow needs elevation is a judgment about its data steps, so no conversion can choose a posture for the author. Its D3 record is the semantic entry `api-endpoint-anonymous-elevated-flow-refused` (protocol 18). - -**`@objectstack/spec`.** The judge and its two refusal sentences join `@objectstack/spec/api`: `anonymousElevatedFlowPairs`, `anonymousFlowDoorTarget`, `anonymousEndpointElevatedFlowMessage` (the endpoint direction), `elevatedFlowAnonymousEndpointsMessage` (the flow direction) and `validateAnonymousElevatedFlows` (the stack's issues), with the types `AnonymousFlowDoorCandidate`, `ElevatedFlowCandidate` and `AnonymousElevatedFlowPair`. `ObjectStackDefinitionSchema` runs the judge beside the per-endpoint gates. The `runAs` description on the flow schema, which prescribed `system` for every user-less trigger, `api` included, now names the exception and what serves that caller. - -**`@objectstack/metadata`.** `MetadataManager.publishPackage` runs the judge over the package's `api` and `flow` items against the index the endpoint matcher serves from. - -**`@objectstack/metadata-protocol`.** The flow publish doors run the judge against the declared endpoints: the engine registry's `api` items and the `metadata` service's. - -**Who is affected, measured.** No in-repository stack pairs an anonymous flow endpoint with a `system` flow: `examples/` declares no `authRequired: false` endpoint, and every anonymous flow endpoint in the test fixtures names a `runAs: 'user'` flow. The partner-webhook example, `showcase_inbound_task_webhook`, takes the signed inbound hook with `runAs: 'system'` and is untouched. diff --git a/.changeset/pre/23225-domain-floor-anonymous-deny.md b/.changeset/pre/23225-domain-floor-anonymous-deny.md deleted file mode 100644 index 970ac65d2a5..00000000000 --- a/.changeset/pre/23225-domain-floor-anonymous-deny.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime)!: the notification inbox, API-key mint and share-link management dispatcher domains decide an anonymous request once, as each domain's first statement, so an anonymous caller is answered 401 whatever the deployment carries - -ADR-0138 D2 decides that everything off its closed list of six door classes answers 401, and that "the decision is taken once per domain, as the domain's first statement, never surface by surface". Three dispatcher domains decided it per face, below another answer: `/notifications` below its inbox capability probe, `/keys` below its method check, and `/share-links` inside its management faces, below the share-link service probe. Each now opens with the shared `shouldDenyAnonymous` decision, before any service is resolved and before any face is chosen, so a face added to one of them later is refused to an anonymous caller from the day it lands. - -Clause-②: no (narrowing) - - - -**BREAKING** (a refusal moved ahead of the answers it used to follow), shipped as `minor` under the launch-window convention for breaking changes. - -What an unauthenticated caller is answered now, on each domain: - -- `/notifications`: `401 UNAUTHENTICATED` on every face. On a deployment with no inbox (a send-only notification provider, or none) the anonymous answer was the `501` capability answer; it is now the 401, and a signed-in caller there still gets the 501. -- `/keys`: `401 UNAUTHENTICATED` for every verb. A verb other than `POST` used to be answered `405` to an anonymous caller; a signed-in caller still gets the 405. -- `/share-links`: `401 UNAUTHENTICATED` on the create, list and revoke faces and on every path the domain does not serve. On a deployment without the share-link service the anonymous answer was `501`; a signed-in caller there still gets the 501. The two token faces, `GET /share-links/:token/resolve` and `GET /share-links/:token/messages`, are unchanged: the capability token admits them (ADR-0138 D2 class 2). - -The refusal is the dispatcher's standard envelope, `{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }`, with the shared message every other dispatcher domain answers with, so a client reads one anonymous refusal on all of them. Nothing a signed-in caller is served changes, and on the standalone server the booted showcase already answered 401 on the faces it mounts. diff --git a/.changeset/pre/23225-ui-data-domain-floor.md b/.changeset/pre/23225-ui-data-domain-floor.md deleted file mode 100644 index 71bbf1a403e..00000000000 --- a/.changeset/pre/23225-ui-data-domain-floor.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime)!: the `/ui` and `/data` dispatcher domains decide an anonymous request once, as each domain's first statement, so an unauthenticated caller is answered 401 on every face they serve - -ADR-0138 D2 decides that everything off its closed list of six door classes answers 401, and that "the decision is taken once per domain, as the domain's first statement, never surface by surface". The rendered-view domain `/ui` and the CRUD and query domain `/data` made no anonymous decision of their own: an unauthenticated caller was answered by whichever face it named. Each now opens with the shared `shouldDenyAnonymous` decision, before the path is parsed and before any service is resolved, so a face added to either later is refused to an anonymous caller from the day it lands. No door class D2 lists runs through either domain. - -Clause-②: no (narrowing) - - - -**BREAKING** (a refusal moved ahead of the answers it used to follow), shipped as `minor` under the launch-window convention for breaking changes. - -What an unauthenticated caller is answered now, wherever these dispatcher domains serve the request. Where `@objectstack/rest` is mounted it serves the same paths itself and already answered 401, so the standalone server's answers do not move. - -- `/data`: `401 UNAUTHENTICATED` on every read and write face, and on every path or verb the domain does not serve. The answer used to come from the face: the CRUD gate's `403 PERMISSION_DENIED` for the guest, a `400` for a request naming no object, a `428` for an unresolved environment on a multi-tenant host. A CORS preflight keeps its pass. -- `/ui`: `401 UNAUTHENTICATED` on every face, and on every path the domain does not serve. - -The refusal is the dispatcher's standard envelope, `{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }`, with the shared message every other dispatcher domain answers with. Nothing a signed-in caller is served changes. The decision is asked about the caller's user id, as the `/notifications` and `/keys` floors ask it: `dispatch()` resolves identity for every request, so no caller on the wire is a system context, and an in-process call of either handler that passes a system context with no user id is refused too. diff --git a/.changeset/pre/23226-guest-owner-stamp.md b/.changeset/pre/23226-guest-owner-stamp.md deleted file mode 100644 index 0be015d4794..00000000000 --- a/.changeset/pre/23226-guest-owner-stamp.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -feat(security)!: an anonymous caller never owns a record and never sets its owner — the owner-anchor stamp's guest branch (ADR-0138 E3, D1) - -Clause-②: no (narrowing) — a guest-supplied owner is refused at the endpoints and the owner column never holds the guest or the system principal. That tightens what a guest write may carry and admits nothing refused today. - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -ADR-0138 D1 rules that the guest is a principal, never an owner. The owner-anchor stamp in `SecurityPlugin` (the step that stamps an absent `owner_id` with the acting user and refuses a forged one) now carries the guest branch the record names as D1's enforcement point. It applies to the guest envelope, an anonymous request served by an `authRequired: false` endpoint: - -- **Nothing is stamped for a guest.** An absent owner stays unset: never the guest, never the system principal. An empty `owner_id` (an empty string) is left unset too, where it used to be stored as an empty string. -- **A guest-supplied owner is refused**, on create and on update, with `PERMISSION_DENIED` (`403`), whatever the guest is granted. The transfer grant is never consulted for a guest. Before this change a guest whose bound permission set carried the transfer bit could create a row owned by any user, the system principal included. The anchor tier already refuses that bit on a guest binding, so no binding a deployment can make today reached it, and this branch does not depend on the tier. Without the transfer bit the refusal already happened before this change; its message now says that no grant admits a guest's owner, instead of naming the transfer grant as the remedy. -- **An owner the scenario sets stands.** A `beforeInsert` hook runs after the stamp, inside the engine's operation, and a record-change flow writes as itself, so neither is mistaken for a value the guest sent. - -Unchanged: the public form doors, which strip a submitted owner and leave it unset (`PUBLIC_FORM_SERVER_MANAGED_FIELDS`); every signed-in caller, whose absent owner is still stamped with its own id and whose foreign owner still needs the transfer grant; and system writes. - -What a guest client does: leave `owner_id` out of an anonymous write. The object's own hooks or record-change flows decide who owns the row, and the row stays unowned when none does. diff --git a/.changeset/pre/23228-record-share-guest-recipient-retired.md b/.changeset/pre/23228-record-share-guest-recipient-retired.md deleted file mode 100644 index 887a088a2a8..00000000000 --- a/.changeset/pre/23228-record-share-guest-recipient-retired.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-sharing': minor ---- - -fix(spec,plugin-sharing)!: `sys_record_share` no longer offers a `guest` recipient, which no grant ever honoured (ADR-0138 D8) - -Clause-②: no (narrowing: a declared recipient value no grant honours is refused) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -A record share to the anonymous principal was declared in two places and honoured in none. Both declarations are gone: - -- `RecordShareRecipientType` (`@objectstack/spec/contracts`) is now `'user' | 'group' | 'position' | 'unit_and_subordinates'`. Passing `recipientType: 'guest'` to `ISharingService.grant`, or typing a `RecordShare.recipient_type` as `'guest'`, is a compile error. -- The `recipient_type` select on `sys_record_share` (`@objectstack/plugin-sharing`) offers those same four options, and its `guest` label is gone from the four translation bundles. - -Why: the grant refused `guest` (ADR-0078), and no gate reads such a row, because the read and write gates match `recipient_type: 'user'` only. ADR-0090 D11 routes guest access through the guest position and share links, so a record share to the guest had nothing to gain. The sharing-rule vocabulary retired the same recipient in v17. - -What a caller sees now: - -- **The grant door** (`POST /data/:object/:id/shares`, or `grant()` from untyped code). A body naming `recipientType: 'guest'` is still refused with `VALIDATION_FAILED` (`400`), as before. The sentence now says the value was removed and that guest access flows through the guest position and share links. It used to say that sharing rules deliver non-`user` recipients, which was never true of a guest. To let a guest reach a record, use a public form or a share link. -- **The row door** (a system-context engine write: a seed, a plugin, a migration script). A row carrying `recipient_type: 'guest'` is now refused with `VALIDATION_FAILED`, an `invalid_option` error on `recipient_type` that lists the four values a row may carry. Before this change such a write stored a row that looked granted and enforced nothing. -- **Unchanged:** every `user` grant, the other three row values, the sharing-rule service, and the gates. A `guest` row that a system write stored before this release is read by no gate, both before and after. diff --git a/.changeset/pre/23231-walled-environment-metadata-writes.md b/.changeset/pre/23231-walled-environment-metadata-writes.md deleted file mode 100644 index 0910bdc6fa1..00000000000 --- a/.changeset/pre/23231-walled-environment-metadata-writes.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/metadata-core': minor -'@objectstack/rest': minor -'@objectstack/runtime': minor -'@objectstack/cloud-connection': minor -'@objectstack/spec': minor ---- - -fix(security)!: under a walled posture every environment-metadata write is the platform operator's, whatever its type (ADR-0131 D12 items 7–8, D11) - -Clause-②: no (narrowing) - - - -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. ADR-0131 D12 item 7: a walled deployment is "one environment, one schema, one catalog … The metadata-authoring capability (`manage_metadata` / `studio.access`) is what headquarters IT holds and plant admins do not." Item 8: a plant "cannot create a position, a permission set, a capability, a template or any metadata … A plant that needs its own catalog is given its own environment." D11: the rules are identical in every posture; only whether the wall is enforced differs. - -**Why.** The environment definition ledger carries no organization column, so a write lands for every organization. Measured on booted `isolated` and `group` stacks before this change: an organization owner granted `manage_metadata` inside their own organization created an object, a view and a flow at `/api/v1/meta`, edited, published, rolled back and reset the operator's view, ran `POST /api/v1/meta/_migrate-stored`, passed all thirteen `/api/v1/packages` write doors, installed, upgraded and uninstalled packages through `install-local`, and created a flow at `/api/v1/automation`, each with `2xx`; another organization's owner read every result. The security catalog was refused at these doors already; every other type was not. - -**FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): - -- `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` of `/api/v1/meta/:type/:name` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for a type outside the security catalog (`object`, `view`, `flow`, `app`, `dashboard`, `translation` and every other type): **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher answers `403 PERMISSION_DENIED`). -- Every `/api/v1/packages` write on the runtime dispatcher — create, the manifest `PATCH`, enable, disable, publish, publish-drafts, discard-drafts, revert, rollback, `commits/:commitId/revert`, duplicate, adopt-orphans and delete — for a package carrying no position, permission set or capability: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is read or written. The refusal no longer reads the package's items. -- `POST /api/v1/marketplace/install-local` (install and upgrade) and `DELETE /api/v1/marketplace/install-local/:id` (uninstall) on `@objectstack/cloud-connection`, for a package carrying no catalog item: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`. The refusal's `error.details` no longer carries `catalogItems`; it carries `manifestId`. -- `POST /api/v1/automation`, `PUT /api/v1/automation/:name`, `DELETE /api/v1/automation/:name` and `POST /api/v1/automation/:name/clone`: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the automation service is touched. -- `POST /api/v1/meta/_migrate-stored`, the install-wide rewrite of the stored metadata, preview and apply alike: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` on `@objectstack/rest` and `403 PERMISSION_DENIED` on the runtime dispatcher, before a row is read. -- Every one of these refusals carries one sentence: it names the written type (`a package` at the package doors, `the environment's stored metadata` at the rewrite), the posture and "ADR-0131 D3, D12 items 7–8, D11", and points at an environment of the organization's own. The catalog refusals that shipped before keep their envelopes and now read the same sentence. -- The activation refusals under a wall (`POST /api/v1/automation/:name/toggle`, `POST /api/v1/actions/_activation/…`, `POST /api/v1/security/_activation/permission/…`) no longer recommend cloning a flow or a permission set, or authoring a sibling action, since those writes are the operator's under a wall too; they name the platform operator. - -**Unchanged.** Reads. Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows. The platform operator and the engine's own writes (seeders, installers, migrations) write as before. Under `single` (and with no `tenancy` service), a `manage_metadata` holder writes exactly as before. - -**The remedy.** The platform operator authors the environment's metadata, in Studio or through a managed package; an organization that needs metadata of its own is given an environment of its own. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-environment-metadata-writes-retired`. `walledCatalogWriteVerdict` (`@objectstack/metadata-core`) now judges every type alike: a custom door that writes environment metadata asks it after its capability gate, with no `isSecurityCatalogType` pre-filter. diff --git a/.changeset/pre/23233-permission-echo-names-runtime-package.md b/.changeset/pre/23233-permission-echo-names-runtime-package.md deleted file mode 100644 index 64dcd076085..00000000000 --- a/.changeset/pre/23233-permission-echo-names-runtime-package.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security): a permission set published into a runtime package names that package to the security catalog, so its `isDefault: true` records an `everyone` suggestion - -Clause-②: no - -- **What was wrong.** A writable runtime package's permission set marked `isDefault: true`, published through the package door with no `packageId` in its body, recorded no audience-binding suggestion on an environment kernel, so ADR-0090 D5's "packages suggest, admins confirm" never happened for it. On that kernel the protocol hydrates no stored row into the engine registry, so the security catalog finds the set in the metadata service. The only body there is the permission-set projection's in-memory copy, and that copy carried no `_packageId`. The catalog served the set as unowned while `GET /api/v1/meta/permission/NAME` named the package, and the suggestion collector skipped it. -- **What changed.** The projection's copy of a tenant-authored set now names the package its stored row is bound to. It reads that binding from the protocol's by-name read (the `_packageId` that read serves) and derives none of its own. The catalog, the by-name read and the layered read (`?layers=true`, whose `packageId` comes from the same copy) now name the same owner. `GET /api/v1/security/suggested-bindings` lists one pending suggestion under that package. A `manage_metadata` holder still has to accept it: the publish grants nothing. -- **When the body's `packageId` disagrees.** A set whose body declares a `packageId` other than the package it is published into is now owned, for the catalog and the suggestions, by the package it is published into, which is what the by-name read already served. On an environment kernel, a suggestion recorded under the body's package is pruned at the next sync while pending, and withheld from `everyone` with its reason logged if it was accepted. A pending suggestion under the publishing package replaces it. -- **Unchanged.** The packaged-permission-set lock and every edit door. (No `sys_permission_set` row is written for the set: C3 stage 7a retires the last writer.) A code package's set, whose owner the catalog reads from its artifact. A set saved into no package, which stays unowned and records no suggestion. A copy of a code-shipped set's legacy overlay, which carries no owner, as before. -- **If the binding cannot be read**, the copy is registered without an owner, as before, and one warning says so. It still enforces the new body, and the next save, publish or boot reads the binding again. diff --git a/.changeset/pre/23235-export-group-key-classifier.md b/.changeset/pre/23235-export-group-key-classifier.md deleted file mode 100644 index a7a077d6a40..00000000000 --- a/.changeset/pre/23235-export-group-key-classifier.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -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. diff --git a/.changeset/pre/23245-dispatcher-auth-gate-outage.md b/.changeset/pre/23245-dispatcher-auth-gate-outage.md deleted file mode 100644 index 4e2e87c8d98..00000000000 --- a/.changeset/pre/23245-dispatcher-auth-gate-outage.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime)!: the runtime dispatcher's ADR-0069 gate answers `503` when an active gate cannot read the session, as REST does - -Clause-②: no (narrowing) — an active gate's outage now refuses what it served; core's existing exports only, no published schema or export moves. - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled. While a gate feature is active, the runtime dispatcher (`HttpDispatcher`) reads the session again at its own gate to ask that question. When that read failed, because the auth service could not answer, the dispatcher took the failure for "no session" and served the request, so a session the policy holds passed the gate on every dispatcher-served route for as long as the outage lasted. REST, and the dispatcher plugin's declared-endpoint fallback, answer the same window `503`. - -- **Refused now:** with a gate feature active, a request whose gate read fails (the session read rejects, or the auth service cannot hand back its session API) is answered `503` with the code `SERVICE_UNAVAILABLE`, in the dispatcher's existing error envelope. It is the authorization-store outage (`AuthzStoreUnavailableError`, `@objectstack/core`, read object `auth_gate`) that REST raises for the same window, and the route's own handler does not run. -- **Unchanged:** with no gate feature active the gate makes no read at all, and a deployment whose gate probe itself fails is treated as having no active gate, as on REST. Whenever the read answers, the answers are as before: a held session gets the gate's `403` with its own `code`, and a session the policy does not hold, an API-key caller and an anonymous caller are served as before. - -What changes for you. Only deployments with password expiry or enforced MFA turned on are affected, and only while their auth service cannot answer a session read: requests on routes the runtime dispatcher serves now answer a retriable `503` instead of being served. Retry once the auth service answers. Nothing needs configuring. diff --git a/.changeset/pre/23254-auth-org-record-page-option.md b/.changeset/pre/23254-auth-org-record-page-option.md deleted file mode 100644 index c16e7c799a4..00000000000 --- a/.changeset/pre/23254-auth-org-record-page-option.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -feat(plugin-auth): `registerOrganizationRecordPage: false` leaves out the platform's `sys_organization` default record page, so a host that ships its own 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`. `AuthPlugin` always registered `sys_organization_detail` (the Members / Invitations / Teams tab strip) as the default record page of `sys_organization`, so a deployment composing `AuthPlugin` beside a host that ships its own default page for that object was refused at boot. - -- **New option.** `AuthPluginOptions.registerOrganizationRecordPage` defaults to `true`. Only an explicit `false` leaves out `sys_organization_detail`, so the host's page is the only default whichever plugin starts first. Plugin order and precedence rules play no part. -- **Nothing else moves.** With `false`, `AuthPlugin` still registers the identity objects, the `sys_user_detail` record page, the System Overview dashboard, and the auth routes and services. It logs one `info` line at boot naming the option. -- **The default is unchanged.** A deployment that does not set the option registers `sys_organization_detail` as before. A host page beside it, with no option set, is still refused with `DEFAULT_RECORD_PAGE_CONFLICT`. -- **To adopt it:** construct `new AuthPlugin({ …, registerOrganizationRecordPage: false })` in the host that registers its own `isDefault: true` record page for `sys_organization`. With the option set and no default page of the host's own, the organization record view opens the synthesized default layout, without the Members / Invitations / Teams tabs. diff --git a/.changeset/pre/23255-verify-flows-system-arm.md b/.changeset/pre/23255-verify-flows-system-arm.md deleted file mode 100644 index d65095d4b9f..00000000000 --- a/.changeset/pre/23255-verify-flows-system-arm.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/verify': minor ---- - -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 }); -``` diff --git a/.changeset/pre/23259-permission-set-owner-mismatch-refused.md b/.changeset/pre/23259-permission-set-owner-mismatch-refused.md deleted file mode 100644 index 6cfa8a18679..00000000000 --- a/.changeset/pre/23259-permission-set-owner-mismatch-refused.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/plugin-security': minor -'@objectstack/spec': minor ---- - -fix(metadata-protocol)!: the metadata save, restore and publish doors refuse a permission set whose body `packageId` disagrees with the package it is saved into, and the audience-binding suggestion reader takes a set's owner from its binding only (ADR-0086 D3) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -ADR-0086 D3 declares `PermissionSetSchema.packageId` as the owning package of a package-shipped set, absent for an environment-authored one. The owner every reader uses is the package the set is saved into: the stored row's `package_id`, served as `_packageId`. A body `packageId` that named another package was accepted and then silently overridden, so an author, an AI build above all, never learned that the owner it wrote was not the owner it got. - -**FROM.** A permission set saved and published into package `app.crm` with `packageId: 'app.other'` in its body answered `200` and `published`, and was owned by `app.crm` everywhere it was read. A set saved into no package with `packageId: 'app.other'` was stored as an environment-authored set, while the audience-binding suggestion reader offered it, accepted it and derived it into `everyone` as `app.other`'s. - -**TO.** - -- **`@objectstack/metadata-protocol`.** The save door (`PUT /api/v1/meta/permission/:name`, draft and publish mode, and every in-process caller) the restore doors (`POST /api/v1/meta/permission/:name/rollback` and a commit revert's restore limb) and the draft promotion (`POST /api/v1/meta/permission/:name/publish` and the package publish) refuse a permission set whose body `packageId` is not the package the row is written into, with `400 VALIDATION_ERROR`, the code and status the doors already answer for a body `name` that disagrees with its row. `issues[]` carries the key at `packageId`. A body `packageId` on a set saved into no package is refused too, the empty string included: a present key declares a package-shipped set. The message names both values and both remedies. The package judged is the one the write lands in: a draft saved without naming a package lands in the package of the item's active row, and is judged against it. A package publish that meets such a draft (one stored before this change) aborts the whole batch, `outcome: 'refused'`, with the draft in `failed[]` under `VALIDATION_ERROR`. A rollback to a stored version that carries such a body (one written before this change) is refused, and a commit revert reports it in `failed[]` under the same code; the remedy there is to save the set again with `packageId` dropped or set to its package, instead of restoring that version. Nothing is written, published or restored. Package duplication copies a set whose body names the source package as naming the target package. `os migrate meta --stored --apply` records a stored set it rewrites, and the door refuses, as that row's failure. -- **`@objectstack/plugin-security`.** The audience-binding suggestion reader takes a set's owner from its binding only, never from its body's `packageId`. A set bound to no package has no owner there, so no suggestion is created for it. An acceptance recorded under the package its body named is withheld from `everyone` at the next derivation. The withholding is reported at `warn`, naming the set and its reason. Accepting a pending suggestion of that kind is refused. -- **`@objectstack/spec`.** The `PermissionSetSchema.packageId` description states the refusal. The step-18 semantic migration entry `permission-set-package-id-binding-mismatch-refused` carries the upgrade sweep. - -**Unchanged.** A body `packageId` equal to the package the set is saved into is accepted, and an absent one is accepted everywhere. Stored rows are not rewritten. A stored set whose body disagrees with its binding keeps loading and keeps reading as its binding, and is refused on its next save or publish, as is a restore of a stored version that carries one. An installed package's own sets are owned by the package that ships them, as before. - -**Remedy, in one line.** Drop `packageId` from the permission set's body, or save the set into the package it names (`?package=` on the REST door, `packageId` in process). diff --git a/.changeset/pre/23260-builtin-token-value-judged.md b/.changeset/pre/23260-builtin-token-value-judged.md deleted file mode 100644 index dcd2f326fb8..00000000000 --- a/.changeset/pre/23260-builtin-token-value-judged.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -A builtin flow node's `config` value that carries a `{token}` is judged for its type at the build doors like any other value: `get_record` with `limit: '{x}'` is refused at `nodes.N.config.limit` ("expected number, received string") by `FlowSchema.parse`, `defineStack`, `objectstack validate`, `objectstack compile`, the metadata save door and `AutomationEngine.registerFlow`, where it used to register and then fail every run that reached the node. - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -**Why.** The build doors' value arm (`flowNodeConfigRefusals`) never refused a value carrying a `{token}` for its type, because the flow interpolator decided what the run would parse there. The interpolator and every call site it had are deleted on this line, and every builtin executor parses its config as authored (an `http` node after its credential channel's `signingSecret`), so the run's contract sees the token the build's sees and refuses it at every run. The value arm passed exactly those values. - -**What is refused now.** A token-carrying value at a builtin key its executor contract refuses it at: a number or boolean key (`get_record` `limit`, `loop` `maxIterations`, `update_record` and `delete_record` `multi`, a screen field's `min`, `max` or `required`, a `try_catch` `retry.maxRetries`), a key with a declared set (`notify` `severity`, `screen` `mode`), a record key written as one token (a CRUD `filter: '{f}'`), and a rule finding the token used to hide (a `notify` `templateData` with no `template`). Each is `node-config-refused-by-contract`, `params: { nodeType, key }`, anchored at the key, in the contract's own words — `defineStack` throws `STACK_SCHEMA_INVALID` (422) at `flows.N.nodes.M.config.`. - -**What does not change.** - -- A token at a plain string key (`outputVariable`, `flowName`, `objectName`) parses and registers as before: the contract takes a string there. -- A token at a value slot (a CRUD `fields` value, a callee `input`, a screen's `defaults`, `defaultValue` or `recordId`, a `loop` or `map` `collection`, a `notify` `recipients`, `templateData` or `sourceId`, an `http` `body`, a filter operand) or at a text slot (a `notify` `title`, `message` or `actionUrl`, a screen `title` or `description`, an `http` `url` or header value) is refused, as before, by that slot's own judge at `registerFlow` and `objectstack validate`, once, with the slot's label and remedy: the value arm exempts each finding that judge owns, so no token is reported twice. -- At a key its executor reads as written (a `notify` `sourceObject`, `actorId` or `payload`; an `http` `method`, `durable`, `timeoutMs` or whole `headers` map) the key is refused once, in the literal's own words, as before. -- An `http` `signingSecret` is still never judged at the build doors: the credential channel may supply what the run signs with. - -## FROM → TO - -| you wrote | write instead | what changes | -|:--|:--|:--| -| `get_record` `limit: '{page.size}'` | `limit: 25` | refused at `config.limit` at every build door, where it registered and failed every run | -| `loop` `maxIterations: '{cap}'` | `maxIterations: 50` | the same, at `config.maxIterations` | -| `update_record` `multi: '{bulk}'` | `multi: true` | the same, at `config.multi` | -| a screen field `min: '{m}'` | `min: 1` | the same, at `config.fields.N.min` | -| `try_catch` `retry: { maxRetries: '{n}' }` | `retry: { maxRetries: 3 }` | the same, at `config.retry.maxRetries` | -| `notify` `severity: '{level}'` | `severity: 'warning'` | the same, at `config.severity` | - -**The one-line fix: write the literal the key declares.** None of these keys is evaluated per run, so no envelope replaces the token there. - -**Who is affected, measured.** A census of every builtin node literal whose `config` evaluates statically — 1010 nodes, 244 of them carrying a `{…}` token, across `examples/`, `packages/` and `apps/` in this repository at the base and hotcrm at `0f84c9c` — finds no verdict this change moves (a control node with `limit: '{x}'` moves, so the census can see one). No published docs page and no `@objectstack/lint` rule says a token at a typed key passes the build. - -### The kit - -- **The judge.** `flowNodeConfigRefusals`'s builtin value arm (`flow-node-config-refusals.ts`): the token carve-out (`carriesInterpolationToken`) is deleted. In its place the arm asks the value-slot judge (`flowNodeValueTemplateRefusals`) and the text-slot judge (`textSlotTemplateRefusal`) which paths they refuse and exempts those, and reports a key whose contract marks its refusal `READ_AS_WRITTEN_PARAM` in that refusal's words alone. -- **ADR-0087.** No new entry: the step-18 D3 entry `flow-builtin-node-config-values-refused` and its rationale fragment now state the class this change judges. diff --git a/.changeset/pre/23265-dispatcher-delete-expected-version.md b/.changeset/pre/23265-dispatcher-delete-expected-version.md deleted file mode 100644 index 844225c1084..00000000000 --- a/.changeset/pre/23265-dispatcher-delete-expected-version.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -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. diff --git a/.changeset/pre/23268-external-sharing-model-retired.md b/.changeset/pre/23268-external-sharing-model-retired.md deleted file mode 100644 index 80f8d6b27a3..00000000000 --- a/.changeset/pre/23268-external-sharing-model-retired.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': minor -'@objectstack/plugin-security': patch ---- - -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. - - diff --git a/.changeset/pre/23268-skills-lint-rules-row.md b/.changeset/pre/23268-skills-lint-rules-row.md deleted file mode 100644 index e2cf387894e..00000000000 --- a/.changeset/pre/23268-skills-lint-rules-row.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -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. diff --git a/.changeset/pre/23269-v18-stored-row-fates.md b/.changeset/pre/23269-v18-stored-row-fates.md deleted file mode 100644 index 1e7069a6844..00000000000 --- a/.changeset/pre/23269-v18-stored-row-fates.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-automation': minor -'@objectstack/metadata-protocol': minor ---- - -fix: `os migrate meta --stored` lists every stored row that protocol 18 refuses and no conversion rewrites. Before, the preview counted each such row as already on protocol. - -Clause-②: yes (widening: `IAutomationService.canonicalizeStoredFlow(...).todos?` in `@objectstack/spec`, `AutomationEngine.canonicalizeStoredFlow(...).todos` in `@objectstack/service-automation` and `StoredFlowCanonicalization.todos?` in `@objectstack/metadata-protocol`, each a new output member; nothing narrows, and no door accepts or refuses anything it did not before) - -Three step-18 retirements refuse a stored shape that has no lossless rewrite. Upgrading a v17 database left those rows refused at load or at boot, and the stored preview read them as `canonical`. Each shape now has exactly one fate: **refused and listed**. The preview and `--apply` print one `TODO` line per site under its row. The line names the step-18 entry that refuses the shape, the site, and the choice the owner has to make. Nothing is rewritten, and the run's verdict does not change, because no run of the pass could clear these rows. - -**#23269, a flow value slot carrying a `{…}` token, including a `get_record` / `update_record` / `delete_record` filter operand: refuse and list.** A whole-path token maps mechanically to `{ dialect: 'cel', source: '' }`, but the rewrite is not lossless. A token the template resolved to nothing (an erased condition, so the node was refused) is a CEL fault on an absent root, and a flow variable named like a query placeholder used to shadow it. ADR-0087 D2 conversions run on the load path and must be lossless, so this one is not a D2. The chain also has no operator-applied rewrite of stored rows separate from the load path: `--stored` writes back the read path's own pass, and `--from 17` rewrites authored sources, not rows. So the flow is listed by flow, node and path (`flow-value-slot-template-dialect-refused`), and `registerFlow` goes on refusing it until the value is rewritten as its CEL envelope. A flow already in the CEL form, or using a query placeholder such as `{current_user_id}`, is not listed. - -**#23270, a `controlled_by_parent` object without exactly one required `master_detail`: refuse and list, by object (`cbp-master-relation-tiers-retired`).** No shape here converts losslessly. The upgrade never picks between two masters, either both required or neither. An explicit `required: false` is a contradiction the builder refuses. Turning a required `lookup` into a `master_detail` changes what deleting the master does. An object with no master has nothing to convert. Leaving `controlled_by_parent` changes who can read the rows, so that is the owner's choice and never a silent rewrite. The listing reads the same predicate `ObjectSchema` refuses with, so the preview and the parse refuse one set. The one lossless shape, a single `master_detail` with `required` omitted, is still converted by `cbp-master-detail-required-defaulted` and is not listed. - -**#23271, a view whose `data` binds the retired view-level API source: refuse and list, by view (`view-data-api-provider-retired`).** There is no mechanical move. The replacement is a page component's `dataSource`, so a page must exist to carry it. Deleting the view's `data` would make the view query its object instead. Views in every stored body spelling are covered, and so are an object's own `listViews`. The line shows the request URL and method and withholds every other request key, so a credential stored in a view's headers never reaches the report. A view on the `object`, `value` or `schema` provider is not listed. - -How it is wired: `applyConversions` reports these shapes through its existing `onTodo` sink, after the conversions run. Each TODO's `conversionId` is the id of the D3 entry that refuses the shape, so the published `ConversionTodoNotice.conversionId` now names either a conversion or a D3 entry. The published flow-canonicalization result gains one member, a widening in three packages: - -- `@objectstack/spec`: the contract `IAutomationService.canonicalizeStoredFlow(...)` declares an optional `todos?: ConversionTodoNotice[]` beside `notices` and `conflicts`. An implementation that reports none still conforms. -- `@objectstack/service-automation`: `AutomationEngine.canonicalizeStoredFlow(...)` returns `todos`. `registerFlow` ignores them, because its own refusal names the same sites. -- `@objectstack/metadata-protocol`: `StoredFlowCanonicalization` gains the matching optional `todos?`, and the stored pass lists a flow row's TODOs the way it lists any other row's. - -The authoring funnel and the read seams pass no sink, so nothing changes for them. diff --git a/.changeset/pre/23272-export-cbp-master-predicate.md b/.changeset/pre/23272-export-cbp-master-predicate.md deleted file mode 100644 index edd373f3526..00000000000 --- a/.changeset/pre/23272-export-cbp-master-predicate.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/lint": patch ---- - -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. diff --git a/.changeset/pre/23276-upgrade-skill-skills-step.md b/.changeset/pre/23276-upgrade-skill-skills-step.md deleted file mode 100644 index 9dfb1373e9f..00000000000 --- a/.changeset/pre/23276-upgrade-skill-skills-step.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/skills": patch ---- - -docs(skills): the upgrade skill's preflight re-installs the catalog from `@objectstack/skills` when a 17.x project moves to 18, and both READMEs give the scaffolder's pinned `--copy` sync (#23276) - -Clause-②: no - -`objectstack-upgrade`'s §0 Preflight gains one step for the skills themselves. A 17.x project's skills were installed with `skills add` from the repository's `main`; their `skills-lock.json` entries read `sourceType: github`, and `experimental_sync` skips them (it prints `Skipped … installed with skills add` and exits 0), so a project that only bumps its packages keeps its 17.x-era skill text. The step removes the eleven `objectstack-*` skills the catalog carried during 17.x (a name not installed is skipped; `--all` when they are the project's only skills), installs `@objectstack/skills` at the installed `@objectstack/spec`'s version, and copies it with `npx skills@1.7.2 experimental_sync --agent claude-code --copy -y` — the command `create-objectstack` runs. The step is paid under the token ratchet by deleting text the same file restates elsewhere; the ceiling is unchanged. - -`README.md` (the npm page) and `dist/skills/README.md` (the catalog's front page, copied from the repository's `skills/README.md`) replace the unpinned linked sync (`npx skills experimental_sync --agent claude-code -y`) with that pinned `--copy` spelling and say why: without `--copy` the sync writes links into `node_modules`, which a clone of a project that commits its skills does not have. diff --git a/.changeset/pre/23279-parent-read-gate-silent-skip.md b/.changeset/pre/23279-parent-read-gate-silent-skip.md deleted file mode 100644 index d793183cdf1..00000000000 --- a/.changeset/pre/23279-parent-read-gate-silent-skip.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -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. diff --git a/.changeset/pre/23280-host-fate-supplement.md b/.changeset/pre/23280-host-fate-supplement.md deleted file mode 100644 index 66488a6a3b9..00000000000 --- a/.changeset/pre/23280-host-fate-supplement.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/cli': minor -'@objectstack/spec': minor ---- - -feat(cli,spec): `os migrate organization-ownership --fate-supplement FILE`. A host declares the ADR-0131 D10 fates of the objects it provides, and the ceremony plans and applies them as it does the platform's own rows - -Clause-②: yes (widening) - -The v18 organization-ownership ceremony refuses any platform-prefixed table that carries `organization_id` when its inventory gives that table no fate. That is right for the platform's own objects, but a host's objects have no row in the inventory, so every database a host runs was refused. The host now declares those fates itself, in one JSON file, and passes the file to the command. **Adoption: a host passes its supplement through the flag; with no flag, the ceremony behaves as before.** - -- **The file is declared in the spec.** `OrganizationOwnershipSupplementSchema` (`@objectstack/spec/system`) is a JSON array of `OrganizationOwnershipSupplementEntrySchema` rows. Each row gives `object`, `fate` (`column-drop`, `mirror-deletion`, `attribution` or `report`), `citation`, and only the fields its fate reads: `anchors` (required for `attribution`), `mirror`, `departures`, `categories`, `notNullExempt` and `note`. Every shape refuses an unknown key, and a key written on the wrong fate is refused rather than ignored. -- **Both modes read it.** The plan plans each row on the same terms as a platform row, and `--apply` performs it the same way. A row whose table is not on the database being planned is listed `absent`, so one supplement serves both a control-plane database and a tenant database. -- **It is refused, never half-read.** The command refuses a missing file, a file that is not JSON, a row the schema refuses, two rows for one object, or a row naming an object the platform inventory already gives a fate. The refusal comes before the database is opened, inside the `--json` envelope (`plan_refused` / `apply_refused`), and names the file, the row index and the object. A row naming one of the cloud-provided objects that the inventory lists with no fate (`cloudCarried`) replaces that placeholder row. -- **Nothing is read without the flag:** no environment variable and no conventional path. A host table with no declared fate is still refused by name, and the refusal now names `--fate-supplement FILE` as the remedy. -- The supplement's rows are part of the plan's `inventoryDigest`, so the apply's checkpoint is keyed on the supplement as well. - -```bash -os migrate organization-ownership --fate-supplement host-fates.json --out plan.json -os migrate organization-ownership --fate-supplement host-fates.json --apply --backup-taken -``` diff --git a/.changeset/pre/23281-comment-reactions-upgrade-conversion.md b/.changeset/pre/23281-comment-reactions-upgrade-conversion.md deleted file mode 100644 index c336c26f008..00000000000 --- a/.changeset/pre/23281-comment-reactions-upgrade-conversion.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/cli': minor -'@objectstack/spec': patch ---- - -feat(cli)!: the v18 upgrade carries stored comment reactions — `os migrate comment-reactions` turns each reaction stored in the retired `sys_comment.reactions` column into a `sys_comment_reaction` record, and `os migrate apply --allow-destructive` refuses to drop that column until the conversion is verified - -Clause-②: no (narrowing) — `os migrate apply --allow-destructive` now refuses the `sys_comment.reactions` drop it accepted before, until a verified conversion is recorded; the conversion itself only writes records of an existing object, so nothing widens. - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -**What the upgrade now carries.** A database upgraded from 17.x keeps `sys_comment.reactions` and every emoji reaction stored in it (schema sync is additive). Those reactions are no longer lost. `os migrate comment-reactions` converts them into records: - -- **One record per stored reaction.** Each (comment, emoji, user) in a stored map becomes one `sys_comment_reaction` row with the same comment, emoji and user, in the comment's organization, owned by the user who reacted. That user can remove it like any reaction made on v18. A user id listed twice under one emoji is one reaction. -- **Nothing is guessed.** A stored value that is not JSON or not a map of emoji to user-id lists, an entry that is not a user id, an emoji longer than a reaction record holds, and a user id that names no `sys_user` row are each listed in the report with their comment id. Nothing is written for them. The readable entries of the same comment still convert. -- **Idempotent.** A reaction that already has its record is skipped, so a second run writes nothing, and re-running finishes an interrupted run. -- **Dry run by default.** `--apply` writes the records, reads the column back, and records the run in `sys_migration`. The run is recorded as verified only when every readable reaction has its record and no stored value is left unreadable. `--json` gives the full report. A run that lists an unreadable value or fails a write exits `1`, and so does an `--apply` run whose read-back finds anything unconverted. - -**The column drop waits for the conversion (the narrowing).** Until a verified run is recorded, `os migrate apply --allow-destructive` refuses to drop `sys_comment.reactions` and exits `1` (`--json`: `message: "refused_comment_reactions_unconverted"`, with the recorded run under `conversion`). Nothing else is applied on that run. This also covers the boot drift line, which names that command as the remedy for the orphaned column. What to do when it refuses: run the conversion below until it reports the run verified, then re-run the apply. - -**`os migrate apply --json` exits `1` when the `sys_account.issuer` retirement pre-flight refuses.** Its two refusals, `refused_account_issuer_collisions` and `refused_account_issuer_preflight_unreadable`, emitted their document with exit code `0`, so a caller gating on the status read a refused apply as a successful one. They now exit `1`, as the same refusals do without `--json`. The document is unchanged. - -**Upgrade order:** take a backup, run `os migrate comment-reactions`, then `os migrate comment-reactions --apply`. If it lists unreadable values, correct each one in the database so it reads as a map of emoji to user ids, or clear it if it holds nothing to keep, and run `--apply` again. Once the run is verified, run `os migrate apply --allow-destructive` to drop the column. Run these with the flags and environment the deployment boots with (an auth secret set), so the audit plugin's objects are composed. - -This supersedes the "stored reactions are not migrated" sentence in the entry that retired the column. `@objectstack/spec`: the protocol-18 semantic entry `sys-comment-reactions-column-retired` now names the conversion in its replacement, reason and acceptance criteria, and orders it before the drop. `os migrate meta` and the upgrade guide carry that text. Its id and surface are unchanged. diff --git a/.changeset/pre/23284-notification-takes-record-organization.md b/.changeset/pre/23284-notification-takes-record-organization.md deleted file mode 100644 index ac5df187f65..00000000000 --- a/.changeset/pre/23284-notification-takes-record-organization.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/service-messaging': minor -'@objectstack/service-automation': minor -'@objectstack/plugin-approvals': minor ---- - -fix(objectql,service-messaging,service-automation,plugin-approvals)!: a notification carries the organization of what it is about, and one no organization owns is refused instead of stored with `organization_id` NULL - -Clause-②: no (narrowing) — notification rows gain the organization they should already carry; what narrows is an emit that names no organization on an install where none is derivable, which the engine now refuses where it used to store the event row with `organization_id` NULL. - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -Every notification emitted from a system-context run — a scheduled flow, a record change written by a system context, an approval node — was stored in `sys_notification` with `organization_id` NULL, so reports and cleanup that filter by organization did not see it. Each emit also logged a `[notify] no organization in scope` warning. On an install holding several organizations, the deliveries of the same emit were refused while the flow step reported success, so nothing reached the inbox. - -**Who owns a notification now:** - -- **The run's organization.** A run that carries one emits exactly as before. -- **Else the record it is about.** A `notify` node in a run with no organization reads the organization of the record the notification is about. That is the declared click-through record (`sourceObject` + `sourceId`), else the run's trigger record. It is read from the stored row by id, never from the record snapshot the run carries. -- **Approvals.** Every approval notification carries its request's organization: `approval.requested`, reminders, comments, send-backs, reassignments, escalations and out-of-office notices. -- **Else the owner the engine derives for a system write** (ADR-0131 D9). `sys_notification` is now in the platform-object tenancy ledger, so an emit that names no organization gets the install's sole organization under the `single` posture. -- **The rows below follow the event.** `emit()` stamps the stored event row's organization on its delivery, inbox row and receipt, on both the inline and the outbox path. - -**Refused now:** where no organization is derivable (a walled posture, or a `single` install holding more than one organization), an emit that names none is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. Nothing is written for it. A `notify` step that hits the refusal fails with `notify failed: ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED: …` instead of succeeding. `MessagingService.emit()` rejects with the engine's error for any other producer. - -**What to do when it refuses:** give the notification an owner. Name the record it is about on the `notify` node (`sourceObject` + `sourceId`), declare the scheduled flow's acting organization (`organization` on its start node), or pass `organizationId` to `emit()` from your own code. - -The `[notify] no organization in scope` warning is gone, because the NULL row it predicted is no longer written. Rows already stored with `organization_id` NULL are not rewritten here. The v18 organization-ownership ceremony attributes them from their source record. diff --git a/.changeset/pre/23287-api-key-account-posture.md b/.changeset/pre/23287-api-key-account-posture.md deleted file mode 100644 index 8cf256ed64d..00000000000 --- a/.changeset/pre/23287-api-key-account-posture.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/core': minor -'@objectstack/rest': minor -'@objectstack/runtime': minor -'@objectstack/plugin-auth': minor -'@objectstack/plugin-sharing': minor -'@objectstack/service-datasource': minor -'@objectstack/service-settings': minor -'@objectstack/service-storage': minor -'@objectstack/platform-objects': minor ---- - -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. diff --git a/.changeset/pre/23291-packageless-read-serves-save-address.md b/.changeset/pre/23291-packageless-read-serves-save-address.md deleted file mode 100644 index bf17451c2cc..00000000000 --- a/.changeset/pre/23291-packageless-read-serves-save-address.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -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. diff --git a/.changeset/pre/23293-client-delete-package.md b/.changeset/pre/23293-client-delete-package.md deleted file mode 100644 index 3af95904bd8..00000000000 --- a/.changeset/pre/23293-client-delete-package.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/client': minor ---- - -feat(client): `meta.deleteItem` can name the package whose row it removes (`packageId`, sent as `?package=`), so a caller can follow the `409 RESOURCE_CONFLICT` remedy the reset door prescribes for an item two packages hold (#23293) - -Clause-②: yes (widening) - -**What is new.** `DeleteMetaItemOptions` gains `packageId?: string`. Both `deleteItem` declarations take it, the unscoped `ObjectStackClient.meta` and the environment-scoped `client.environment(id).meta`, and send it as `?package=` through the one query builder they share. The name, the query key and the omission rule are the ones `saveItem`'s `packageId` already uses: 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). The REST reset door `DELETE /api/v1/meta/:type/:name` reads `?package=` and refuses a delete that names no package of an item two or more packages hold, with `409 RESOURCE_CONFLICT`. The refusal names the packages and the remedy, which is to name one with `?package=`. 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.deleteItem('view', 'shared_grid'); - -// TO: removes com.acme.crm's row of the item and no other package's row -await client.meta.deleteItem('view', 'shared_grid', { packageId: 'com.acme.crm' }); -``` - -**Unchanged.** A delete without `packageId` sends the same request as before: no query string unless `state: 'draft'` is set, and no header unless `ifMatch` is set. `packageId` combines with `ifMatch` and `state` (`?state=draft&package=`). `?dropStorage` is still not an option. diff --git a/.changeset/pre/23297-package-axis-delete-draft.md b/.changeset/pre/23297-package-axis-delete-draft.md deleted file mode 100644 index 7063bedaa74..00000000000 --- a/.changeset/pre/23297-package-axis-delete-draft.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol)!: a delete naming one package keeps the item's other rows registered, a draft save that names no package of an item two or more packages hold is refused `409 RESOURCE_CONFLICT`, and the delete's `_lock` refusal carries the named package's reason (ADR-0048) - -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 one row per package. Since the save and the delete name their package (`?package=` on the REST door, `packageId` in process), three paths still did not take the package they were handed. - -**The registry after a delete naming one package.** FROM: `DELETE /api/v1/meta/object/my_object?package=com.acme.a` of an object two packages hold removed `com.acme.a`'s row and the receipt said the other package's row remains, and the item read served that row, but the registry entry was retired as if the deleted row were the whole item. Data CRUD then answered `404 OBJECT_NOT_FOUND` for the object until the next boot or save, and on a kernel whose registry holds the stored rows, registry lookups missed an item of any other type the same way. TO: when a row of the item is still stored after a delete, that row is registered again, as a boot registers it, and nothing is retired. Deleting the last row still retires the item. - -**A draft save that names no package.** FROM: `PUT /api/v1/meta/view/my_view?mode=draft` with no `?package=`, of an item whose active rows two packages hold, answered `200` "(env-wide, state=draft)" and stored the draft in whichever package the database returned first. With one package and a package-less row beside it, the draft went into the package or stayed package-less by the same order. TO: the draft inherits the package of the item's active rows when they are bound to one package, whatever the storage order (a package-less row beside it is not a second package). When they are bound to two or more, the save is refused `409 RESOURCE_CONFLICT`, writes nothing, and names the packages with the `?package=` remedy, exactly as the direct save and the delete answer. A draft read that names no package serves the package-unbound draft's version for such an item. - -**The delete's `_lock` refusal.** The delete's lock gate now takes the package the delete names, as the save's does. The verdict is unchanged: the lock is the strictest of every package that ships or stores the item, so a delete naming a package is refused whenever the save naming it is. What changes is the prose: when several packages declare the strictest lock, the `ITEM_LOCKED` refusal of a delete naming a package carries that package's reason, where it carried the reason of the package whose id sorts first. - -**Remedy.** For an item two or more packages hold, send the draft save with `?package=` naming the package whose draft it is (`packageId` in process). Nothing else asks anything of a caller. diff --git a/.changeset/pre/23298-env-kernel-registry-write-through.md b/.changeset/pre/23298-env-kernel-registry-write-through.md deleted file mode 100644 index faebd7d9bad..00000000000 --- a/.changeset/pre/23298-env-kernel-registry-write-through.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-protocol): an environment kernel that hydrates its own `sys_metadata` writes a published body through to its engine registry, so a narrowed permission set stops granting its old body without a restart - -Clause-②: no - -- **What was wrong.** The protocol's registry write-through skipped every type except `object` whenever the protocol had an `environmentId`. A kernel that hydrates its own store at boot (the standalone stack's `env_local`, or a hosted kernel with `hydrateMetadataFromDb`) put every stored body into its registry at boot and then never updated it. A save was accepted and served by `GET /api/v1/meta/:type/:name`, but the engine registry kept the boot-time body until a restart, and held nothing for an item created after boot. The security catalog reads that registry first, so a permission set narrowed through `PUT /api/v1/meta/permission/:name` kept resolving its earlier, wider body, and a permission set published after boot was not in the catalog at all: a user granted it was refused, and an `isDefault` set recorded no `everyone` suggestion. -- **What changed.** The write-through now asks whose registry it is, not whether the kernel has an environment. It registers the published body wherever the registry takes this kernel's stored rows: on an unscoped kernel, as before, and on an environment kernel once the boot has hydrated that kernel's own store into it (`loadMetaFromDb`). The delete's heal re-registers a MetadataService baseline on the same kernels, so a save followed by a reset does not leave the deleted body in the registry. -- **The owner.** The registry entry of a stored row now carries the row's package binding as `_packageId` on the boot and on the write-through, as a list read already stamped it, so a runtime package's `isDefault` set published through the metadata door is owned in the security catalog at once and records its `everyone` suggestion, on a host kernel and on a hydrating environment kernel alike; a row bound to no package stays unowned, and an artifact's own envelope still wins. -- **Unchanged.** An environment kernel whose store is never hydrated into its registry still writes nothing into it, so a registry shared with unscoped callers never receives a project-scoped row. The `object` branch and the organization-scope refusal are unchanged. What the registry holds after a save is what it holds after a restart. diff --git a/.changeset/pre/23306-storage-attach-verb.md b/.changeset/pre/23306-storage-attach-verb.md deleted file mode 100644 index 6d0571c762e..00000000000 --- a/.changeset/pre/23306-storage-attach-verb.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-storage': minor ---- - -A server-side producer can store a file and attach it to a record: `IFileAttachmentService.attachFile`, registered by `StorageServicePlugin` as the kernel service `file-attachments` (`FILE_ATTACHMENT_SERVICE`), writes the bytes, one `sys_file` row and one `sys_attachment` row linking it to the parent, under the checks every other attachment of that parent meets - -Clause-②: yes (widening: `@objectstack/spec/contracts` gains `IFileAttachmentService`, `AttachFileInput`, `AttachedFile` and `FILE_ATTACHMENT_SERVICE`, and `StorageServicePlugin` registers and declares one more service) - -- **The call.** `attachFile({ parentObject, parentId, organizationId, filename, mimeType, data })` answers `{ fileId, attachmentId, size }`. `organizationId` is required: the organization the caller acts in, or `null` for none. It acts as the system, for a producer with no browser upload in front of it (inbound mail first). Reach it with `ctx.getService(FILE_ATTACHMENT_SERVICE)` once the kernel has bootstrapped. -- **What it runs.** Each check is the one the rest of the platform already runs, not a copy: - - The `max_upload_mb` storage setting, through the same verdict the REST upload doors ask. Over it, the call is refused `413 PAYLOAD_TOO_LARGE` before anything is stored. - - The parent's organization. A parent walled into another organization than `organizationId` is refused `404 RECORD_NOT_FOUND`, as a missing parent is. Both rows are written in the parent's organization, or in the caller's for a global parent. - - Every gate on the `sys_attachment` insert, among them the `enable.files` opt-in: a parent object without it is refused `403 FILES_DISABLED`. - - Before the kernel has bootstrapped, or with no data engine, the call is refused `503 SERVICE_UNAVAILABLE`. A malformed input is refused `400 VALIDATION_ERROR`. -- **No refusal leaves a committed `sys_file`.** The row is written `pending` before the bytes, and turns `committed` only in the same transaction as the `sys_attachment` insert. A refused call removes the bytes and the row before it throws. If the process dies mid-call, the pending-upload retention reclaims both. -- `@objectstack/service-storage`: the upload doors' size refusal moved from the route module into `storage-limits.ts`, beside the limit it enforces, so the doors and the verb read one status, one code and one sentence. The doors answer exactly as before. diff --git a/.changeset/pre/23307-mail-attachments.md b/.changeset/pre/23307-mail-attachments.md deleted file mode 100644 index ea130b62a7d..00000000000 --- a/.changeset/pre/23307-mail-attachments.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/service-mailbox': minor ---- - -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. diff --git a/.changeset/pre/23308-security-catalog-echo.md b/.changeset/pre/23308-security-catalog-echo.md deleted file mode 100644 index 4b6c99ad96d..00000000000 --- a/.changeset/pre/23308-security-catalog-echo.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security): an environment kernel's security catalog serves metadata-door permission sets and positions again - -Clause-②: no - -On an environment kernel (the per-project kernel a hosted tenant runs), a permission set or a position saved, published or deleted through the metadata door is in the security catalog again: - -- It resolves from the kernel's metadata service, under the package its stored row is bound to. -- A user granted it is authorized by it. -- An `isDefault: true` set in a runtime package records its pending `everyone` suggestion again (ADR-0090 D5). - -After a restart, the stored overlays are served again too. - -This regressed in C3 stage 8 (`feat(plugin-security)!: retire the four security-catalog row objects`). That change unwired the permission-set projector. On an environment kernel, the projector's in-memory echo was the only thing that put a stored set into the metadata service, because the metadata door hydrates no project row into the engine registry. Since stage 8, such a set resolved to nothing and granted nothing (fail-closed), and no suggestion was recorded. - -Positions never had an echo. On an environment kernel, a metadata-door position was unresolved before stage 8 as well, and it now resolves. - -`SecurityPlugin` now registers its own mutation projector for `permission` and `position` on the protocol, plus one pass over the stored environment-wide overlays at `kernel:ready`. Both only register the overlay body in the metadata service. They write no row, and they change nothing a host kernel resolves, since a host kernel reads these from the engine registry first. diff --git a/.changeset/pre/23310-serve-org-record-page.md b/.changeset/pre/23310-serve-org-record-page.md deleted file mode 100644 index 80f68bd2091..00000000000 --- a/.changeset/pre/23310-serve-org-record-page.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/cli': minor -'@objectstack/plugin-dev': minor -'@objectstack/objectql': patch ---- - -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. diff --git a/.changeset/pre/23312-permission-set-owner-binding-build-and-registration.md b/.changeset/pre/23312-permission-set-owner-binding-build-and-registration.md deleted file mode 100644 index 9a6d5620ffe..00000000000 --- a/.changeset/pre/23312-permission-set-owner-binding-build-and-registration.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/objectql': minor -'@objectstack/plugin-security': minor -'@objectstack/metadata-protocol': patch -'@objectstack/cli': patch ---- - -fix(spec,objectql)!: a permission set whose body `packageId` disagrees with the package that ships it is refused at `defineStack` / `os validate` / `os build` and at package registration, and every reader takes a set's owner from its binding only (ADR-0086 D3) - -Clause-②: yes (narrowing) - - - -**BREAKING** (an accept-set narrowing on a published authoring surface), shipped as `minor` under the launch-window convention for breaking changes. - -ADR-0086 D3 declares `PermissionSetSchema.packageId` as the owning package of a package-shipped set, absent for an environment-authored one. The owner every reader uses is the binding: the package that ships the set or that it is saved into, served as `_packageId`. The metadata save, restore and publish doors already refuse a body `packageId` that disagrees with it. Package source and package registration did not: a set whose body named another package was registered under the package that shipped it, with no refusal and no warning, so an author, an AI build above all, never learned that the owner it wrote was not the owner it got. - -**What is refused, and where.** One judge, `judgePermissionSetOwnerBinding` (new export of `@objectstack/spec/security`), serves every door: - -- **The stack schema** (`ObjectStackDefinitionSchema`), so `defineStack`, `objectstack validate`, `objectstack build`, the lint scorer and an artifact load all refuse a set whose body `packageId` is not the id of the package that ships it: the stack's `manifest.id`, each `packages[]` body's own `id`, and `manifest.id` for a top-level set no body declares. A stack with no `manifest` ships its sets from no package, so any key there is refused, the empty string included. The issue sits at the set's `packageId` (`permissions.0.packageId`); `defineStack` throws `STACK_SCHEMA_INVALID` (422) carrying it. -- **Package registration** (`ObjectQL.registerApp`, the manifest and a nested plugin, for a manifest no schema parse judged) throws `400 VALIDATION_ERROR` with `issues[]` at `packageId` before the set is filed, the envelope the same seam throws for a view container whose `name` disagrees with its key. - -The message prescribes first, names both values, and offers both remedies: drop the key, or ship the set from the package it names. No error code is minted. - -**Readers.** `@objectstack/plugin-security`'s packaged-set lock (`classifyPackagedPermissionSet`) and `@objectstack/cli`'s permission-set name-collision report take a set's package from its binding only. A set filed into no package whose body named one is no longer locked as that package's, and a stack with no `manifest` declares no owner for the collision report. - -**Unchanged.** A body `packageId` equal to the package that ships the set is accepted, and an absent one is accepted everywhere. A top-level copy of a set a `packages[]` body declares (an artifact built with both halves) is judged on its body, not against the artifact's own id. The metadata save, restore and publish doors answer in the same words as before (`@objectstack/metadata-protocol` now calls the shared judge). Stored `sys_metadata` rows are not rewritten. - -## FROM → TO - -| door | FROM | TO | -|:--|:--|:--| -| `defineStack` / `objectstack validate` / `objectstack build` | `defineStack({ manifest: { id: 'com.acme.crm', … }, permissions: [{ name: 'crm_member', packageId: 'com.acme.other', … }] })` accepted; the set was owned by `com.acme.crm` | refused at `permissions.0.packageId`, `STACK_SCHEMA_INVALID` 422 | -| `ObjectQL.registerApp` | the same manifest registered the set under `com.acme.crm`, silently | `400 VALIDATION_ERROR`, `issues[]` at `packageId`, nothing filed | -| an artifact built before this change | loaded, the set owned by the shipping package | refused at load; rebuild it without the key | - -**Remedy, in one line.** Drop `packageId` from the permission set in your stack source (the package that ships a set is its owner), or move the set into the stack of the package it names, then build again. diff --git a/.changeset/pre/23316-refused-flow-registration-verdict.md b/.changeset/pre/23316-refused-flow-registration-verdict.md deleted file mode 100644 index 1cf8c4033c1..00000000000 --- a/.changeset/pre/23316-refused-flow-registration-verdict.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-protocol': minor -'@objectstack/service-automation': minor -'@objectstack/rest': minor ---- - -A stored flow the automation engine refuses is no longer served as running: the flow meta read serves the engine's verdict beside the item as `registration`, an envelope sibling of `lock`, and the flow list serves it as `registrations` keyed by flow name, so a refused flow reads `{ state: 'refused', reason: { code?, message } }` beside its stored `status`, which stays as authored. - -Clause-②: yes (widening) - -- **The shape** (`@objectstack/spec`). `FlowRegistrationSchema` is a closed set: `registered` (the engine holds the flow), `refused` (it refused the stored flow and holds nothing under the name; `reason` carries the engine's own `message` and, when the thrown error set one, its `code`, verbatim), and `unregistered` (it holds nothing and refused nothing: a read during boot, between a save and the engine following it, or after a deliberate unregister). `GetMetaItemResponseSchema` gains `registration` and `GetMetaItemsResponseSchema` gains `registrations`, both optional. New exports: `FlowRegistrationSchema`, `FlowRegistrationRefusalSchema` and the types `FlowRegistration` and `FlowRegistrationRefusal`. `IAutomationService` gains the optional member `getFlowRegistration(name)`. -- **Where the verdict comes from** (`@objectstack/service-automation`). The `kernel:ready` cold-boot bind records each flow it refuses, with what the engine threw, after it withdraws the flow; the `metadata:reloaded` re-sync and the metadata-change sync record a refusal that leaves the name unregistered. A later successful registration of the name, or its withdrawal, ends the record. `AutomationEngine` answers `getFlowRegistration(name)` and takes `recordFlowRefusal(name, error)`. The refusal's warn line, its level and the withdrawal are unchanged. -- **Where it is served** (`@objectstack/metadata-protocol`). The protocol reads the engine through the kernel's service table by name, as it already reads the engine's stored-flow canonicalizer; it does not import the automation service. The field is absent where no automation service declares the member, on every type other than `flow`, on a draft read and on the execution face the engine binds from: absence means no engine judged the flow, never `registered`. The verdict is the engine of the process that answered the read. -- **The default door** (`@objectstack/rest`, `@objectstack/metadata-protocol`, `@objectstack/spec`). `GET /api/v1/meta/flow/:name` on a default deployment (`metadata.enableCache: true`) is answered by the cached item branch, which now carries `registration` too. `getMetaItemCached` returns it beside `data`, read live from its delegate read on every call, and `GetMetaItemCachedResponseSchema` declares it. It is never part of the ETag, so a cached read that carries it is never answered `304 Not Modified`: a judged flow is always served in full. The cached branch's other envelope keys are unchanged. diff --git a/.changeset/pre/23317-stored-preview-gaps.md b/.changeset/pre/23317-stored-preview-gaps.md deleted file mode 100644 index c2639b85c18..00000000000 --- a/.changeset/pre/23317-stored-preview-gaps.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-automation': minor ---- - -fix: `os migrate meta --stored` lists the two families of stored rows it still read as on protocol: a page component whose `data` prop binds the retired view-level API source, and every other site `registerFlow` refuses in a stored flow the parse accepts. - -Clause-②: yes (widening: `ConversionTodoNotice.conversionId` in `@objectstack/spec` may now name `registerFlow`, the refusing door; `IAutomationService.canonicalizeStoredFlow(...).todos?` in `@objectstack/spec` and `AutomationEngine.canonicalizeStoredFlow(...).todos` in `@objectstack/service-automation` now also carry each site flow registration refuses; the `onTodo` sink of `applyConversions` now also receives a page component bound to the retired API source; nothing narrows, and no door accepts or refuses anything it did not before) - -**A page component bound to the retired view-level API source: refuse and list, by page and component (`view-data-api-provider-retired`).** `object-grid`, `object-map`, `object-gantt` and `object-tree` take a view data source in their `data` prop, so the step-18 retirement of the `provider: 'api'` arm reaches them through the same schema. The listing judges each stored page component with its own props row, the one the component-props gate parses, and lists it only when that row refuses the binding with the retirement's prescription; a component whose `data` is a row array, a custom type and a component's own `dataSource` binding are not listed. The line names the page, the component and its position, shows the request URL and method, and withholds every other request key. Nothing is rewritten: deleting `data` would make the component query its object instead. Left as stored, the page still loads and saves, because the page parse does not judge a component's properties, while `os validate`, `os build` and `os lint` refuse the binding in source. The D3 entry `view-data-api-provider-retired` now names these components in its scope. - -**A stored flow refused by `registerFlow` after the parse: refuse and list, by site (`registerFlow`).** The stored pass ran only one of registration's judges, the value-slot one. `canonicalizeStoredFlow` now runs registration's whole validation without registering: every check `registerFlow` runs once the parse has accepted the flow, through the one list `registerFlow` itself throws from. Each refused site is a `TODO registerFlow` line under its row, in the check's own words: a text slot still on the single-brace dialect or with a hole that does not compile, a decision's `mode`, a malformed node or edge predicate, a plugin node's undeclared config key, an `api` flow with no secret. A check added to registration later is listed the same way, with no second change. A site the chain already lists under a step-18 entry is not repeated. What the parse itself refuses, the builtin config contracts' value arm among it, is not a TODO: `canonicalizeStoredFlow` throws it, and the preview reports the row among those that could not be rewritten, in the parse's words. - -`registerFlow` refuses exactly what it refused before, with the same message: its checks now return their refusal instead of throwing it, and `registerFlow` throws the first one. The doors that canonicalize a flow without registering it (the metadata save door, a package duplication) read no `todos`, and the stored pass's run of the checks logs nothing, so they behave as before. diff --git a/.changeset/pre/23325-record-share-remedy-text.md b/.changeset/pre/23325-record-share-remedy-text.md deleted file mode 100644 index 15a823a5b9a..00000000000 --- a/.changeset/pre/23325-record-share-remedy-text.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/plugin-sharing': patch ---- - -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. diff --git a/.changeset/pre/23328-share-link-refusal-order.md b/.changeset/pre/23328-share-link-refusal-order.md deleted file mode 100644 index 69a765db496..00000000000 --- a/.changeset/pre/23328-share-link-refusal-order.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/plugin-sharing': patch -'@objectstack/runtime': patch ---- - -fix(plugin-sharing, runtime): a share link that has a password and is limited to signed-in users answers an anonymous visitor `401 SIGN_IN_REQUIRED`, whatever password was sent - -Clause-②: no — every request refused before is still refused; only which refusal code an anonymous caller reads changes. - -A share link can carry a password and the `signed_in` audience together. For such a link, the share-link resolve door answered an anonymous visitor from the password before the audience: `NEEDS_PASSWORD` with no password, and `WRONG_PASSWORD` with any password, the correct one included. The console's shared-record page then told a visitor who typed the right password "Wrong password.", and never offered sign-in. - -- **Anonymous caller, `signed_in` link:** `401 SIGN_IN_REQUIRED` with no password, a wrong one or the correct one. The answer is the same for all three, so it never tells an anonymous caller whether a password is right. Once signed in, the visitor is asked for the password as before. -- **Signed-in caller:** unchanged. `401 NEEDS_PASSWORD` with no password, `401 WRONG_PASSWORD` with a wrong one, and the record with the correct one. -- **Unchanged:** links with any other audience (a `public` link with a password still answers `NEEDS_PASSWORD` / `WRONG_PASSWORD` to an anonymous caller), the expired-or-revoked `410 EXPIRED_OR_REVOKED`, the `404 INVALID_OR_EXPIRED` for an unknown token or an object with public sharing switched off, and every link that resolves. -- Both mounts of the resolve door answer alike: plugin-sharing's share-link routes and the runtime dispatcher's share-links domain. diff --git a/.changeset/pre/23332-page-action-row-operation.md b/.changeset/pre/23332-page-action-row-operation.md deleted file mode 100644 index b196d143ac1..00000000000 --- a/.changeset/pre/23332-page-action-row-operation.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: a page action row declares `ActionSchema`'s own `operation` enum and refuses `onSuccess` beside `operation: 'update'`, with the action's text - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on two keys of 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` to the console's action runner, which reads it before any executor: `operation: 'update'` performs the declarative write through the platform action route and runs no executor of its own. The rows typed `operation` as an open value and never carried `ActionSchema`'s refusal of `onSuccess` beside `operation: 'update'`, so a row with an `api` or `script` executor, the write and a hop parsed while the same action was refused. Now: - -- a row's `operation` is `ActionSchema`'s own enum (one member, `'update'`); `'delete'` and `'custom'` get the action's "bulk-def operation with no row-level form" refusal, and any other value is refused at `operation`; -- a row's `onSuccess` beside `operation: 'update'` is refused whatever executor the row names (or none), with ONE text shared with `ActionSchema`: the hop would follow the write, whose success carries no result `${result.*}` could read. - -`ActionSchema`'s reach is unchanged. Its message for the pairing used to open "`onSuccess` is not defined for an `operation: 'update'` action yet"; it now states what the runner does (reads `operation` before any executor, writes, then hops) and keeps the prescription "Drop `onSuccess`". - -### FROM → TO - -| before | what to write instead | -| --- | --- | -| `{ actionType: 'api', operation: 'update', patch: { status: 'done' }, onSuccess: { navigate: '/done' } }` (or `type` on a member, or no executor) | the same row without `onSuccess`, with no executor key or `'script'` (beside `operation: 'update'` any other executor is refused at that key, D3 `ui-page-action-row-declarative-update-rules`): the runner performs the write before any executor, so the hop followed a write whose success declares no result | -| `{ operation: 'delete', … }` / `{ operation: 'custom', … }` on a row | no `operation`: a row delete is the object's own delete affordance, and a row is already the action a bulk def's `'custom'` would name | -| any other `operation` value | `operation: 'update'` with a `patch`, or no `operation` (the runner ran nothing for it) | - -**The one-line fix: on a page action row, write `operation` only as `'update'`, and never beside `onSuccess`.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-operation-declared`; the rewrite is by hand. - -**What an author now sees.** `os validate`'s props gate reports the refusal at the row's `operation` or `onSuccess` (`properties.…`, or `properties.actions.N.…` for a member). For example, `{ actionType: 'api', operation: 'update', patch: { status: 'done' }, onSuccess: { navigate: '/done' } }` on an `action:button` gets: ``An `action:button`'s `onSuccess` (`operation: 'update'`) would follow a result the contract does not declare: the console's action runner reads `operation` before any executor, …``. That row is also refused at `actionType`, since beside `operation: 'update'` an executor other than `'script'` is refused (D3 `ui-page-action-row-declarative-update-rules`); without `onSuccess`, and with no `actionType` or `actionType: 'script'`, it parses. - -**Measured producers.** None in this repository. In objectui (at its pin `1fb1ced36243` and its main `b9814b3`), the action renderers' own tests carry `operation: 'update'` rows without `onSuccess`, which stay accepted; no row carries another `operation` value or the pairing. objectui's metadata-admin form already clears `onSuccess` when an author switches an action into an update. diff --git a/.changeset/pre/23335-held-admin-vendor-doors-refused.md b/.changeset/pre/23335-held-admin-vendor-doors-refused.md deleted file mode 100644 index e77a768c44c..00000000000 --- a/.changeset/pre/23335-held-admin-vendor-doors-refused.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -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. diff --git a/.changeset/pre/23335-held-platform-admin-refused.md b/.changeset/pre/23335-held-platform-admin-refused.md deleted file mode 100644 index 4269fdcf99b..00000000000 --- a/.changeset/pre/23335-held-platform-admin-refused.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -fix(plugin-auth)!: the platform-admin actions refuse a session the ADR-0069 authentication policy holds, a platform administrator's included - -Clause-②: no (narrowing: a held session is refused on the platform-admin surface, which ADR-0069 D3 already declares gated) - - - -**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 and the runtime dispatcher already refuse such a session. The platform-admin actions plugin-auth mounts on the host server (user creation, password, ban, removal, unlock, manager, import, membership, OAuth-client and SSO administration) are judged by one shared judge, `judgePlatformAdmin`, which read the session and the admin rung but never the posture, so a held platform administrator could still administer the platform. - -- **Refused now:** a held session meets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message` on every platform-admin action that judge guards, in the family's declared envelope, before the action runs. The judge asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) without a request path, so these actions never inherit the exemption that keeps the auth endpoints reachable. -- **Order:** an anonymous caller still meets `401 UNAUTHENTICATED` first. A held signed-in caller who is not a platform administrator now meets the posture's `403` code instead of `403 PERMISSION_DENIED`: same status, the remedy named. -- **Unchanged:** a session the policy does not hold is served exactly as before. Sign-in, change-password, two-factor enrollment and sign-out still answer a held session, so remediation works as before. - -What changes for you. A platform administrator the policy holds must remediate (change the password, or enroll an authenticator) before the platform-admin actions answer them, as on every other protected route. diff --git a/.changeset/pre/23338-flow-binding-d3-replacement-reads.md b/.changeset/pre/23338-flow-binding-d3-replacement-reads.md deleted file mode 100644 index 16160bfd2d0..00000000000 --- a/.changeset/pre/23338-flow-binding-d3-replacement-reads.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the two step-18 flow-binding D3 entries name only the reads a renamed variable has on protocol 18, a text-slot hole and a CEL expression - -Clause-②: no - -The `replacement` text of two ADR-0087 D3 semantic entries, `flow-binding-name-dollar-refused` and `flow-binding-variable-dollar-name-refused`, listed three ways a flow reads the old variable name, for the author to rename. Protocol 18 has only the first two. The protocol upgrade guide and `spec-changes.json` are generated from these entries, so they now say what the two entries' release notes already say. - -| FROM | TO | -|:--|:--| -| Rename every read of the old name with it — a text-slot hole, a CEL expression, a single-brace token in a value position | Rename every read of the old name with it — a text-slot hole, a CEL expression | - -No protocol-18 value position reads a flow variable through a single-brace `{…}` token. A value slot takes a CEL value envelope or a literal, and a `{…}` token there is refused. The one `{…}` string a value slot hands on is a CRUD `filter` operand's whole-string query placeholder (`{current_user_id}`, `{today}`): the query engine reads it, and it names no flow variable. A text slot reads `{{ }}` holes. - -Text only. No schema, door, conversion or entry id changes, and each entry keeps its `fromVersion`, `toVersion` and every other field. diff --git a/.changeset/pre/23343-plugin-auth-import-account-invitation.md b/.changeset/pre/23343-plugin-auth-import-account-invitation.md deleted file mode 100644 index d34075e9fda..00000000000 --- a/.changeset/pre/23343-plugin-auth-import-account-invitation.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -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. diff --git a/.changeset/pre/23343-plugin-email-account-invitation-template.md b/.changeset/pre/23343-plugin-email-account-invitation-template.md deleted file mode 100644 index 299a35ae8b2..00000000000 --- a/.changeset/pre/23343-plugin-email-account-invitation-template.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/plugin-email': minor ---- - -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. diff --git a/.changeset/pre/23347-i18n-plugin-mounts-retired.md b/.changeset/pre/23347-i18n-plugin-mounts-retired.md deleted file mode 100644 index 4a2f545ec62..00000000000 --- a/.changeset/pre/23347-i18n-plugin-mounts-retired.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/service-i18n': minor ---- - -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. diff --git a/.changeset/pre/23350-cli-meta-package-flag.md b/.changeset/pre/23350-cli-meta-package-flag.md deleted file mode 100644 index 1b65b624483..00000000000 --- a/.changeset/pre/23350-cli-meta-package-flag.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/cli': minor -'@objectstack/client': patch ---- - -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. diff --git a/.changeset/pre/23354-seed-claim-seed-rows.md b/.changeset/pre/23354-seed-claim-seed-rows.md deleted file mode 100644 index 99ecb66d82f..00000000000 --- a/.changeset/pre/23354-seed-claim-seed-rows.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/plugin-security': patch -'@objectstack/organizations': patch ---- - -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. diff --git a/.changeset/pre/23354-seed-landed-rows.md b/.changeset/pre/23354-seed-landed-rows.md deleted file mode 100644 index 5e883a8d6dc..00000000000 --- a/.changeset/pre/23354-seed-landed-rows.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-protocol': minor -'@objectstack/runtime': minor ---- - -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). diff --git a/.changeset/pre/23357-page-component-view-data-api-refused.md b/.changeset/pre/23357-page-component-view-data-api-refused.md deleted file mode 100644 index 87383e07f9b..00000000000 --- a/.changeset/pre/23357-page-component-view-data-api-refused.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: a page component's `data` prop that names the retired view-level `api` data source is refused at the Studio, REST and MCP save door as `os validate` refuses it (#23357) - -**BREAKING** — a page whose `object-grid`, `object-map`, `object-gantt` or `object-tree` component carries `properties.data` with `provider: 'api'` is refused at the component's `properties.data.provider`, with the retirement's own prescription, by the component-props gate (`validateComponentProps`, `@objectstack/lint`, rule `component-props-invalid`) at `os validate`, `os build`, `os lint` and the metadata save door. 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. The page parse leaves a component's `properties` an open record, and before 18 the component-props gate ran at the CLI doors only, so a page saved through the Studio, REST or MCP kept the binding, with its request headers, and the console kept sending it. - -Clause-②: yes (narrowing) - -The judge is the retirement's own: the component's `ComponentPropsMap` row parses its `data` with `ViewDataSchema`, and the finding carries that schema's prescription unchanged, one text for a view and a page. It reaches every component under `regions` and `slots`, at any depth, a component nested in a `page:tabs` item or a container included. Only those four types meet this prescription: a component whose `data` is a row array (`object-kanban`, `object-calendar`, `object-timeline`) is judged by its own row and refused for its shape, a custom or registered type with no props row is not judged, and a component's own `dataSource` binding is untouched. - -## FROM → TO - -| you wrote (saved by the Studio, REST and MCP door before 18) | 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.** `os validate`, `os build`, `os lint` and `PUT /api/v1/meta/page/:name` refuse the page at `pages[i].regions[j].components[k].properties.data.provider` (or the nested or slot position), rule `component-props-invalid`, with the prescription. At the save door that is `422 INVALID_METADATA`, nothing persisted. `definePage()` and `defineStack({ pages })` run no authoring rule and accept the page. - -**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 as stored, with no read diagnostic for the binding, and its next save is refused with 422 by the component-props gate. The console no longer reads the retired arm (objectstack-ai/objectui#12325), so a stored page does not send the request. - - diff --git a/.changeset/pre/23359-uncomposed-security-remedy.md b/.changeset/pre/23359-uncomposed-security-remedy.md deleted file mode 100644 index a28303b6e9c..00000000000 --- a/.changeset/pre/23359-uncomposed-security-remedy.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -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. diff --git a/.changeset/pre/23365-hono-declared-envelope.md b/.changeset/pre/23365-hono-declared-envelope.md deleted file mode 100644 index 167764cbdf0..00000000000 --- a/.changeset/pre/23365-hono-declared-envelope.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -'@objectstack/hono': minor -'@objectstack/plugin-hono-server': minor ---- - -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) diff --git a/.changeset/pre/23367-api-key-lifecycle.md b/.changeset/pre/23367-api-key-lifecycle.md deleted file mode 100644 index e7f491833aa..00000000000 --- a/.changeset/pre/23367-api-key-lifecycle.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/core': minor -'@objectstack/runtime': minor -'@objectstack/rest': minor -'@objectstack/mcp': minor -'@objectstack/plugin-auth': minor -'@objectstack/plugin-sharing': minor -'@objectstack/service-settings': minor -'@objectstack/service-storage': minor -'@objectstack/service-datasource': minor -'@objectstack/cloud-connection': minor -'@objectstack/platform-objects': minor ---- - -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. diff --git a/.changeset/pre/23371-share-link-messages-audience-inputs.md b/.changeset/pre/23371-share-link-messages-audience-inputs.md deleted file mode 100644 index 11ce14cd9bb..00000000000 --- a/.changeset/pre/23371-share-link-messages-audience-inputs.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-sharing': patch -'@objectstack/runtime': patch ---- - -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. diff --git a/.changeset/pre/23372-share-link-email-audience-uniform-refusal.md b/.changeset/pre/23372-share-link-email-audience-uniform-refusal.md deleted file mode 100644 index 50c91305d2e..00000000000 --- a/.changeset/pre/23372-share-link-email-audience-uniform-refusal.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/plugin-sharing': patch -'@objectstack/runtime': patch ---- - -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. diff --git a/.changeset/pre/23374-automation-references-envelopes.md b/.changeset/pre/23374-automation-references-envelopes.md deleted file mode 100644 index 2f4c491775c..00000000000 --- a/.changeset/pre/23374-automation-references-envelopes.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -fix(skills): the automation skill's two reference files write a CRUD `filter` operand as a CEL value envelope or the `{today}` query placeholder; the single-brace `{…}` filter spelling leaves them - -Clause-②: no - -`skills/objectstack-automation/references/examples-flows.md` and `references/state-machines-and-approvals.md` no longer teach `'{TODAY()}'`, `'{TODAY() + N}'` or `'{record.id}'` at an `update_record` filter operand — spellings the platform refuses (`expression-invalid`) at `objectstack validate`, `registerFlow` and the executor, so an author who copied the example got a refused node. The examples now write what the platform accepts: the query placeholder `'{today}'` for today's date, a CEL value envelope `{ dialect: 'cel', source: 'isoDate(daysFromNow(N))' }` for a one-day window at a date offset, and `{ dialect: 'cel', source: 'record.id' }` for the triggering record's id. The prose names the CEL value envelope where it named a template token (and no longer says a CEL envelope is compared as a literal at a filter operand), and the time-word cheat sheet names the CEL envelope and the `{{ }}` text hole where it named the retired `{…}` template. diff --git a/.changeset/pre/23385-walled-accepted-defaults.md b/.changeset/pre/23385-walled-accepted-defaults.md deleted file mode 100644 index 41831652b51..00000000000 --- a/.changeset/pre/23385-walled-accepted-defaults.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -feat(plugin-security): under a walled posture, the deployment-level acceptance of package defaults can be host configuration (`acceptedDefaultPermissionSets`, ADR-0131 D5) (#23385) - -Clause-②: yes (widening) - -**What was wrong.** On a walled (`group` / `isolated`) deployment with several organizations and none of slug `default`, the audience-binding suggestion record has no organization to own it. Every boot logged `[security] audience-binding suggestion sync failed (non-fatal)`, and the accept door answered `409 SUGGESTION_STATE` with the remedy "create the Default Organization (slug 'default')". That is a reserved organization for deployment-level rows, the shape ADR-0131 §1.5 rejects. - -**What is new.** `SecurityPluginOptions.acceptedDefaultPermissionSets?: readonly string[]` is read only under a walled posture. When it is declared, possibly as `[]`: - -- the `everyone` anchor derives from the baseline plus the declared sets. Each name passes the same checks an accepted suggestion does: the security catalog serves it under some package, it declares `isDefault: true`, and it carries no bit the `everyone` anchor forbids (ADR-0090 D7). A name that fails a check grants nothing and is reported, at `error` for a forbidden bit; -- the `sys_audience_binding_suggestion` record is neither read nor written, so the boot no longer warns. Existing rows are counted once and reported, not deleted; -- the record's list, accept and dismiss doors answer `409 SUGGESTION_STATE` naming the option. - -```ts -new SecurityPlugin({ acceptedDefaultPermissionSets: [] }); // nothing beyond the baseline -new SecurityPlugin({ acceptedDefaultPermissionSets: ['crm_readonly'] }); // every member also holds crm_readonly -``` - -A walled deployment that declares nothing keeps today's behaviour. When it has no organization to own the record, the refusal now names `acceptedDefaultPermissionSets` instead of "create the Default Organization". - -**Refused loudly.** A value that is not a list of non-empty names is refused at construction. A declaration under the `single` posture is refused at `start()`: there the record owned by the Default Organization is the acceptance. The `single` posture is otherwise unchanged, including its refusal text. - -**Exported for callers of the module's functions.** `readAcceptedEveryoneSets`, `syncAudienceBindingSuggestions` and `reconcileAudienceBindingSuggestions` take an optional trailing `DeploymentAcceptance` (`{ posture, acceptedDefaultPermissionSets }`), and `SuggestionDeps` gains `acceptedDefaultPermissionSets`. Omitted, they read the record as before. `WithheldAcceptance` gains `source: 'record' | 'host-configuration'`. diff --git a/.changeset/pre/23387-two-master-names-release.md b/.changeset/pre/23387-two-master-names-release.md deleted file mode 100644 index 0a7f466746b..00000000000 --- a/.changeset/pre/23387-two-master-names-release.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -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. diff --git a/.changeset/pre/23388-page-component-rules-runtime-door.md b/.changeset/pre/23388-page-component-rules-runtime-door.md deleted file mode 100644 index 9822c2711c9..00000000000 --- a/.changeset/pre/23388-page-component-rules-runtime-door.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/lint': minor -'@objectstack/spec': patch ---- - -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. diff --git a/.changeset/pre/23391-stored-failed-truth.md b/.changeset/pre/23391-stored-failed-truth.md deleted file mode 100644 index ec7a4111c4f..00000000000 --- a/.changeset/pre/23391-stored-failed-truth.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -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. diff --git a/.changeset/pre/23394-branding-logo-live.md b/.changeset/pre/23394-branding-logo-live.md deleted file mode 100644 index 60c017e268b..00000000000 --- a/.changeset/pre/23394-branding-logo-live.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the liveness ledger reads `app.branding.logo` as `live`, citing the console's `AppMark` reader (#23394) - -Clause-②: no - -**What was wrong.** The liveness ledger shipped with `@objectstack/spec` graded `app.branding.logo` `planned`, with a note saying no mounted component rendered it. The console now does: objectui's `AppMark` draws `branding.logo` as the app's square icon on the top-bar app pill, on each App Switcher row and in the phone drawer header. - -**What changes.** - -- `liveness/app.json`: `branding.logo` reads `live`, citing `AppMark` and the two shell components that mount it at the `.objectui-sha` pin. The designer pages that write the key stay cited as its `producer`. The `branding` container's note says the same. -- `liveness/state-counts/app.md` is regenerated: `app` moves from `live 50 · planned 1` to `live 51 · planned 0`. - -Unchanged: what `AppSchema` accepts and refuses (an app-level `logo` is still refused, and the refusal still points at `branding`), and every other ledger row. diff --git a/.changeset/pre/23395-page-form-empty-state.md b/.changeset/pre/23395-page-form-empty-state.md deleted file mode 100644 index b3868cae6ca..00000000000 --- a/.changeset/pre/23395-page-form-empty-state.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/platform-objects': patch ---- - -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`. diff --git a/.changeset/pre/23397-ceremony-builtin-positions.md b/.changeset/pre/23397-ceremony-builtin-positions.md deleted file mode 100644 index b552d090f72..00000000000 --- a/.changeset/pre/23397-ceremony-builtin-positions.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/plugin-security': patch -'@objectstack/cli': patch ---- - -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. diff --git a/.changeset/pre/23399-ceremony-not-declared-column-drop.md b/.changeset/pre/23399-ceremony-not-declared-column-drop.md deleted file mode 100644 index 1dd9444e6f3..00000000000 --- a/.changeset/pre/23399-ceremony-not-declared-column-drop.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -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. diff --git a/.changeset/pre/23400-ownership-occupancy-gate.md b/.changeset/pre/23400-ownership-occupancy-gate.md deleted file mode 100644 index 3367c3719d9..00000000000 --- a/.changeset/pre/23400-ownership-occupancy-gate.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -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`. diff --git a/.changeset/pre/23403-audience-anchor-binding-gate-retired.md b/.changeset/pre/23403-audience-anchor-binding-gate-retired.md deleted file mode 100644 index 1353c9f46f8..00000000000 --- a/.changeset/pre/23403-audience-anchor-binding-gate-retired.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -refactor(plugin-security): the audience-anchor binding gate on `sys_position_permission_set` is deleted - -Clause-②: no - -`SecurityPlugin`'s private engine-middleware gate that refused binding a high-privilege permission set to the `everyone` or `guest` position guarded writes to `sys_position_permission_set` only. C3 stage 8 (#23222) unregistered that object, so no write has reached the gate since. The gate, its call in the middleware and its three unit cases are removed. - -No behaviour changes, and nothing exported changes. The anchor tier itself (`describeAnchorForbiddenBits` from `@objectstack/spec/security`) still applies wherever a binding is made: when an `everyone` suggestion is confirmed (`confirmAudienceBindingSuggestion`), and on every derivation of the `everyone` anchor. No door binds a set to `guest`. diff --git a/.changeset/pre/23404-permission-set-description-liveness.md b/.changeset/pre/23404-permission-set-description-liveness.md deleted file mode 100644 index fb510313d07..00000000000 --- a/.changeset/pre/23404-permission-set-description-liveness.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -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`. diff --git a/.changeset/pre/23406-sealed-first-draft-remedy.md b/.changeset/pre/23406-sealed-first-draft-remedy.md deleted file mode 100644 index d056b2a8126..00000000000 --- a/.changeset/pre/23406-sealed-first-draft-remedy.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -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. diff --git a/.changeset/pre/23415-drop-storage-other-rows.md b/.changeset/pre/23415-drop-storage-other-rows.md deleted file mode 100644 index dc7e8a2cae0..00000000000 --- a/.changeset/pre/23415-drop-storage-other-rows.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -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`. diff --git a/.changeset/pre/23416-runtime-owner-overlay-refusal.md b/.changeset/pre/23416-runtime-owner-overlay-refusal.md deleted file mode 100644 index 2f46249a377..00000000000 --- a/.changeset/pre/23416-runtime-owner-overlay-refusal.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -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. diff --git a/.changeset/pre/23421-retire-page-aliases-at-18.md b/.changeset/pre/23421-retire-page-aliases-at-18.md deleted file mode 100644 index 09c99890848..00000000000 --- a/.changeset/pre/23421-retire-page-aliases-at-18.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -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. - - diff --git a/.changeset/pre/23427-page-action-row-declarative-update-rules.md b/.changeset/pre/23427-page-action-row-declarative-update-rules.md deleted file mode 100644 index 6506ebc96ad..00000000000 --- a/.changeset/pre/23427-page-action-row-declarative-update-rules.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -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. diff --git a/.changeset/pre/23429-sys-metadata-family-organization-column-retired.md b/.changeset/pre/23429-sys-metadata-family-organization-column-retired.md deleted file mode 100644 index a70dade6222..00000000000 --- a/.changeset/pre/23429-sys-metadata-family-organization-column-retired.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -'@objectstack/metadata-core': minor -'@objectstack/metadata-protocol': minor -'@objectstack/metadata': minor -'@objectstack/rest': minor -'@objectstack/spec': minor -'@objectstack/objectql': minor -'@objectstack/plugin-security': minor -'@objectstack/service-datasource': minor -'@objectstack/service-automation': minor -'@objectstack/platform-objects': minor -'@objectstack/driver-sql': minor -'@objectstack/driver-turso': minor -'@objectstack/cli': patch ---- - -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. diff --git a/.changeset/pre/23429-v18-boot-refusal.md b/.changeset/pre/23429-v18-boot-refusal.md deleted file mode 100644 index 8699cd26756..00000000000 --- a/.changeset/pre/23429-v18-boot-refusal.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/runtime': minor -'@objectstack/cli': minor -'@objectstack/spec': minor ---- - -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`. diff --git a/.changeset/pre/23430-grant-permission-set-id-retired.md b/.changeset/pre/23430-grant-permission-set-id-retired.md deleted file mode 100644 index ebfb0555370..00000000000 --- a/.changeset/pre/23430-grant-permission-set-id-retired.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/plugin-security': minor -'@objectstack/cli': minor -'@objectstack/spec': minor ---- - -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. diff --git a/.changeset/pre/23434-component-read-points-part-1.md b/.changeset/pre/23434-component-read-points-part-1.md deleted file mode 100644 index 2560e3951b3..00000000000 --- a/.changeset/pre/23434-component-read-points-part-1.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -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. diff --git a/.changeset/pre/23434-component-read-points-part-2.md b/.changeset/pre/23434-component-read-points-part-2.md deleted file mode 100644 index cbdb2ccc552..00000000000 --- a/.changeset/pre/23434-component-read-points-part-2.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -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. diff --git a/.changeset/pre/23434-component-read-points-part-3.md b/.changeset/pre/23434-component-read-points-part-3.md deleted file mode 100644 index 44e5bc119af..00000000000 --- a/.changeset/pre/23434-component-read-points-part-3.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -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. diff --git a/.changeset/pre/23434-objectui-read-points-to-ledger.md b/.changeset/pre/23434-objectui-read-points-to-ledger.md deleted file mode 100644 index dd7b2cc6f0d..00000000000 --- a/.changeset/pre/23434-objectui-read-points-to-ledger.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -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. diff --git a/.changeset/pre/23434-sha-less-read-points.md b/.changeset/pre/23434-sha-less-read-points.md deleted file mode 100644 index 1d3a4e379e3..00000000000 --- a/.changeset/pre/23434-sha-less-read-points.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -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. diff --git a/.changeset/pre/23447-page-component-view-data-judge-deleted.md b/.changeset/pre/23447-page-component-view-data-judge-deleted.md deleted file mode 100644 index 4b84c651467..00000000000 --- a/.changeset/pre/23447-page-component-view-data-judge-deleted.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: `checkPageComponentViewData` is deleted; a page component's `data` prop naming the retired view-level `api` source is refused by the component-props rule alone, at the CLI doors and the metadata save door (#23447) - -**BREAKING** — `checkPageComponentViewData` is no longer exported from `@objectstack/spec/ui`, and `PageSchema` no longer carries it. Since the component-props rule (`validateComponentProps`, `@objectstack/lint`) runs on a `page` write at the metadata save door, it and the page-parse check judged the same binding at the same door: one operation with two implementations. The governed side stays. The props rule parses the component's `properties` against the type's `ComponentPropsMap` row, and that row's `data` is `ViewDataSchema`, so it raises the retirement's own prescription on the same four types (`object-grid`, `object-map`, `object-gantt`, `object-tree`), at the same positions: regions, `page:tabs` items, nested containers and slots. - -Clause-②: yes (narrowing) - -What narrows: the published export leaves `@objectstack/spec/ui`. What `PageSchema` accepts widens by this one shape, a component `data` naming the retired provider, which the props rule then refuses at every door that runs it. - -## FROM → TO - -| you wrote | write instead | -| --- | --- | -| `import { checkPageComponentViewData } from '@objectstack/spec/ui'`, and `.superRefine(checkPageComponentViewData)` on a mirror built from `PageSchema.shape` | nothing on the mirror: a page component's `properties` is judged by `validateComponentProps` from `@objectstack/lint` (or `runAuthoringRules`), against the stack that carries the page | -| a page component's `properties.data` with `provider: 'api'` | unchanged from the step-18 retirement: `provider: 'object'` with `object`, or `provider: 'value'` with `items`; an endpoint read moves onto the component's `dataSource: { provider: 'api', read: { url: '/api/...' } }` | - -**The one-line fix:** drop the import and the re-attachment; run the props rule where you need the verdict. - -**Which doors refuse the binding now, measured:** - -| door | before | after | -| --- | --- | --- | -| `definePage()`, `defineStack({ pages })`, `PageSchema.safeParse` | refused, `custom` at `…properties.data.provider` | accepted (these doors run no lint rule) | -| `os validate`, `os build` | refused inside `defineStack` | refused by the props rule: `component-props-invalid` at `pages[0].regions[0].components[0].properties.data.provider`, with the prescription | -| metadata save door (`PUT /api/v1/meta/page/:name`, the Studio, MCP) | `422 INVALID_METADATA`, spec-parse issue `custom` | `422 INVALID_METADATA`, rule `component-props-invalid` at the same component, with the prescription; nothing stored | -| boot (`loadMetaFromDb`) | `invalid: 1`, registered under `[metadata_spec_invalid]` | `invalid: 0`, registered, no warning | -| read (`getMetaItem`) | served, `_diagnostics.valid: false` with the refusal | served as stored, `_diagnostics.valid: true` | - -**A page already stored with such a component** still loads and is served as stored, and no longer carries a read diagnostic for the binding; its next save is refused with 422 by the props rule. `os migrate meta --stored` still lists it under the step-18 entry `view-data-api-provider-retired`, and that entry's fate text now says so. - - diff --git a/.changeset/pre/23455-key-admission-posture-required.md b/.changeset/pre/23455-key-admission-posture-required.md deleted file mode 100644 index 62d11cff01f..00000000000 --- a/.changeset/pre/23455-key-admission-posture-required.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/core': minor -'@objectstack/runtime': minor -'@objectstack/cloud-connection': minor -'@objectstack/mcp': minor -'@objectstack/service-storage': patch ---- - -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). diff --git a/.changeset/pre/23462-dispatcher-patch-expected-version.md b/.changeset/pre/23462-dispatcher-patch-expected-version.md deleted file mode 100644 index 72caebefcb4..00000000000 --- a/.changeset/pre/23462-dispatcher-patch-expected-version.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -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. diff --git a/.changeset/pre/23473-uninstall-shared-object-refusal.md b/.changeset/pre/23473-uninstall-shared-object-refusal.md deleted file mode 100644 index 5720ab57e17..00000000000 --- a/.changeset/pre/23473-uninstall-shared-object-refusal.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -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. diff --git a/.changeset/pre/23474-email-refusal-cost.md b/.changeset/pre/23474-email-refusal-cost.md deleted file mode 100644 index c0f5779c1d8..00000000000 --- a/.changeset/pre/23474-email-refusal-cost.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/plugin-sharing': patch ---- - -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 diff --git a/.changeset/pre/23477-revoke-reason-vocabulary.md b/.changeset/pre/23477-revoke-reason-vocabulary.md deleted file mode 100644 index 7c2c73539c1..00000000000 --- a/.changeset/pre/23477-revoke-reason-vocabulary.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -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. diff --git a/.changeset/pre/23479-meta-delete-noop-line.md b/.changeset/pre/23479-meta-delete-noop-line.md deleted file mode 100644 index a723c3f9287..00000000000 --- a/.changeset/pre/23479-meta-delete-noop-line.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -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`. diff --git a/.changeset/pre/23480-cli-meta-register-force.md b/.changeset/pre/23480-cli-meta-register-force.md deleted file mode 100644 index f464c96786f..00000000000 --- a/.changeset/pre/23480-cli-meta-register-force.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -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`. diff --git a/.changeset/pre/23487-retire-overdue-conversions.md b/.changeset/pre/23487-retire-overdue-conversions.md deleted file mode 100644 index 491933e3ecb..00000000000 --- a/.changeset/pre/23487-retire-overdue-conversions.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-protocol': patch ---- - -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`. - - diff --git a/.changeset/pre/23488-admin-scope-binding-authoring-retired.md b/.changeset/pre/23488-admin-scope-binding-authoring-retired.md deleted file mode 100644 index e07cc298ef5..00000000000 --- a/.changeset/pre/23488-admin-scope-binding-authoring-retired.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-security': minor -'@objectstack/core': patch -'@objectstack/platform-objects': patch ---- - -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. - - diff --git a/.changeset/pre/23491-patch-data-request-gate.md b/.changeset/pre/23491-patch-data-request-gate.md deleted file mode 100644 index 26968ee7ec7..00000000000 --- a/.changeset/pre/23491-patch-data-request-gate.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/rest': minor -'@objectstack/runtime': minor ---- - -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`. diff --git a/.changeset/pre/23492-empty-version-token-refused.md b/.changeset/pre/23492-empty-version-token-refused.md deleted file mode 100644 index bddfdfe03f0..00000000000 --- a/.changeset/pre/23492-empty-version-token-refused.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/rest': minor -'@objectstack/runtime': minor ---- - -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. diff --git a/.changeset/pre/23495-draft-doors-one-address.md b/.changeset/pre/23495-draft-doors-one-address.md deleted file mode 100644 index f1cfde92ab3..00000000000 --- a/.changeset/pre/23495-draft-doors-one-address.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/spec': patch ---- - -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. diff --git a/.changeset/pre/23497-public-form-section-i18n.md b/.changeset/pre/23497-public-form-section-i18n.md deleted file mode 100644 index 3a28b1e2ae9..00000000000 --- a/.changeset/pre/23497-public-form-section-i18n.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -fix(rest): `GET /forms/:slug` serves a public form's section headings in the visitor's language, beside the field labels it already translated - -Clause-②: no - -An anonymous visitor of a published form cannot load the translation bundle in the browser, so the server is the only place its section titles can be translated. The route translated `objectSchema` (the field labels) under `Accept-Language` and returned each form section as authored, so a zh-CN visitor read English section titles over Chinese field labels. It now localizes each section's `label` and `description` through the same `objects.OBJECT._sections.NAME` keys the signed-in console reads, with the request's locale, the deployment's fallback chain and default locale, and the packaged view as the override base. - -With `Accept-Language: zh-CN` over a form whose section `about` is labelled `Tell us about yourself`, and a zh-CN bundle carrying `objects.crm_lead._sections.about.label: 介绍一下你自己`: - -- FROM: `form.sections[0].label` is `Tell us about yourself`, and `objectSchema.fields.first_name.label` is `名`. -- TO: `form.sections[0].label` is `介绍一下你自己`; the field label is unchanged (`名`). A section `description` follows the same key's `description`. - -What does not change: a section whose key the bundle lacks keeps its authored text; a section with no `name` is served as authored; a section that authors no heading is not given one; a heading edited on top of the packaged view keeps the edit. The response shape (`slug`, `object`, `label`, `form`, `objectSchema`), its top-level `label`, `Vary: Accept-Language`, the declared-field narrowing, the lookup / master-detail / user strip and `POST /forms/:slug/submit` are unchanged. diff --git a/.changeset/pre/23501-automation-door-package-remedy.md b/.changeset/pre/23501-automation-door-package-remedy.md deleted file mode 100644 index fbad0d2254f..00000000000 --- a/.changeset/pre/23501-automation-door-package-remedy.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -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. diff --git a/.changeset/pre/23505-extension-refusal-409.md b/.changeset/pre/23505-extension-refusal-409.md deleted file mode 100644 index 3a809de4784..00000000000 --- a/.changeset/pre/23505-extension-refusal-409.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -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. diff --git a/.changeset/pre/23509-mcp-coded-refusal.md b/.changeset/pre/23509-mcp-coded-refusal.md deleted file mode 100644 index 1a2106c64eb..00000000000 --- a/.changeset/pre/23509-mcp-coded-refusal.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/mcp': minor ---- - -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. diff --git a/.changeset/pre/23511-duplicate-package-unprefixed-object.md b/.changeset/pre/23511-duplicate-package-unprefixed-object.md deleted file mode 100644 index 857c453fdb4..00000000000 --- a/.changeset/pre/23511-duplicate-package-unprefixed-object.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -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. diff --git a/.changeset/pre/23513-grant-remedy-plan-file.md b/.changeset/pre/23513-grant-remedy-plan-file.md deleted file mode 100644 index 818ad75cf92..00000000000 --- a/.changeset/pre/23513-grant-remedy-plan-file.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/plugin-security': patch -'@objectstack/spec': patch ---- - -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. diff --git a/.changeset/pre/23514-sentinel-owner-save.md b/.changeset/pre/23514-sentinel-owner-save.md deleted file mode 100644 index 38cb0ce987a..00000000000 --- a/.changeset/pre/23514-sentinel-owner-save.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -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. diff --git a/.changeset/pre/23527-duplicate-permission-field-keys.md b/.changeset/pre/23527-duplicate-permission-field-keys.md deleted file mode 100644 index 45f69458707..00000000000 --- a/.changeset/pre/23527-duplicate-permission-field-keys.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -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. diff --git a/.changeset/pre/23528-exposure-refusal-code.md b/.changeset/pre/23528-exposure-refusal-code.md deleted file mode 100644 index 1cab86f517d..00000000000 --- a/.changeset/pre/23528-exposure-refusal-code.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -**Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. - -fix(runtime): `callData`'s exposure refusal for the object a request addresses carries the data door's code, `OBJECT_API_DISABLED` (404) or `OBJECT_API_METHOD_NOT_ALLOWED` (405), on the dispatcher's `/data` routes, the `/mcp` data tools and declared endpoints (#23528) - -Clause-②: no - -**What was wrong.** An object can declare `apiEnabled: false` or an `apiMethods` whitelist. The runtime dispatcher's exposure check for the object a request addresses (`refuseUnexposedDataAction`, which every `callData` asks first) refused with a status and a message but no `code`. So: - -- the dispatcher's `/data` routes derived a code from the status: `404 RESOURCE_NOT_FOUND` / `405 METHOD_NOT_ALLOWED`; -- the `/mcp` data tools rendered the message alone; -- a declared endpoint whose target is an object answered the derived code too. - -`RestServer` and the MCP stdio tools answer the same declaration `OBJECT_API_DISABLED` / `OBJECT_API_METHOD_NOT_ALLOWED`, and so did the dispatcher's own refusal of a nested-relation filter on an unexposed object. - -**What a caller reads now.** Every one of these doors answers the data door's code with the same status as before: - -| door | declaration | FROM | TO | -|:--|:--|:--|:--| -| dispatcher `/data` (and declared endpoints) | `apiEnabled: false` | `404`, `error.code` `RESOURCE_NOT_FOUND` | `404`, `error.code` `OBJECT_API_DISABLED` | -| dispatcher `/data` (and declared endpoints) | verb outside `apiMethods` | `405`, `error.code` `METHOD_NOT_ALLOWED` | `405`, `error.code` `OBJECT_API_METHOD_NOT_ALLOWED` | -| `/mcp` data tools | either | `isError: true`; the text is the message | `isError: true`; the text is `{ "error": { "code", "message", "status" } }` | - -The status and the message do not change. The thrown value is now an `Error` carrying `code`, `status`, the addressed object as `object`, and the effective operation set as `allowed` on a 405. A caller that branched on `RESOURCE_NOT_FOUND` / `METHOD_NOT_ALLOWED` for an exposure refusal on these doors should branch on the two `OBJECT_API_*` codes, as it already does for `RestServer`. diff --git a/.changeset/pre/23529-mcp-action-tools-withhold-permission-details.md b/.changeset/pre/23529-mcp-action-tools-withhold-permission-details.md deleted file mode 100644 index 5d70bc19ee9..00000000000 --- a/.changeset/pre/23529-mcp-action-tools-withhold-permission-details.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -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. diff --git a/.changeset/pre/23531-hono-conflict-body.md b/.changeset/pre/23531-hono-conflict-body.md deleted file mode 100644 index 98d926f30ae..00000000000 --- a/.changeset/pre/23531-hono-conflict-body.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/plugin-hono-server': patch -'@objectstack/hono': patch ---- - -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 diff --git a/.changeset/pre/23531-hono-conflict-current-record.md b/.changeset/pre/23531-hono-conflict-current-record.md deleted file mode 100644 index de9597abae3..00000000000 --- a/.changeset/pre/23531-hono-conflict-current-record.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/plugin-hono-server': patch -'@objectstack/hono': patch ---- - -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 diff --git a/.changeset/pre/23532-hono-undeclared-500-leak-filter.md b/.changeset/pre/23532-hono-undeclared-500-leak-filter.md deleted file mode 100644 index cd2bdcd6f64..00000000000 --- a/.changeset/pre/23532-hono-undeclared-500-leak-filter.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/hono': patch ---- - -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. diff --git a/.changeset/pre/23537-duplicate-package-reference-collision.md b/.changeset/pre/23537-duplicate-package-reference-collision.md deleted file mode 100644 index 2ac997b548e..00000000000 --- a/.changeset/pre/23537-duplicate-package-reference-collision.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -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. diff --git a/.changeset/pre/23538-duplicate-names-in-step.md b/.changeset/pre/23538-duplicate-names-in-step.md deleted file mode 100644 index 7ec5bcb8f75..00000000000 --- a/.changeset/pre/23538-duplicate-names-in-step.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -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. diff --git a/.changeset/pre/23539-references-permission-fields-keys.md b/.changeset/pre/23539-references-permission-fields-keys.md deleted file mode 100644 index dd6f0443780..00000000000 --- a/.changeset/pre/23539-references-permission-fields-keys.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -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`. diff --git a/.changeset/pre/23542-walled-binding-remedy.md b/.changeset/pre/23542-walled-binding-remedy.md deleted file mode 100644 index 290b8d6c1a7..00000000000 --- a/.changeset/pre/23542-walled-binding-remedy.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -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. diff --git a/.changeset/pre/23543-f1-retire-mongo-driver-alias.md b/.changeset/pre/23543-f1-retire-mongo-driver-alias.md deleted file mode 100644 index 383d6479840..00000000000 --- a/.changeset/pre/23543-f1-retire-mongo-driver-alias.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/driver-mongodb': patch ---- - -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. - - diff --git a/.changeset/pre/23543-f2-job-retry-policy-without-attempts-refused.md b/.changeset/pre/23543-f2-job-retry-policy-without-attempts-refused.md deleted file mode 100644 index 2d55d825d89..00000000000 --- a/.changeset/pre/23543-f2-job-retry-policy-without-attempts-refused.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-job': patch ---- - -feat(spec)!: a job `retryPolicy` block whose `maxRetries` is omitted or 0 is refused, and `retry-policy-converged` leaves the load path - -Clause-②: yes (narrowing) - -**BREAKING (authoring):** `maxRetries` has defaulted to 0 since 17, so a `job.retryPolicy` block that omits it, or writes 0, buys no retry: the job runs once. Through 17 the loader hid that by writing the pre-17 `maxRetries: 3` and `backoffMultiplier: 2` into such a block with a notice. At 18 the ADR-0087 D2 conversion `retry-policy-converged` is retired from the load path (`retiredAfter: '17.7.0'`), and the job parse refuses the block at `retryPolicy.maxRetries`, at every door that parses an authored job (`defineStack`, `os validate`, `os build`, and `os lint` when the config calls it): "A retry block with no attempts is no retry block: state `maxRetries` ≥ 1, for example 3, or delete the block." The rule is the job's, the same one `flow.errorHandling` applies to `strategy: 'retry'`; the shared `RetryPolicySchema` is unchanged, so a `try_catch` node's `retry` region is judged as before. - -Migration: - -- `retryPolicy: {}` or `retryPolicy: { backoffMs: 5000 }` → `retryPolicy: { maxRetries: 3, backoffMs: 5000 }` (3 is the count such a block ran with before 17), or delete the block if the job should run once. -- `retryPolicy: { maxRetries: 0, … }` → delete the block: a job with no `retryPolicy` is a single attempt. - -What an author who still writes it sees: `defineStack` throws `STACK_SCHEMA_INVALID` (422) with one issue at `jobs.N.retryPolicy.maxRetries` carrying the prescription above, and `os validate` prints the same issue. Nothing is rewritten silently any more. - -Not refused: a block that states `maxRetries` and omits `backoffMultiplier`. It keeps the declared default of 1, a flat delay, where the loader used to write 2. State `backoffMultiplier: 2` to keep the old exponential curve. - -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. For such a block the edit writes both pre-17 numbers (`maxRetries: 3`, `backoffMultiplier: 2`). The D3 entry `job-retry-policy-without-attempts-refused` states the decision left to you: keep the written count, or delete the block. - -The flow half of the conversion (`retryDelayMs` → `backoffMs` on `flow.errorHandling` and a `try_catch` node's `retry`) needed nothing more: both surfaces already refuse `retryDelayMs` with the rename to `backoffMs`. - -Stored metadata needs no edit. Stored-row rehydration, the automation engine's flow rehydration, the metadata save door's flow fallback and the artifact-ingestion door inside its window still replay the conversion, so a job row stored without a count keeps loading with the three retries it ran with. - -`@objectstack/service-job`: a comment-only change in `runWithPolicy`, whose `maxRetries` default is now reached only by a caller that builds the options by hand. - - diff --git a/.changeset/pre/23543-f3-retire-wait-connector-config-lift.md b/.changeset/pre/23543-f3-retire-wait-connector-config-lift.md deleted file mode 100644 index 67b1c7412b0..00000000000 --- a/.changeset/pre/23543-f3-retire-wait-connector-config-lift.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-automation': patch ---- - -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. - - diff --git a/.changeset/pre/23543-f4-visibility-alias-keys-retired.md b/.changeset/pre/23543-f4-visibility-alias-keys-retired.md deleted file mode 100644 index 7ff16d15771..00000000000 --- a/.changeset/pre/23543-f4-visibility-alias-keys-retired.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': patch -'@objectstack/metadata-core': patch ---- - -feat(spec)!: retire the view form's `visibleOn` and the page component's `visibility` as keys; their ADR-0089 renames leave the load path - -Clause-②: yes (narrowing) - -**BREAKING (authoring):** ADR-0089 made `visibleWhen` the one conditional-visibility key, and through 17 the old spellings were declared `[DEPRECATED]` keys that the parse folded onto `visibleWhen` without a word: `visibleOn` on a form view's fields and sections, and `visibility` on a page component. At 18 all three positions carry a `retiredKey()` tombstone naming `visibleWhen`, the parse-time fold is gone, and the two ADR-0087 D2 conversions `view-visibleOn-to-visibleWhen` and `page-component-visibility-to-visibleWhen` (protocol 15) are retired from the load path (`retiredAfter: '17.7.0'`). - -## FROM → TO - -| you wrote (17.7 and earlier) | write instead | -| --- | --- | -| `visibleOn: "…"` on a form field (`view.form.sections[].fields[]`) | `visibleWhen: "…"` | -| `visibleOn: "…"` on a form section (`view.form.sections[]`) | `visibleWhen: "…"` | -| `visibility: "…"` on a page component (`page.components[]`, slots and nested containers included) | `visibleWhen: "…"` | - -**The one-line fix:** rename the key to `visibleWhen`; the CEL predicate is unchanged. Where a node wrote both keys, `visibleWhen` was the one in effect: delete the old key. - -**What an author who still writes it sees.** `tsc` fails at a typed authoring site (`FormFieldInput`, `FormSection`, `PageComponent` type the key as retired), and the parse refuses it by name at its own path — `defineStack` throws `STACK_SCHEMA_INVALID` (422), `os validate` / `os build` print the issue, and the metadata save door answers 422: - -> `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. - -**No tool makes the rename.** Both conversions belong to protocol 15, below the migration floor, so `os migrate meta --from 17` applies no edit for either key and `--write` writes none: it prints each written key among the refusals the migrated stack still has, and lists the D3 entry `ui-visibility-alias-keys-retired` as the hand edit. - -**Stored metadata needs no edit.** Stored-row rehydration and the artifact-ingestion door inside its window still replay both conversions, so a `sys_metadata` view or page row written with the old key keeps loading with `visibleWhen`. A script that saves a view or page through the metadata API with the old key is refused and needs the same rename. - -## The retirement kit - -- **Tombstones** on `FormFieldSchema`, `FormSectionSchema` and `PageComponentSchema`; `RETIRED_KEYS_BY_MAJOR[18]`: `ui/FormField:visibleOn`, `ui/FormSection:visibleOn`, `ui/PageComponent:visibility`; the authorable-surface rows read `[RETIRED]`. -- **The fold is gone.** The three shapes are plain closed objects again (no `.transform` pipe): a section's group-reference rule no longer lists `visibleOn` (the tombstone answers it first), and a slotted page node's `type` refusal now arrives at its own path instead of nested in an `invalid_union`. -- **The D3 entry `ui-visibility-alias-keys-retired`**, one for both keys. -- **No deprecation window**, per the project's startup-stage posture. - -`@objectstack/lint`: the visibility-predicate rules (`visibility-root-mislayered`, `visibility-bare-identifier`, `visibility-predicate-syntax`) and the predicate-path rules read `visibleWhen` only. `os validate`, `os build`, `os verify`, `defineStack` and the metadata save door parse first and refuse either retired key by name, so a body they judge cannot carry one. `os lint` is the one door that hands these rules an unparsed stack: there a predicate written under a retired key is not judged, by design — the parsing doors refuse the key itself, and `os lint` reports no refused shape. - -`@objectstack/metadata-core`: `detectUnboundFormViewPredicateRoots` reads `visibleWhen` only. Its caller scans an artifact after `applyArtifactForwardConversions`, which renames a legacy `visibleOn` first. - -Not part of it: `normalizeVisibleWhen` and `VISIBILITY_ALIAS_KEYS` stay exported (no schema here calls them any more); objectui's renderer reads of the old keys leave on the objectui side. - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. - - diff --git a/.changeset/pre/23543-f5-http-callout-node-types-reserved.md b/.changeset/pre/23543-f5-http-callout-node-types-reserved.md deleted file mode 100644 index 5b614482140..00000000000 --- a/.changeset/pre/23543-f5-http-callout-node-types-reserved.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-automation': minor ---- - -feat(spec)!: the retired callout node types `http_request`, `http_call` and `webhook` are reserved inside the open flow-node namespace; `flow-node-http-callout-rename` leaves the load path - -Clause-②: yes (narrowing) - -**BREAKING (authoring):** ADR-0018 unified the three outbound callout node types `http_request`, `http_call` and `webhook` into the one `http` node, and through 17 the loader rewrote an authored node typed with one of them to `http` with a notice. A flow node's `type` is an open namespace (a plugin registers its own node types), so nothing refused the old names. At 18 they are reserved: the flow parse refuses each as a node's `type` by name, the automation engine refuses to register a node executor under one, and the ADR-0087 D2 conversion `flow-node-http-callout-rename` (protocol 11) is retired from the load path (`retiredAfter: '17.7.0'`). A wait node's `waitEventConfig.eventType: 'webhook'` is a different field and is not affected. - -## FROM → TO - -| you wrote (17.7 and earlier) | write instead | -| --- | --- | -| a flow node `type: 'http_request'` | `type: 'http'` | -| a flow node `type: 'http_call'` | `type: 'http'` | -| a flow node `type: 'webhook'` | `type: 'http'` | -| a plugin's `engine.registerNodeExecutor({ type: 'webhook', … })` (or either other name) | a type of your own, e.g. `'acme_webhook'` | - -**The one-line fix:** change the node's `type` to `'http'`; the node's `id`, `label` and `config` stay as they are (the conversion never touched `config`, so the `http` contract judges it as written). - -**What an author who still writes it sees.** The parse refuses the node at its own `type` path — a top-level node and a node inside a `loop`, `parallel` or `try_catch` region alike. `defineStack` throws `STACK_SCHEMA_INVALID` (422), `os validate`, `os build` and `os lint` (whose config calls `defineStack`) print the issue, and the metadata save door answers a NEW flow with `422 INVALID_METADATA` at the node's `type`, with or without an automation service, and stores nothing: - -> Flow node `type: 'http_request'` was removed in @objectstack/spec 18 (ADR-0087 D2) — it is one of the three callout node types ADR-0018 unified into `http`, the loader rewrote it to `http` until this release, and the name stays reserved inside the open node namespace so no plugin can claim it (ADR-0018). Use `type: 'http'`. Change the value; the conversion never touched the node's `config`, so the `http` contract judges it as written. - -The refusal is a `pattern` on `FlowNodeSchema.type`, so the published JSON Schema carries it too. - -**`@objectstack/service-automation`:** `AutomationEngine.registerNodeExecutor` (and `registerNodeAlias`, which calls it) throws when the executor's `type` or its descriptor's `type` is one of the three names, and registers nothing. No node could ever reach such an executor: an authored node with the name is refused at parse, and a stored one is rewritten to `http` before it parses. A plugin that registered its own executor under one of these names fails at that call and needs a type of its own. - -**No tool makes the rename.** The conversion belongs to protocol 11, below the migration floor, so `objectstack migrate meta` applies no edit for it and `--write` writes none: it prints each such node among the refusals the migrated stack still has, at `flows.N.nodes.M.type`, and lists the D3 entry `flow-node-http-callout-types-reserved` as the hand edit. - -**Stored metadata needs no edit.** Stored-row rehydration, the automation engine's flow rehydration and the artifact-ingestion door inside its window still replay the conversion, so a `sys_metadata` flow row written with an old name keeps loading as an `http` node. The rewrite there is now unconditional: until 18 it skipped a node whose old name a live executor owned and reported a conflict, and the engine no longer lets such an executor exist. - -## The retirement kit - -- **The reserved-name table** `FLOW_RESERVED_NODE_TYPES` and its refusal `reservedFlowNodeTypeRefusal` (`@objectstack/spec/automation`), read by the flow parse, the engine's registration refusal and the conversion itself. -- **The conversion** is `retiredFromLoadPath: true`, `retiredAfter: '17.7.0'`; its ADR-0078 conflict guard is gone. -- **The D3 entry `flow-node-http-callout-types-reserved`.** -- **No deprecation window**, per the project's startup-stage posture. - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` and `@objectstack/service-automation` are published, so this is breaking for consumers no telemetry was consulted for. - - diff --git a/.changeset/pre/23543-f5-jsx-retire-page-kind-jsx.md b/.changeset/pre/23543-f5-jsx-retire-page-kind-jsx.md deleted file mode 100644 index f64c91097bd..00000000000 --- a/.changeset/pre/23543-f5-jsx-retire-page-kind-jsx.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-protocol': patch -'@objectstack/cli': patch -'@objectstack/lint': patch -'@objectstack/platform-objects': patch ---- - -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`. - - diff --git a/.changeset/pre/23546-trail-doors-setup-access.md b/.changeset/pre/23546-trail-doors-setup-access.md deleted file mode 100644 index e9b85f37d4c..00000000000 --- a/.changeset/pre/23546-trail-doors-setup-access.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -'@objectstack/rest': minor -'@objectstack/runtime': minor ---- - -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. diff --git a/.changeset/pre/23549-fls-empty-object-part.md b/.changeset/pre/23549-fls-empty-object-part.md deleted file mode 100644 index 81dc166464e..00000000000 --- a/.changeset/pre/23549-fls-empty-object-part.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -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 `.`. diff --git a/.changeset/pre/23553-default-record-page-remedy.md b/.changeset/pre/23553-default-record-page-remedy.md deleted file mode 100644 index 562f4a45950..00000000000 --- a/.changeset/pre/23553-default-record-page-remedy.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/metadata-protocol': patch -'@objectstack/runtime': patch ---- - -docs: the default-record-page remedies name the other package's page, not the whole package - -Clause-②: no - -A second `isDefault: true` record page for one object is refused with `DEFAULT_RECORD_PAGE_CONFLICT`. The package registry's refusal already told the author to keep one default or "compose the deployment without the other package's page". Three sibling texts still told them to drop the whole package, though a deployment can keep a package and leave out the one page it ships: - -- `@objectstack/metadata-protocol`: the metadata save door's refusal now reads "a page a package ships stays the default unless the deployment is composed without that package's page." Only the remedy clause changed; `code`, `status`, `object` and `holders` are unchanged. -- `@objectstack/spec`: `PageSchema.isDefault`'s TSDoc and the error-code ledger's comment on `DEFAULT_RECORD_PAGE_CONFLICT` say the same. -- `@objectstack/runtime`: `AppPlugin`'s private `bundle` field now documents the three readers outside the class that read it structurally, so a rename is not made blind. - -No behaviour changes; nothing to migrate. diff --git a/.changeset/pre/23556-approval-request-names-record.md b/.changeset/pre/23556-approval-request-names-record.md deleted file mode 100644 index 864abf806ae..00000000000 --- a/.changeset/pre/23556-approval-request-names-record.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -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. diff --git a/.changeset/pre/23558-ceremony-dialect-resolver.md b/.changeset/pre/23558-ceremony-dialect-resolver.md deleted file mode 100644 index 452e3d06075..00000000000 --- a/.changeset/pre/23558-ceremony-dialect-resolver.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/metadata-core': minor ---- - -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. diff --git a/.changeset/pre/23561-duplicate-object-name-token.md b/.changeset/pre/23561-duplicate-object-name-token.md deleted file mode 100644 index b8bf41cadfa..00000000000 --- a/.changeset/pre/23561-duplicate-object-name-token.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -`duplicatePackage` no longer renames, in free text, a name that is both a prefixed package object and a field key of one of the package's objects. Such a name (for example a field keyed `acme_deal` on the object `acme_deal`) may name the field, so it is now rewritten only at object-reference properties (`object`, `reference`, `objectName`, a permission set's `objects` keys, …), the way a bare object name already is. A copied list view's `columns` and a copied form's `sections[].fields` keep naming the field the copy has, instead of a field named after the renamed object: the list view is no longer refused at the copy with `list-view-field-unknown`, and the form no longer copies a field the copy does not have. Free text naming such an object (a description) is left as stored; a prefixed object no field key spells is still renamed in free text as before. diff --git a/.changeset/pre/23562-read-only-arms-journal-mode-note.md b/.changeset/pre/23562-read-only-arms-journal-mode-note.md deleted file mode 100644 index 26671672350..00000000000 --- a/.changeset/pre/23562-read-only-arms-journal-mode-note.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -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. diff --git a/.changeset/pre/23566-picklist-translation-refs.md b/.changeset/pre/23566-picklist-translation-refs.md deleted file mode 100644 index 38557f6ab00..00000000000 --- a/.changeset/pre/23566-picklist-translation-refs.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): the translation-reference rules now judge a locale pack's `picklists` face (#23566) - -Clause-②: no - -Before this change a locale pack's `picklists` entries were never checked. A key that named nothing passed `os lint --strict`, and at runtime the resolver simply never found it, so the label rendered in the source locale. The two existing rules now cover this face, each at the severity its field-level twin already has. No new rule id is added: - -- `picklists.` for a list that no package in the stack declares is `translation-target-unknown` (error). When a declared name is close, the finding offers it as "Did you mean". -- `picklists..options.`, where the key is not a value of the list or of a `picklistExtensions` entry for it, is `translation-option-key-unknown` (warning). A key spelled as an option's display label is told which value to rename it to. - -A list the stack only extends through `picklistExtensions`, and that no package of the stack declares, is skipped: its owner's options cannot be seen from here. A list declared by a sibling package of the same artifact resolves, and its option keys are judged. `os explain translation-target-unknown` and `os explain translation-option-key-unknown` name the new face. diff --git a/.changeset/pre/23573-serve-no-storage-loud.md b/.changeset/pre/23573-serve-no-storage-loud.md deleted file mode 100644 index e33724c4372..00000000000 --- a/.changeset/pre/23573-serve-no-storage-loud.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -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. diff --git a/.changeset/pre/23574-data-request-contract-remainder.md b/.changeset/pre/23574-data-request-contract-remainder.md deleted file mode 100644 index 26c746ade5d..00000000000 --- a/.changeset/pre/23574-data-request-contract-remainder.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/rest': minor -'@objectstack/runtime': minor ---- - -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. diff --git a/.changeset/pre/23577-sealed-write-one-sentence.md b/.changeset/pre/23577-sealed-write-one-sentence.md deleted file mode 100644 index fa07d85ab43..00000000000 --- a/.changeset/pre/23577-sealed-write-one-sentence.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/runtime': patch -'@objectstack/spec': patch ---- - -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. diff --git a/.changeset/pre/23586-occ-conflict-caller-visibility.md b/.changeset/pre/23586-occ-conflict-caller-visibility.md deleted file mode 100644 index 24261ebc669..00000000000 --- a/.changeset/pre/23586-occ-conflict-caller-visibility.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -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. diff --git a/.changeset/pre/23587-hono-structured-arm-fields.md b/.changeset/pre/23587-hono-structured-arm-fields.md deleted file mode 100644 index 58a87c6ee02..00000000000 --- a/.changeset/pre/23587-hono-structured-arm-fields.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/plugin-hono-server': patch ---- - -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 diff --git a/.changeset/pre/23587-hono-unique-violation.md b/.changeset/pre/23587-hono-unique-violation.md deleted file mode 100644 index 3351c9dc8d0..00000000000 --- a/.changeset/pre/23587-hono-unique-violation.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/plugin-hono-server': patch ---- - -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 diff --git a/.changeset/pre/23594-subject-title-reader-fls.md b/.changeset/pre/23594-subject-title-reader-fls.md deleted file mode 100644 index 02fa4b7ae9b..00000000000 --- a/.changeset/pre/23594-subject-title-reader-fls.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -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. diff --git a/.changeset/pre/23598-plugin-auth-import-invite-delivery-failure.md b/.changeset/pre/23598-plugin-auth-import-invite-delivery-failure.md deleted file mode 100644 index 0a6253fe68e..00000000000 --- a/.changeset/pre/23598-plugin-auth-import-invite-delivery-failure.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -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. diff --git a/.changeset/pre/23609-legacy-org-read-seam.md b/.changeset/pre/23609-legacy-org-read-seam.md deleted file mode 100644 index e89d7bd6a9d..00000000000 --- a/.changeset/pre/23609-legacy-org-read-seam.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch -'@objectstack/metadata': patch -'@objectstack/cli': patch -'@objectstack/spec': patch ---- - -**Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. - -fix(metadata-protocol,metadata): on a database the organization-ownership ceremony has not migrated, the paged family reads, `DatabaseLoader`'s count and the package rollback agree with the environment-only reads; the ceremony's apply is named with `--plan-file` (ADR-0131 D6/D7) - -Clause-②: no - -Every change below matters only where legacy organization rows still sit in the `sys_metadata` family, the database `os serve` refuses to start on until `os migrate organization-ownership` has verified. On a migrated or fresh database every answer is unchanged. - -- **`@objectstack/metadata-protocol`: a paged read leaves the legacy rows out before the page is cut.** `auditMetaItem` (`limit`), `listCommits` (`limit`), `revertStoredPackage`'s first-active-row probe and `SysMetadataRepository.list({ limit })` used to filter a legacy organization's rows out after the limit. A page they sat in came back short, and `revertStoredPackage` answered `RESOURCE_CONFLICT` (409) "has never been published" for a package the environment had published when its first active row was an organization's. When a page holds a legacy row, the read is now repeated with those rows excluded in the query. -- **`@objectstack/metadata-protocol`: `rollbackToPackageCommit` refuses a legacy organization's commit by name.** It answered `COMMIT_NOT_FOUND` (404) for a commit that is there. It now gives `revertCommit`'s refusal: `NOT_OVERRIDABLE` (403), the same sentence, naming the commit and its organization. -- **`@objectstack/metadata`: `DatabaseLoader` counts what it reads.** `exists()` answered true for an address only a legacy organization row held, while `load()` answered null, and `queryHistory`'s `total` counted legacy history rows no page serves. Neither counts them now. -- **`@objectstack/metadata-protocol`: internal cleanup.** The organization plumbing that only ever carried the environment (`null`) is removed from the list merge and the served-row resolution. No export changes. -- **`@objectstack/spec`: wording.** The four ADR-0087 entries `18.sys-metadata{,-audit,-commit,-history}-organization-column-retired` now name the ceremony's reviewed apply, `os migrate organization-ownership --out PLAN` and then `--apply --plan-file PLAN`, which `--plan-file`'s own entry requires. No id, category, version or field the migration engine reads changes. -- **`@objectstack/cli`: wording.** `os migrate apply --allow-destructive`'s refusal on an unmigrated database now reads "a drop the organization-ownership ceremony owns, and the ceremony has not verified on this database". Its `--json` payload is unchanged. diff --git a/.changeset/pre/23610-page-isdefault-template-slots.md b/.changeset/pre/23610-page-isdefault-template-slots.md deleted file mode 100644 index c3b45189ad4..00000000000 --- a/.changeset/pre/23610-page-isdefault-template-slots.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/objectql': patch ---- - -**Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. - -feat(spec)!: a page declares nothing the console does not honour — `isDefault: true` is refused off a record page bound to an object, `template` closes to the registered layouts, and a region the template does not place is refused - -Clause-②: yes (narrowing) - -**BREAKING (authoring):** two accept sets of `PageSchema` narrow to what the console runs, and the `PageTypeSchema` text stops promising what it does not. - -| Writing | Before | Now | -| :--- | :--- | :--- | -| `isDefault: true` on a `home`, `app`, `utility` or `list` page, or on a record page with no `object` | accepted, stored and served; nothing read it | refused at `isDefault` — by `definePage`, `defineStack` (so `os validate` and `os build`, `STACK_SCHEMA_INVALID` 422), the metadata save door (`422 INVALID_METADATA`) and package registration (`SchemaRegistry.installPackage` / `registerItem`, `422 INVALID_METADATA`) | -| `template: 'full-width'` | accepted; drawn exactly as `default` | refused by name: "Page `template: 'full-width'` was removed in @objectstack/spec 18 … Use `template: 'default'`" | -| any other `template` outside `default`, `header-sidebar-main`, `three-column`, `dashboard` (`two-column`, `single-column`, …) | accepted; drawn as the page type's default layout | refused at `template` | -| on a `kind: 'full'` page, a region its template does not place (`left_sidebar`, `right_sidebar`, `content`, …) | accepted; stacked under `main` at full width, its `width` ignored | refused at `regions.N.name`, naming the template's slots and the rename | -| on a `kind: 'full'` page, a second region filling a slot an earlier one fills (two `main` regions; on `dashboard`, a second `header` or `footer`) | accepted; not drawn at all — the console draws the first region of a name and leaves that name out of the rest | refused at `regions.N.name`, naming the region that already fills the slot | - -Migration: - -- **`isDefault` on a non-record page** → delete it. A page does not choose where an app opens: an app's landing page is its **first navigation item** (by `order`), so to land on the page put its entry first in the app's `navigation`; the root landing follows the app's own `isDefault`. A record page meant as an object's default record page binds that object with `object`. -- **`template: 'full-width'`** → `template: 'default'` (or delete the key — `default` is the default). The page renders exactly as before. -- **A `template` held in a widened `string`** (a shared base object, `{ template: 'default' }` spread into `definePage`) no longer type-checks against the closed set → `template: 'default' as const`. -- **An unregistered template** → one of `default` (header, sidebar, main, aside, footer), `header-sidebar-main` (header, sidebar, main), `three-column` (header, sidebar, main, aside, footer) or `dashboard` (header, footer, and every other region a grid cell). -- **An unplaced region** → name it for the slot it fills: `left_sidebar` → `sidebar`, `right_sidebar` → `aside`, `content` → `main`; or choose `dashboard` when the regions are a grid. Names match case-insensitively, as the console's do. `slotted`, `html` and `react` pages are not judged on region names. -- **A second region for a filled slot** → move its components into the first region of that name, or name it for an unfilled slot. On `dashboard`, two regions of any other one name stay two grid cells and are accepted. - -The template set and each template's slots are now published: `PageTemplateSchema`, `PAGE_TEMPLATE_SLOTS` and `PAGE_TEMPLATE_OPEN_GRID` (`@objectstack/spec/ui`), with the two checks `checkPageDefaultPlacement` and `checkPageRegionPlacement` and the one refusal sentence `misplacedDefaultPageRefusal` that the parse and the registry share. - -`os migrate meta` lists both as hand edits — the D3 entries `page-is-default-off-record-page-refused` and `page-template-closed-region-unplaced-refused`. No D2 conversion rewrites them: where an app opens, and which slot a region fills, are the author's calls. - -**Stored metadata keeps loading.** No conversion touches these shapes and the read path does not refuse them, so a `sys_metadata` page row already stored with a non-record `isDefault: true`, `template: 'full-width'` or an unplaced region is served unchanged and renders as before (a stored row hydrates into the registry without a package, so the registry does not judge it either). Saving such a row again through the metadata API is refused until it is fixed. - -The text, without a contract change: `PageTypeSchema`'s docblock and enum comments now say that `home`, `app` and `utility` are the same region layout at three content widths — `home` is not a landing declaration, and `utility` is an ordinary inline page (the console has no floating or docked shell slot for it); `utility` stays a page type, read by the print door and the page-type width. The `print`-on-`utility` refusal no longer calls a utility page "a floating panel". - -`@objectstack/objectql`: the registry refuses `isDefault: true` on a non-record page a package registers (`MisplacedDefaultPageError`, `INVALID_METADATA` 422), at the package door and at the package-bound item seam, with the page parse's sentence. - - diff --git a/.changeset/pre/23610-skills-pages-contract.md b/.changeset/pre/23610-skills-pages-contract.md deleted file mode 100644 index f850074b321..00000000000 --- a/.changeset/pre/23610-skills-pages-contract.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -docs(skills): the `objectstack-ui` pages rule says what a page type, template and region name run (#23610) - -Clause-②: no - -`skills/objectstack-ui/rules/pages.md` taught `home` as the app's landing page, `utility` as a floating utility panel, templates `two-column` / `single-column` and regions `left_sidebar` / `right_sidebar`, and `kind: 'jsx'` as a live alias of `html` — none of which the console draws or the spec accepts. The rule now states the declared contract: `home` is the widest region page and not a landing (an app opens on its first navigation item; `isDefault` on it is refused), `utility` is a narrow inline region page with no floating or docked shell slot, `template` is one of `default`, `header-sidebar-main`, `three-column` or `dashboard` with the regions each one places (`sidebar` left, `aside` right; `full-width` retired), a region the template does not place or a second region for a filled slot is refused at its `name`, and `kind: 'jsx'` is refused with the prescription `html`. Skill text only; nothing an author writes changes. diff --git a/.changeset/pre/23615-grant-unique-index-skill.md b/.changeset/pre/23615-grant-unique-index-skill.md deleted file mode 100644 index 7a644c67e30..00000000000 --- a/.changeset/pre/23615-grant-unique-index-skill.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -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. diff --git a/.changeset/pre/23618-approval-title-family-served-fields.md b/.changeset/pre/23618-approval-title-family-served-fields.md deleted file mode 100644 index 4f6bd47f15d..00000000000 --- a/.changeset/pre/23618-approval-title-family-served-fields.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -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. diff --git a/.changeset/pre/23622-read-only-arms-journal-note-remainder.md b/.changeset/pre/23622-read-only-arms-journal-note-remainder.md deleted file mode 100644 index a04cf9591ee..00000000000 --- a/.changeset/pre/23622-read-only-arms-journal-note-remainder.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -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. diff --git a/.changeset/pre/23623-no-server-banner-urls.md b/.changeset/pre/23623-no-server-banner-urls.md deleted file mode 100644 index 77effbb773e..00000000000 --- a/.changeset/pre/23623-no-server-banner-urls.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -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. diff --git a/.changeset/pre/23630-promote-doors-d99.md b/.changeset/pre/23630-promote-doors-d99.md deleted file mode 100644 index 67fe5afcf24..00000000000 --- a/.changeset/pre/23630-promote-doors-d99.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -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. diff --git a/.changeset/pre/23632-form-section-labels.md b/.changeset/pre/23632-form-section-labels.md deleted file mode 100644 index 8878376e8ca..00000000000 --- a/.changeset/pre/23632-form-section-labels.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -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 diff --git a/.changeset/pre/23633-unrecognised-database-url-scheme.md b/.changeset/pre/23633-unrecognised-database-url-scheme.md deleted file mode 100644 index d4f9093ad28..00000000000 --- a/.changeset/pre/23633-unrecognised-database-url-scheme.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os serve` refuses an `OS_DATABASE_URL` whose scheme no driver recognises, reads `pg://` and a scheme-less path as the runtime host does, and opens `sqlite://x.db` as `./x.db` - -Clause-②: no - -This is the `os serve` path a host config takes (one whose `plugins` hold plugin instances), and the one `OS_MODE=off|none|legacy` takes. `os dev` and `os start` reach it through their `serve` child. - -- **Before, an unrecognised scheme (`OS_DATABASE_URL=redis://127.0.0.1:6399/0`).** `os serve --dev` and `os dev` printed `✓ Server is ready` and the banner `Driver: SqlDriver(better-sqlite3) → :memory:`. The database the operator named was dropped without a word, and every write was lost at restart. In production (`os serve`, `os start`) the boot met the no-storage refusal, which tells the operator to set `OS_DATABASE_URL` even though they had set it. -- **After.** Both postures exit 1 before any plugin starts with `✗ Unsupported database URL scheme "redis://" (OS_DATABASE_URL / --database). Supported: …`. The message lists the recognised spellings and names only the scheme, never the URL, so credentials in the URL are not printed. The standalone runtime host (`os dev` / `os start` over a plain config, `os migrate`) already refused these schemes, so the two hosts now agree. A `.db` or `.wasm.db` ending no longer rescues an unrecognised scheme: `redis://h/app.db` and `redis://h/app.wasm.db` used to be handed to SQLite as a file path, and both are now refused. A `*.wasm.db` name still selects the WASM engine for a `file:` / `sqlite:` URL or a path with no scheme. -- **`pg://…` and a path with no scheme (`./data/app`, `/var/lib/objectstack/app`).** The runtime host already reads these as postgres and as a SQLite file. Before, this path booted `--dev` on `:memory:` for them and production met the no-storage refusal. Now `pg://` is a postgres datasource and a scheme-less path is that SQLite file, in dev and production. -- **An opaque scheme (two or more characters and a `:` with no `//`, such as `jdbc:postgresql://h/db` or `redis:h`).** Before, `--dev` booted on `:memory:` for these and production met the no-storage refusal. Now they get the same refusal, naming the scheme (`"jdbc:"`), in both postures. A one-letter drive is a path, not a scheme (`C:\data\app`, `C:/data/app`). A file name whose first segment contains a colon now reads as a scheme too: `backup:2026.db` was a SQLite file, and is now refused. Write `./backup:2026.db` or `file:backup:2026.db` for that file. -- **`sqlite://x.db`.** Before, this path stripped `sqlite:` and opened `//x.db`, a file at the filesystem root. The banner read `→ sqlite://x.db`. The runtime host already reads `sqlite:` and `sqlite://` as aliases of `file:` and opens `./x.db`. This path now uses that same rule (`normalizeDatabaseUrl` from `@objectstack/runtime`), so `sqlite://x.db` and `sqlite:x.db` open `./x.db` and `sqlite:///abs/x.db` opens `/abs/x.db`, on both hosts. `sqlite://x.wasm.db` likewise opens `./x.wasm.db` on the WASM engine. - -`postgres://`, `postgresql://`, `mysql://`, `mongodb://`, `libsql://`, `wasm-sqlite://`, `file:`, `:memory:` and `*.db` paths resolve as before. An unset or blank `OS_DATABASE_URL` keeps the dev SQLite default. An explicit `OS_DATABASE_DRIVER` still wins over the URL, which is then not classified. diff --git a/.changeset/pre/23634-stack-datasource-default-refused.md b/.changeset/pre/23634-stack-datasource-default-refused.md deleted file mode 100644 index aa02acfdb13..00000000000 --- a/.changeset/pre/23634-stack-datasource-default-refused.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -**Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. - -feat(spec)!: a stack's `datasources` collection refuses a datasource named `default`, the name the host reserves for its primary datasource - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on one collection of the stack definition, shipped as `minor` under the launch-window convention for accept-set narrowings. No config that boots today is newly refused: every boot already refused this one. - -`default` is the host's primary datasource. The runtime declares and connects it itself, and the app plugin refuses a stack that declares it in every boot posture (with `OS_DATABASE_URL`, with `--dev`, and on the standalone path): - -> app 'APP_ID' declares a datasource named 'default' — that name is reserved for the host's primary datasource. - -`defineStack` and `os validate` accepted the same stack, so its author met that refusal only after the boot's earlier plugins had started. The stack's `datasources` collection now refuses the entry at authoring, located at `datasources.N.name` (the map form `datasources: { default: { … } }` is refused the same way), with the runtime's sentence and its remedy: - -> This stack declares a datasource named 'default' — that name is reserved for the host's primary datasource, and every boot refuses a stack that declares it. To give the project its own primary database, set OS_DATABASE_URL and omit this entry. To add a datasource of the stack's own, declare it under another name (e.g. 'app_primary') and route objects to it explicitly (an object's `datasource`, or a `datasourceMapping` rule). - -`DatasourceSchema` is unchanged: it also documents the host's own `default` row, which keeps parsing. - -### FROM → TO - -| before | what to write instead | -| --- | --- | -| `datasources: [{ name: 'default', driver: 'sqlite', config: { filename } }]`, meant as the project's database | no entry; set `OS_DATABASE_URL` (e.g. `file:./data/app.db`) and the host connects it as `default` | -| the same entry, meant as a datasource of the stack's own | `datasources: [{ name: 'app_primary', … }]`, and route the objects that use it: `datasource: 'app_primary'` on the object, or a `datasourceMapping` rule naming `app_primary` | - -**The one-line fix: drop the `default` entry and set `OS_DATABASE_URL`, or rename it and route objects to it.** `os migrate meta --from 17` lists the D3 entry `stack-datasource-host-reserved-name-refused`; the rewrite is by hand, because which of the two the entry meant is the author's call. - -**Measured producers.** None in this repository (examples, `create-objectstack`, skills, docs); the one instance found was an agent-written measurement fixture. diff --git a/.changeset/pre/23635-sqlite-wasm-background-flush-failure.md b/.changeset/pre/23635-sqlite-wasm-background-flush-failure.md deleted file mode 100644 index f2e1fd71ebb..00000000000 --- a/.changeset/pre/23635-sqlite-wasm-background-flush-failure.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/driver-sqlite-wasm': patch ---- - -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`. diff --git a/.changeset/pre/23636-no-storage-hydration-error.md b/.changeset/pre/23636-no-storage-hydration-error.md deleted file mode 100644 index 31c6a590a4b..00000000000 --- a/.changeset/pre/23636-no-storage-hydration-error.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -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. diff --git a/.changeset/pre/23637-lookup-target-title-row-access.md b/.changeset/pre/23637-lookup-target-title-row-access.md deleted file mode 100644 index ab96d6d54a6..00000000000 --- a/.changeset/pre/23637-lookup-target-title-row-access.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -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. diff --git a/.changeset/pre/23639-readable-ids-drivers.md b/.changeset/pre/23639-readable-ids-drivers.md deleted file mode 100644 index 206a1b7347a..00000000000 --- a/.changeset/pre/23639-readable-ids-drivers.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/core': minor -'@objectstack/driver-sql': minor -'@objectstack/driver-sqlite-wasm': minor -'@objectstack/driver-turso': minor -'@objectstack/driver-memory': minor -'@objectstack/driver-mongodb': minor ---- - -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. diff --git a/.changeset/pre/23644-failed-boot-rollback.md b/.changeset/pre/23644-failed-boot-rollback.md deleted file mode 100644 index 969f41398af..00000000000 --- a/.changeset/pre/23644-failed-boot-rollback.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/core': patch ---- - -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. diff --git a/.changeset/pre/23645-delete-heal-boot-hydrated.md b/.changeset/pre/23645-delete-heal-boot-hydrated.md deleted file mode 100644 index 237014ecb66..00000000000 --- a/.changeset/pre/23645-delete-heal-boot-hydrated.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -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. diff --git a/.changeset/pre/23646-adopt-orphans-object-registry-owner.md b/.changeset/pre/23646-adopt-orphans-object-registry-owner.md deleted file mode 100644 index 871b1e3d205..00000000000 --- a/.changeset/pre/23646-adopt-orphans-object-registry-owner.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -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. diff --git a/.changeset/pre/23648-sqlite-wasm-sql-driver.md b/.changeset/pre/23648-sqlite-wasm-sql-driver.md deleted file mode 100644 index b4f1825c50e..00000000000 --- a/.changeset/pre/23648-sqlite-wasm-sql-driver.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -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`. diff --git a/.changeset/pre/23649-datasource-mapping-before-sync.md b/.changeset/pre/23649-datasource-mapping-before-sync.md deleted file mode 100644 index 99d006079ba..00000000000 --- a/.changeset/pre/23649-datasource-mapping-before-sync.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/runtime': patch ---- - -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. diff --git a/.changeset/pre/23650-page-header-show-star-retired.md b/.changeset/pre/23650-page-header-show-star-retired.md deleted file mode 100644 index 76f7d75cabd..00000000000 --- a/.changeset/pre/23650-page-header-show-star-retired.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: retire `page:header` `showStar` — the follow-star switch the reference console no longer draws (#23650) - -**BREAKING** — `showStar` on a `page:header` component (`PageHeaderProps`) is now refused. Since objectui#12427 the header's record chip draws the follow star only where the host wires a favorites toggle into the record context, and the reference console wires none: the record's favorites entry is the header's ⋯ menu. So `showStar: true` (the default) drew nothing and `showStar: false` hid nothing. Delete the key; the ⋯ menu's favorites entry is unchanged, and metadata does not control it. - -Clause-②: no (narrowing) - -Measured before removal, at `140238a47`: no producer. No example app, skill, doc or package source in this repository authors the key outside the spec's own schema and tests, and neither does any app or example in objectui at the pinned `f96f771140f1`. - -## FROM → TO - -| you wrote (17.7 and earlier) | write instead | -| --- | --- | -| `showStar: true` or `showStar: false` in a `page:header`'s `properties` | nothing — delete the key; the record's favorites entry is the header's ⋯ menu | - -**The one-line fix:** delete `showStar` from every `page:header`. - -**What an author who still writes it sees.** `tsc` fails at a typed authoring site (`PageHeaderProps` types the key as retired), and `os validate`, `os build` and `os lint` refuse it as an `error` (`component-props-invalid`) at its own path with the prescription: - -> `page:header` property `showStar` was removed in @objectstack/spec 18 (ADR-0087 D2) — the reference console draws no star beside the record title, so the key switched nothing: the record's favorites entry is the header's ⋯ menu, and metadata does not control it. Delete the key, whether it was `true` or `false`; the ⋯ menu's favorites entry is unchanged. 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. - -A page's own parse keeps its component `properties` as an open bag, so an existing page carrying the key is never refused at load. - -## The retirement kit - -- **A `retiredKey()` tombstone** on `PageHeaderProps`, a `strictObject`, beside the `icon` and `breadcrumb` this row lost before it. `RETIRED_KEYS_BY_MAJOR[18]`: `ui/PageHeaderProps:showStar`. The authorable-surface row reads `[RETIRED]`, and the `showStar = true` default leaves the defaults baseline. -- **The D2 conversion `page-header-show-star-removed`** (protocol 18, retired from the load path) deletes the key from every `page:header` — region-level, nested in a container's `children`, and in a slotted page's named slot — `true` and `false` alike, one notice per header, as a lossless delete. It is scoped by component `type`: the same key name on another component is left alone. -- **The D3 entry `page-header-show-star-retired`** records the one case the conversion cannot decide: a host of your own that wires the favorites toggle now draws its star on every record header, a header that said `false` included. -- **No deprecation window**, per the project's startup-stage posture. - -**Not part of it:** the record context's favorites hook in the published objectui component library (`onToggleFavorite`) stays — this removes the metadata switch, not the host hook. The renderer's read of the key leaves on the objectui side. - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. - - diff --git a/.changeset/pre/23657-draft-save-one-package-draft.md b/.changeset/pre/23657-draft-save-one-package-draft.md deleted file mode 100644 index 40f93fd9491..00000000000 --- a/.changeset/pre/23657-draft-save-one-package-draft.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/spec': patch ---- - -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. diff --git a/.changeset/pre/23658-client-publish-package.md b/.changeset/pre/23658-client-publish-package.md deleted file mode 100644 index 8edaf13605a..00000000000 --- a/.changeset/pre/23658-client-publish-package.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/client': minor ---- - -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. diff --git a/.changeset/pre/23661-runtime-object-metadata-copy.md b/.changeset/pre/23661-runtime-object-metadata-copy.md deleted file mode 100644 index 8a9a8bb2063..00000000000 --- a/.changeset/pre/23661-runtime-object-metadata-copy.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -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. diff --git a/.changeset/pre/23666-ceremony-platform-global-column-drop.md b/.changeset/pre/23666-ceremony-platform-global-column-drop.md deleted file mode 100644 index b86718de3b1..00000000000 --- a/.changeset/pre/23666-ceremony-platform-global-column-drop.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -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. diff --git a/.changeset/pre/23673-parent-gate-readable-ids.md b/.changeset/pre/23673-parent-gate-readable-ids.md deleted file mode 100644 index cc6ff5106ba..00000000000 --- a/.changeset/pre/23673-parent-gate-readable-ids.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/plugin-audit': minor ---- - -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. diff --git a/.changeset/pre/23679-memory-readable-ids-hashed-once.md b/.changeset/pre/23679-memory-readable-ids-hashed-once.md deleted file mode 100644 index ae36136ce5f..00000000000 --- a/.changeset/pre/23679-memory-readable-ids-hashed-once.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/driver-memory': patch ---- - -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. diff --git a/.changeset/pre/23684-drop-gate-host-tables.md b/.changeset/pre/23684-drop-gate-host-tables.md deleted file mode 100644 index c5f6f177888..00000000000 --- a/.changeset/pre/23684-drop-gate-host-tables.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/cli': minor ---- - -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`. diff --git a/.changeset/pre/23686-dispatcher-unique-violation.md b/.changeset/pre/23686-dispatcher-unique-violation.md deleted file mode 100644 index 13fa0b20c32..00000000000 --- a/.changeset/pre/23686-dispatcher-unique-violation.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -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 diff --git a/.changeset/pre/23689-env-kernel-runtime-object-copy.md b/.changeset/pre/23689-env-kernel-runtime-object-copy.md deleted file mode 100644 index 95b6ced896e..00000000000 --- a/.changeset/pre/23689-env-kernel-runtime-object-copy.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -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. diff --git a/.changeset/pre/23690-adopt-orphans-draft-view.md b/.changeset/pre/23690-adopt-orphans-draft-view.md deleted file mode 100644 index 3db4eb4fdaa..00000000000 --- a/.changeset/pre/23690-adopt-orphans-draft-view.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -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. diff --git a/.changeset/pre/23696-sqlite-uncreatable-path.md b/.changeset/pre/23696-sqlite-uncreatable-path.md deleted file mode 100644 index c65d3310cb2..00000000000 --- a/.changeset/pre/23696-sqlite-uncreatable-path.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/driver-sql': patch ---- - -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. diff --git a/.changeset/pre/23701-mcp-unique-violation.md b/.changeset/pre/23701-mcp-unique-violation.md deleted file mode 100644 index 70fe169e22d..00000000000 --- a/.changeset/pre/23701-mcp-unique-violation.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -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 diff --git a/.changeset/pre/23702-sandbox-relay-duplicate.md b/.changeset/pre/23702-sandbox-relay-duplicate.md deleted file mode 100644 index 358024e78b2..00000000000 --- a/.changeset/pre/23702-sandbox-relay-duplicate.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -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 diff --git a/.changeset/pre/23710-liveness-print-flow-messages.md b/.changeset/pre/23710-liveness-print-flow-messages.md deleted file mode 100644 index 6340ecf4a70..00000000000 --- a/.changeset/pre/23710-liveness-print-flow-messages.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): a page that declares `print` is no longer warned that nothing applies it, and the flow terminal-toast translations read `live` (#23710) - -Clause-②: no - -**What was wrong.** The console now prints a page that declares `print`: its `PageRenderer` mounts a print stylesheet built from every key of the declaration (paper size and orientation, the four margins, the running header and footer, page numbers, and the two page-break hints). But the liveness ledger shipped with `@objectstack/spec` still graded `page.print` `planned` with an author warning, so `os validate` / `os build` / `os lint` told every author of a print page that printing it prints the screen layout. The `print` describes said the same ("until it ships, nothing applies it"). - -**What changes.** - -- `liveness/page.json`: `print`, its eight keys and the four `margins` sides read `live`, citing the console reader at the `.objectui-sha` pin. The container's `authorWarn` / `authorHint` are dropped, so `lintLivenessProperties` no longer emits `liveness-planned-property` for a page that declares `print`. -- `liveness/translation.json`: `flows.successMessage` and `flows.errorMessage` read `live`. The console's flow runner shows the translated terminal toast through `translateFlow` and falls back to the flow's own text when the bundle has none. -- `PagePrintSchema` and `PageSchema.print`: the describes name the console's browser print rendering as the consumer that applies each key. The server-side PDF renderer is still to come. - -Unchanged: what a `print` declaration accepts and refuses, and every other ledger row. diff --git a/.changeset/pre/23712-draft-save-d99-address.md b/.changeset/pre/23712-draft-save-d99-address.md deleted file mode 100644 index 3d37ed4b0fc..00000000000 --- a/.changeset/pre/23712-draft-save-d99-address.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -**Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. - -fix(metadata-protocol)!: a draft save naming no package of an object its package-less definition owns is refused when its draft address is a package's draft, with a way out to the package-less draft (ADR-0029 D9.9) - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The ADR-0029 D9.9 check of the object save door is now asked of the package the write lands in. A draft save naming no package (`PUT /api/v1/meta/object/NAME?mode=draft`, Studio's designer save) leaves the binding to the draft address, which is the item's one package `Q` when an active row is bound to `Q`, or `Q`'s draft when no active row is bound to a package and `Q`'s draft is the only one stored (ADR-0048). The check used to ask with the request's empty package, so an object whose owner is its package-less definition read as its own re-save, and the save wrote `Q`'s draft. It answered `200`, and the promote door then refused every publish of that draft. - -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, carrying `packageId: Q` and `ownerPackageId: null`. Only rows written before the save door refused a package's row beside the package-less one, or drafts bound to a package stored before that row existed, reach these states. - -- Over a row bound to `Q` stored beside the package-less one, with no draft, with a draft bound to no package, or with both that draft and `Q`'s: FROM `PUT /api/v1/meta/object/NAME?mode=draft` answering `200` with `Q`'s draft written (a new one, or the draft bound to no package adopted into `Q`), TO the refusal "A draft save naming no package of object 'NAME' writes a draft bound to 'Q', which no publish can serve: the object is served by its package-less definition. First DELETE /api/v1/meta/object/NAME?package=Q, then send this save again: it writes the package-less draft. Nothing was written." -- The same row with `Q`'s draft and no draft bound to no package: FROM `200` into `Q`'s draft, TO the refusal naming `Q`'s draft discard after the row's delete: "First DELETE /api/v1/meta/object/NAME?package=Q, then ?state=draft&package=Q (the draft discard), then send this save again: …". Deleting the row alone leaves `Q`'s draft, which the address then still names. Each package's draft is named the same way when several are stored. -- No row bound to a package, and `Q`'s draft the only draft stored: FROM `200` into `Q`'s draft, TO the refusal "First DELETE /api/v1/meta/object/NAME?state=draft&package=Q (the draft discard), then send this save again: it writes the package-less draft. …". - -**Remedy, in one line.** Send the requests the refusal names, in its order, then the same draft save: it writes the package-less draft, and the table, its records and the served definition stay as they are. A discard removes only that package's draft, which no publish could serve; the save sent again carries the edit. To move the object into `Q` instead, send the draft save naming `Q`: its refusal names that move. - -**Unchanged.** - -- A draft save naming no package where no package's row and no package's draft is stored: it writes the draft bound to no package. -- A draft save naming no package where the draft bound to no package is the address (no package's row, that draft stored): it is written, as before. -- A draft save naming no package over rows bound to two or more packages, or over two packages' drafts with no package row and no draft bound to no package: `409 RESOURCE_CONFLICT`, as before. -- Every save naming a package, every publish-mode save, and the promote doors: same answers and texts. -- An object a code package ships, an object owned by a runtime row bound to a real package, and every type except `object`. -- Stored rows are not rewritten on upgrade. diff --git a/.changeset/pre/23714-unique-violation-arm-relay.md b/.changeset/pre/23714-unique-violation-arm-relay.md deleted file mode 100644 index a667bda2d74..00000000000 --- a/.changeset/pre/23714-unique-violation-arm-relay.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-hono-server': patch -'@objectstack/runtime': patch ---- - -**Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. - -refactor(plugin-hono-server,runtime): the Hono doors and the HTTP dispatcher read the duplicate-record wire code off the shared error classification instead of spelling it themselves (#23714) - -**No behaviour change.** A repeated unique value still answers `409 UNIQUE_VIOLATION` at both Hono doors (a `HonoHttpServer` route or raw mount, and `createHonoApp`'s catch arms through `declaredEnvelopeForThrow`) and at the dispatcher's doors (`HttpDispatcher`'s thrown-error envelope), with the same `message`, the same `details` (`developerMessage`, `object`, and `field` when the driver named the column) and the same envelope keys. The answers were compared byte for byte at those doors before and after. A client or an app author has nothing to change. - -**What changed.** Both translations used to spell the wire code as a string literal. They now read it off the classification the REST server answers with (`structuredCodeAnswer` in `@objectstack/types`), as `@objectstack/mcp` already does. So the code has one source, and neither package stamps a code that the error-code ledger does not list under it. The engine's in-process code is unchanged: `DuplicateRecordError.code` is still `DUPLICATE_RECORD`. - -Clause-②: no diff --git a/.changeset/pre/23715-strict-draft-read-address.md b/.changeset/pre/23715-strict-draft-read-address.md deleted file mode 100644 index aa597e1d350..00000000000 --- a/.changeset/pre/23715-strict-draft-read-address.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/spec': patch ---- - -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. diff --git a/.changeset/pre/23718-sandbox-plain-object-throw.md b/.changeset/pre/23718-sandbox-plain-object-throw.md deleted file mode 100644 index 89acfc4324c..00000000000 --- a/.changeset/pre/23718-sandbox-plain-object-throw.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -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 diff --git a/.changeset/pre/23723-sandbox-unreadable-rejection.md b/.changeset/pre/23723-sandbox-unreadable-rejection.md deleted file mode 100644 index a551dc6d034..00000000000 --- a/.changeset/pre/23723-sandbox-unreadable-rejection.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -**Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. - -fix(runtime): a sandboxed body that throws a value no read can flatten (`throw ''`, a null-prototype object, an object whose `message` getter throws) now ends its run at once with the sentence `the script threw a value that could not be read`, instead of waiting for the wall-clock ceiling (#23723) - -**Latency and answer change, for these values only.** The QuickJS runner's reject handler records a body's rejection as a flattened string, and the run ends only once that string is non-empty. For these values the read threw or gave `''`, so nothing was recorded: the run waited for the sandbox's wall-clock ceiling (30 s by default) and ended as a budget error with no business message, which the doors answer as a crash. The handler now records the fixed sentence above for any value it cannot read, and the run ends on the same path as any other rejection. The sentence names nothing the value carried and no stack. - -Measured at `POST /api/v1/actions/:object/:action` on a `type: 'script'` action: - -- `throw { code: 'EMAIL_TAKEN', get message() { throw new Error('boom') } }` - - FROM, after the 30 s ceiling: `500 {"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error","httpStatus":500}}` - - TO, in about 0.1 s: `400 {"success":false,"error":{"code":"VALIDATION_ERROR","message":"the script threw a value that could not be read","httpStatus":400,"declaredCode":"EMAIL_TAKEN"}}` -- `throw ''` and `throw Object.create(null)` - - FROM, after the 30 s ceiling: the same `500 INTERNAL_ERROR` - - TO, in about 0.1 s: `400 {"success":false,"error":{"code":"VALIDATION_ERROR","message":"the script threw a value that could not be read","httpStatus":400}}` - -The status and envelope are the ones the door already gives a body that threw; no door changed. A `code` the value carries is kept, as for any other thrown value. Two more values that could not be read move the same way: an `Error` whose `name` and `message` are both empty, and an `Error` whose `message` getter throws. To give the caller words, throw a readable `message` (or an `Error`). - -Control, unchanged byte for byte: every value the handler could already read (a thrown `Error` or subclass, a plain `{ code, message }` object and the other plain-object shapes, primitives, the engine's relayed duplicate refusal, a nested hook refusal's sentence, the `code` / `fields` / `status` / `userMessage` fields, and a capability denial). A body that never settles still ends at the wall-clock ceiling. - -In-process: the `SandboxError` a caller catches for such a throw now reads `KIND 'NAME' threw: Error: the script threw a value that could not be read`, with that sentence as `innerMessage`, instead of the wall-clock budget error. - -Clause-②: no diff --git a/.changeset/pre/23726-read-only-arms-remainder.md b/.changeset/pre/23726-read-only-arms-remainder.md deleted file mode 100644 index f51bc457df5..00000000000 --- a/.changeset/pre/23726-read-only-arms-remainder.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/service-storage': patch -'@objectstack/objectql': patch ---- - -Clause-②: no - -fix(cli): `os storage orphans`, the `os secret rewrap` dry run and the `os migrate summary-nulls` dry run say they write no row or table, and that opening a SQLite file can still switch its journal mode, instead of saying nothing was written - -**What was wrong.** Each of these runs 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, each run 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 "nothing was written". Nothing was lost. - -**What changed.** Each sentence below now claims no row or table. Where the run opened a SQLite file, the report's line is followed by the note the other read-only 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 line ends without it. - -- `os storage orphans` (`formatStrandedOrphanInventory`, `@objectstack/service-storage`). FROM: "This pass is READ-ONLY: nothing was written, tombstoned or deleted." TO: "This pass is READ-ONLY: no row or table was written, tombstoned or deleted." and the note. -- `os secret rewrap` (the dry run's note, `REWRAP_NOTES.dryRun`, also in `--json` `report.notes`). FROM: "Dry run: every attributed row was opened, re-sealed and verified in memory, and nothing was written. Re-run with --apply to write." TO: "… and no row or table was written. Re-run with --apply to write." The note already follows the dry run's closing line on a SQLite file, so this sentence carries none. -- `os migrate summary-nulls` (the dry run that found NULL roll-ups, `formatSummaryBackfillReport`, `@objectstack/objectql`). FROM: "Dry run — nothing was written. Re-run with --apply to backfill." TO: "Dry run — no row or table was written. Re-run with --apply to backfill." and the note. - -**Unchanged.** No exit code, flag, prompt, refusal or export signature changes, and no `--json` document gains a field; 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. diff --git a/.changeset/pre/23731-activity-changed-fields.md b/.changeset/pre/23731-activity-changed-fields.md deleted file mode 100644 index f0a4532f890..00000000000 --- a/.changeset/pre/23731-activity-changed-fields.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/plugin-audit': minor -'@objectstack/cli': minor ---- - -feat(plugin-audit): `sys_activity` rows carry two writer-stamped read-rule columns, and the withheld-row rule is a predicate over them, with no SYSTEM pre-scan (ADR-0139 E6) - -**Run `os migrate activity-changed-fields --apply` at once after upgrading.** After the upgrade, and until it runs, the `sys_activity` rows written before the upgrade carry no stamp and are withheld from every non-system reader: activity feeds, the header bell and record timelines show only the rows written since the upgrade. The door is `os migrate activity-changed-fields`, dry run by default, `--apply` to write, idempotent, and it stamps both columns in one run. Its flags are `os migrate audit-metadata-bodies`'s, one for one (`--database-url`, `--apply`, `--yes`, `--force`, `--json`). The columns arrive with the upgraded deployment's schema sync, which an `--apply` run performs itself; a dry run against a database no upgraded process has opened cannot read them and exits `1`. - -Clause-②: yes - -`sys_activity` gains two columns, stamped at write time by one derivation (`planActivityStamp`): the CRUD mirror stamps its own rows, and a `sys_activity` before-insert hook stamps every row any other writer inserts (an app's own server action, the seed loader), over whatever value that writer sent. - -- `changed_fields` (`tags`) — the key set of the recorded change: for an update, every field its recorded `old` / `new` values name; `[]` for a create, a delete, a note, or an update that touched only `internal` fields. It is served narrowed to the fields the reader is served. -- `milestone_key` (`text`) — for a fired activity milestone, one sorted, comma-joined key of every field the object's `activityMilestones` declaration watched when it fired; `null` for every other row. It is never served. - -The withheld-row rule is now a WHERE over those two columns, one branch per parent object, on `find`, `findOne`, `count` and `aggregate`. It reads no row, so the 2,000-row SYSTEM pre-scan and its window are gone: a broad read of the stream is served every row the caller may read, with a true `total`. What the rule withholds: - -- an update row whose `changed_fields` is non-empty and names no field the reader is served (unchanged); -- a fired milestone row, unless its key equals the key of the object's CURRENT `activityMilestones` declaration and the reader is served every field that declaration watches. **The cost:** a milestone row written under an earlier declaration, or naming a since-removed field, is withheld from every non-exempt reader until retention ages it out (14 days declared; `lifecycle.retention_overrides` may lengthen it); -- an unstamped row, from every non-system reader: one at rest from before the upgrade until the backfill stamps it, and one the planner cannot judge (a milestone row naming no readable watched-field list), which stays unstamped. - -The parent-field query guard covers both columns as it covers `metadata`: a reader withheld any field of the parent is refused a filter, sort, group or aggregate over `changed_fields` or `milestone_key` (`PERMISSION_DENIED` / 403). - -`@objectstack/plugin-audit` exports the backfill, `backfillActivityChangedFields` (with `ACTIVITY_BACKFILL_PAGE_SIZE` and its report and engine types), and the planner, `planActivityStamp` (with `ACTIVITY_CHANGED_FIELDS_COLUMN`, `ACTIVITY_MILESTONE_KEY_COLUMN` and `ActivityStamp`). Inside the module (none of it is exported from the package), `computeWithheldUpdateFilter` keeps its name and now builds the predicate, `isWithheldOnlyUpdate` keeps its name as the update half over `changed_fields`, and `isWithheldMilestone` / `isWithheldRow` are gone with the pre-scan they judged for. diff --git a/.changeset/pre/23732-attachment-readable-ids.md b/.changeset/pre/23732-attachment-readable-ids.md deleted file mode 100644 index 10ef892ed8e..00000000000 --- a/.changeset/pre/23732-attachment-readable-ids.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/service-storage': minor ---- - -**Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. - -feat(service-storage): the `sys_attachment` read gate writes one `ReadableIds` leaf per parent object and does no pre-scan, so a broad read is judged over every row, not a 2,000-row window (ADR-0139 D4, D5) - -Clause-②: yes - -The gate serves an attachment only when the caller may read the record it is attached to. 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. - -The gate now writes one branch per object the metadata registry holds (except `sys_attachment` itself): `{ parent_object: P, parent_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: - -- A broad read of `sys_attachment` is served every attachment whose parent record the caller may read, with a true `total`, whatever the table size. An attachment whose parent the caller may not read, whose parent no longer exists, or that names no parent is still never served. A read scoped to one record (the attachments panel) 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_comment`, `sys_comment_reaction`, `sys_activity` and `sys_audit_log`); and a parent bound to another datasource than `sys_attachment`. The whole read also stays on the probe when the security service offers no `canReadObject`, or when none is registered. -- A read whose probe finds no candidate row is now narrowed to no rows rather than left unfiltered. It served nothing before either. -- On a driver that does not compile the leaf (`driver-mongodb`), a read of `sys_attachment` now fails with `INTERNAL_ERROR` / 500 naming the driver and the leaf, instead of serving a capped list. The gate does not turn that refusal into an empty answer. This is ADR-0139's ruled behaviour until a pipeline form is measured for that driver. - -For a host that installs the gate itself: `installAttachmentReadVisibility(engine, logger, getAdmission?)` takes an optional third argument that resolves the security service on every read. `StorageServicePlugin` passes it. A call without it keeps every read on the bounded probe and says so once. diff --git a/.changeset/pre/23734-heavy-flow-bounded-modal.md b/.changeset/pre/23734-heavy-flow-bounded-modal.md deleted file mode 100644 index fd4b47b9267..00000000000 --- a/.changeset/pre/23734-heavy-flow-bounded-modal.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -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. diff --git a/.changeset/pre/23735-reference-deleted-when.md b/.changeset/pre/23735-reference-deleted-when.md deleted file mode 100644 index 3da4128967e..00000000000 --- a/.changeset/pre/23735-reference-deleted-when.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-core': patch -'@objectstack/driver-sql': patch -'@objectstack/platform-objects': patch ---- - -**Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. - -feat(spec): `referenceDeletedWhen` beside `referenceVia` marks a polymorphic pointer's target as platform-deleted - -Clause-②: yes - -`FieldSchema` gains one optional key beside `referenceVia`: `referenceDeletedWhen: { field, value }`. On rows where the sibling `select` field `field` holds the option `value`, the record the pointer names was deleted by the platform's own assertion, and a renderer draws a tombstone from that option's own label instead of a link. Example: `record_id: Field.text({ referenceVia: 'object_name', referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' } })`. - -Any other shape is refused at parse, naming the field and the option: - -- the key on a field that declares no `referenceVia` (`FieldSchema`), or an unknown key inside it; -- a `field` that is the declaring field itself, is not declared on the object, is not a `select`, is a `multiple: true` select, or declares no inline `options` (a `picklist`-backed select); a `value` that is not one of the sibling's option values (`ObjectSchema`, at every door: `ObjectSchema.create()`, `defineStack`, raw `.parse()` and the metadata save door). - -Nothing that parsed before is refused: the key is new. Its liveness row is `planned` until the renderer that reads it ships. - -The other packages classify the new key where every field key is classified: - -- **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` deletes a served field's `referenceDeletedWhen` when its `field` names a field the caller cannot read (field-level security); `value` is an option value and never a field name. -- **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies it `presentation`: no DDL reads it. -- **`@objectstack/platform-objects`.** The field form's new `referenceDeletedWhen` row carries its labels and help text in zh-CN, ja-JP and es-ES. diff --git a/.changeset/pre/23736-record-id-deleted-when.md b/.changeset/pre/23736-record-id-deleted-when.md deleted file mode 100644 index ceab3a008be..00000000000 --- a/.changeset/pre/23736-record-id-deleted-when.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -fix(plugin-approvals): `sys_approval_request.record_id` declares that the record it names was deleted when `cancel_reason` is `record_deleted` - -Clause-②: no - -- **The pointer carries its own tombstone predicate.** `record_id` declares `referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' }` beside its `referenceVia: 'object_name'`. A request the platform cancelled because its record was deleted (`status: cancelled`, `cancel_reason: record_deleted`) therefore says, in the served object definition, that the pointer names a record that no longer exists. A renderer draws that option's label (`Related record deleted`) in place of a link, and reads nothing. -- **Nothing is accepted or answered differently.** The sibling is the existing `cancel_reason` select, which already holds `record_deleted`, so the spec's parse-time refusals of the key are satisfied by the shipped object. No export, type, route, stored value or request answer moves. - -A patch: one key on one platform object's served field definition. diff --git a/.changeset/pre/23737-uncreatable-db-dir-refusal.md b/.changeset/pre/23737-uncreatable-db-dir-refusal.md deleted file mode 100644 index e51a90122f4..00000000000 --- a/.changeset/pre/23737-uncreatable-db-dir-refusal.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/runtime': patch -'@objectstack/cli': patch ---- - -fix(runtime,cli): `os start` and `os dev` refuse a SQLite database whose directory cannot be made, at once, instead of hanging - -Clause-②: no - -**What was wrong.** The standalone host (`createStandaloneStack`, which `os start`, `os dev`'s standalone boot and every embedder run) prepared the directory of a file-backed SQLite database with Node's recursive `mkdirSync`. Node's recursive mkdir never returns when a level answers `ENOENT` although its parent exists: for `OS_DATABASE_URL=file:/proc/os-x/ctl.db` it looped `mkdir("/proc/os-x")` and `mkdir("/proc")` without end, holding the event loop. `os start` printed `→ Starting server...` and hung with no timeout and no message. The `sqlite-wasm` arm (`wasm-sqlite://…`, or `OS_DATABASE_DRIVER=sqlite-wasm`) did the same. `os dev` hung the same way in its own process when its default database lived under a state directory that cannot be made (for example `OS_HOME=/proc/os-x`). - -**What an operator sees now.** The boot exits non-zero within its normal start-up time, naming the database file, the directory and the operating system's error: - -```text -✗ [StandaloneStack] SQLite database file '/proc/os-x/ctl.db' cannot be created: its directory '/proc/os-x' could not be made: ENOENT: no such file or directory, mkdir '/proc/os-x' -``` - -`os dev` with an unusable state directory prints `✗ The default database '…/data/objectstack.db' cannot be created: its directory '…/data' could not be made: …`, followed by the remedy: point `OS_HOME` at a directory that can be created, or choose the database with `--database`. - -Nothing else changes. A creatable path is still made, every missing level of it. The read-only boot (`os migrate plan` over an absent database file) still creates no directory. The refusal names the path, never the database URL. diff --git a/.changeset/pre/23740-form-section-columns-no-default.md b/.changeset/pre/23740-form-section-columns-no-default.md deleted file mode 100644 index 07c6d3f5615..00000000000 --- a/.changeset/pre/23740-form-section-columns-no-default.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -**Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. - -feat(spec)!: a form section's `columns` has no default — undeclared, the density is the renderer's (by available width); declared, it is the author's ceiling - -Clause-②: no - -**BREAKING:** a parsed form section without `columns` no longer carries `columns: 1`. `FormSectionSchema.columns` drops `.default(1)`: the key stays optional with values `1`–`4`, and the string spellings `'1'`–`'4'` still parse to the number. - -| A form section authored as | Parsed before | Parsed now | -|:---|:---|:---| -| `{ fields: [...] }` (no `columns`) | `columns: 1` | no `columns` key | -| `{ columns: 2, fields: [...] }` | `columns: 2` | `columns: 2` (unchanged) | -| `{ columns: '3', fields: [...] }` | `columns: 3` | `columns: 3` (unchanged) | - -The two meanings, stated on the schema's describe and docblock: - -- **Declared** — the author's ceiling: at most that many fields per row where the container is wide enough. A renderer collapses it on narrow containers. -- **Undeclared** — the renderer's choice, by available width. After parse a renderer can now tell "the author said nothing" apart from "the author asked for one column", which the old default erased. No pixel or breakpoint value enters the protocol. - -Nothing to rewrite: every input accepted before is accepted now with the same declared value, and none refused before is accepted. A section that must stay single-column on a wide container declares `columns: 1`. A consumer that reads a parsed section's `columns` treats an absent key as "the renderer decides" rather than as `1`. `FormViewSchema.columns` (the form body) was already optional with no default and is unchanged. - - diff --git a/.changeset/pre/23743-no-server-announcements.md b/.changeset/pre/23743-no-server-announcements.md deleted file mode 100644 index 040556ca595..00000000000 --- a/.changeset/pre/23743-no-server-announcements.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): an `os serve` boot that binds no HTTP server sends no `objectstack:listening` message and writes no runtime state file, and the banner's `MCP:` row prints only when the MCP route is mounted - -Clause-②: no - -- **Before.** `os serve --no-server`, with no transport composed, sent `{ type: 'objectstack:listening', port, url: 'http://localhost:PORT' }` over an `ipc` channel and wrote `runtime...json` with the same `port` and `url`. Nothing listened on that port: a `GET` to it was refused. The docs define `objectstack:listening` as "The HTTP server is bound". -- **After, for a supervisor of a boot that binds no HTTP server** (`--no-server` with no config-mounted transport, or a transport plugin that did not load). It receives no `objectstack:listening` message. It still receives `objectstack:seed-settled`, which is then the boot's only message, so a parent waiting for the seed to settle does not hang. No `runtime...json` is written, so "is my server running, and where?" gets no answer naming an address. The file's payload, where it is written, is unchanged. -- **After, with an HTTP server composed.** Unchanged: the state file is written first, then the banner prints, then `objectstack:listening` is sent with the port actually bound, and `objectstack:seed-settled` follows it. That includes a `--no-server` boot whose config mounts its own `HonoServerPlugin`. The banner, the IPC message and the state file read the same check, a registered `http.server` service on the booted kernel, so they agree on whether there is an address. -- **The banner's `MCP:` row.** It and its "connect an AI client" hint now also require the plugin that mounts `/api/v1/mcp`, the runtime dispatcher, to be registered on the kernel. `os serve` composes the dispatcher only under `--server`. So a `--no-server` boot whose config mounts its own `HonoServerPlugin` prints `API:` but no longer prints an `MCP:` address, which answered 404 there. diff --git a/.changeset/pre/23744-kernel-only-lifetime.md b/.changeset/pre/23744-kernel-only-lifetime.md deleted file mode 100644 index 32abd83daac..00000000000 --- a/.changeset/pre/23744-kernel-only-lifetime.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): an `os serve` boot that composes no HTTP server now stays up until it is stopped, as its banner's `Press Ctrl+C to stop` says - -Clause-②: no - -- **Before.** `os serve --no-server --preset minimal`, in production with no storage driver (no `OS_DATABASE_URL`), printed the ready banner ending `Press Ctrl+C to stop` and then exited 0 by itself, about 50 ms later, with no signal and without the kernel shutting down. The same command stayed up whenever the storage driver kept a connection pool (`--dev --no-server`, or `--no-server` with `OS_DATABASE_URL` set). Measured, that pool's reaping interval was the only thing keeping those boots alive, so whether a kernel-only boot stayed up depended on the driver. -- **After, for an operator or supervisor of a boot with no HTTP server composed** (`--no-server` with no config-mounted transport, or a transport plugin that did not load). The process stays up after its banner, whatever the driver, until SIGINT, SIGTERM or SIGQUIT. The kernel's own signal handling then drains it and exits with the kernel's code, 0 on a clean shutdown, as on a server boot. No signal listener is added. -- **Why "stay up" and not "run and exit".** The banner says `Press Ctrl+C to stop`. The kernel is built to own the process: a signal drains it, then exits it. The platform's background loops never keep a process alive by themselves, so the host decides the lifetime. The run-and-exit kernel boot already exists, and is explicit: `OS_MIGRATE_AND_EXIT=1`. Every in-repo kernel-only boot that is meant to exit sets it, and every other one is stopped by a signal. -- **Unchanged.** A boot with an HTTP server composed, including a `--no-server` boot whose config mounts its own `HonoServerPlugin`. `OS_MIGRATE_AND_EXIT=1`, which still shuts the kernel down and exits 0 before any banner. The IPC messages and the runtime state file. -- **If you relied on the exit.** A script that ran `os serve --no-server` and waited for the process to end now has to set `OS_MIGRATE_AND_EXIT=1` (bootstrap once, then exit 0), or stop the process with a signal. diff --git a/.changeset/pre/23745-record-json-block.md b/.changeset/pre/23745-record-json-block.md deleted file mode 100644 index 1134689be8f..00000000000 --- a/.changeset/pre/23745-record-json-block.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`record:json` is a declared page component type — one key of the bound record drawn as indented JSON behind a required, fail-closed `requiredPermissions` gate — with a strict `ComponentPropsMap` row - -Clause-②: yes - -- **New `PageComponentType` member:** `record:json`, in the reserved `record` namespace. One node draws the value under one key of the bound record as indented JSON, in a panel that starts collapsed, with a copy control. Declared spec first: objectui draws it and `@objectstack/plugin-approvals` places it on the approval request page after this release. -- **Its `ComponentPropsMap` row (`RecordJsonProps`) is strict and has three keys:** - - `field` (required) — the key to draw, in the snake_case machine-name grammar. It may be a declared field or a key the record's read route serves beside the declared fields (`payload` on a `sys_approval_request` row), so it is not checked against the object's fields: a misspelled `field` draws nothing, and no publish check reports it. - - `label` (optional) — the collapsed panel's summary line, a string or an inline locale map. - - `requiredPermissions` (required, at least one non-empty name) — capabilities the user must all hold. It fails CLOSED: a client that cannot resolve the user's capabilities draws nothing, and so does a user missing any of them. Presentation only — it authorises nothing, and the read route still serves the value. -- **The record blocks' own `requiredPermissions` is unchanged.** On `record:details`, `record:highlights`, `record:related_list` and `record:quick_actions` it stays optional and fail-open; the same key name on `record:json` is a separate declaration with its own describe. -- **Refusals.** Any other key in `properties` is refused by name (`component-props-unknown-key` at `os validate` / `os build` / `os lint`); a node with no `field` or no `requiredPermissions`, or with `requiredPermissions: []`, is refused at parse. Inside a page that declares `print`, the type is refused with its own reason (a collapsed panel behind a per-viewer gate). -- **Consumers.** A consumer that derives coverage from `PageComponentType.options` or `ComponentPropsMap` — a designer palette, a renderer registry — sees one more member to classify. diff --git a/.changeset/pre/23753-navigation-record-surface.md b/.changeset/pre/23753-navigation-record-surface.md deleted file mode 100644 index 4c6b9f82c36..00000000000 --- a/.changeset/pre/23753-navigation-record-surface.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -docs(skills): the `objectstack-ui` navigation rule states the derived record surface per flow — viewing routes to a page, creating or editing opens a modal bounded on desktop and full-screen on mobile, a light record uses a drawer (#23753) - -Clause-②: no - -`skills/objectstack-ui/rules/navigation.md`'s Surface bullet said a field-heavy object opens create/edit/detail as a full page and that mobile always pages. The platform's derivation (`deriveRecordFlowSurface` in `@objectstack/spec`) routes only the `view` flow to a page; the create, edit and child task flows never route — a field-heavy record, or any record on mobile, opens them in a modal that is bounded on desktop and full-screen on mobile, and a light record on desktop opens every flow in a drawer. The bullet now states that mapping and names the override only where the spec declares it: `navigation.mode` on the list view (`navigation` is not an object key). Text only; nothing an author writes changes. diff --git a/.changeset/pre/23756-section-translation-tsdoc.md b/.changeset/pre/23756-section-translation-tsdoc.md deleted file mode 100644 index 9735d258608..00000000000 --- a/.changeset/pre/23756-section-translation-tsdoc.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Clause-②: no - -The published TSDoc now sends a form section's translations to the right key. `FormSectionSchema.name` used to name only `metadataForms..sections.`, which is the key for a metadata-editing form (`defineForm({ schemaId })`, read by `resolveMetadataFormLabels`). A section of an object-bound form view is keyed at `objects.._sections.`, where `` is the form's own `data.object`, else its view's. The docblock now names both keys and which form each one serves. `TranslationDataSchema._sections` now names the three surfaces that author its key (form-view sections, `record:details` sections, object `fieldGroups`) and its readers (`walkObjectSections`, `collectViewRecord`, `translateView`). `resolveMetadataFormLabels`' docblock no longer says that a section without a `name` keeps its section-level text: such a section is looked up under a slug of its `label`, as the function has always done. These are comment changes only: no key, `.describe()` text or behaviour changes. An object-form translation bundle written from the old docblock under `metadataForms.…` never translated anything. Move its section entries to `objects.._sections.`. diff --git a/.changeset/pre/23764-unsupported-scheme-no-url.md b/.changeset/pre/23764-unsupported-scheme-no-url.md deleted file mode 100644 index 6f549346e15..00000000000 --- a/.changeset/pre/23764-unsupported-scheme-no-url.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): the standalone host's database-URL refusals name the URL's scheme and never print the URL, so a password or token in `OS_DATABASE_URL` no longer reaches the console or the boot log - -Clause-②: no - -The standalone host is what `os dev`, `os start` and `os serve` boot over a plain config, and what `os migrate` boots. Its URL refusals interpolated the whole database URL, and the CLI prints that message as it exits. A URL is where postgres / mysql / mongodb credentials ride, so whenever such a URL was refused, the password and any token in its query string were printed. Each refusal now names only the scheme (the letters before `://`), which cannot hold a username, a password, a host, a path or a query. - -- **An unsupported scheme.** With `OS_DATABASE_URL=redis://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN`: - - FROM: `[StandaloneStack] Unsupported database URL scheme: redis://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN. Supported schemes: …` - - TO: `[StandaloneStack] Unsupported database URL scheme "redis://". Supported schemes: …`. The list of supported schemes is unchanged. `os migrate … --json` carries the same text in its `error` field. -- **A `memory://` or `mingo://` URL (the retired in-memory engine).** - - FROM: `The database URL "memory://…"` (or `OS_DATABASE_URL "memory://…"`) followed by the full URL. - - TO: `A database URL with the "memory://" scheme` (or `An OS_DATABASE_URL with the "memory://" scheme`). A declared default datasource reads `(driver: memory, URL scheme "memory://")`. The rest of the sentence, which names the SQLite replacements, is unchanged. -- **An explicit `sqlite` driver over a URL that is not a file path** (for example `OS_DATABASE_DRIVER=sqlite` beside a `postgres://…` URL). - - FROM: `sqlite driver was selected but the URL does not look like a file path: "postgres://PLACEHOLDER-USER:PLACEHOLDER-SECRET@…"` - - TO: `sqlite driver was selected but the URL does not look like a file path (its scheme is "postgres://")`. A bare `file:` now reads `(it names no file after "file:")`. - -What does not change: which URLs resolve, and to which driver; the URL handed to the driver, which keeps its credentials; and where each refusal is raised, with the same error class. Each message keeps the phrase that identifies it: `Unsupported database URL scheme`, `selects the in-memory (mingo) engine`, `does not look like a file path`. diff --git a/.changeset/pre/23765-opaque-scheme-refusal.md b/.changeset/pre/23765-opaque-scheme-refusal.md deleted file mode 100644 index 27349ca8519..00000000000 --- a/.changeset/pre/23765-opaque-scheme-refusal.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): the standalone host refuses an `OS_DATABASE_URL` with an opaque scheme (`jdbc:postgresql://…`, `redis:h`), and a `.wasm.db` ending no longer turns an unrecognised scheme into a SQLite file - -Clause-②: no - -This is the runtime's standalone host: `os dev`, `os start` and `os serve` over a plain config or an artifact, every `os migrate` subcommand, and every embedder of `createStandaloneStack`. - -- **Before, an opaque scheme (two or more characters and a `:` with no `//`).** `OS_DATABASE_URL=jdbc:postgresql://127.0.0.1:5999/db os serve --dev` printed `✓ Server is ready` and opened a SQLite file at a path made of the URL, creating the directories `./jdbc:postgresql:/127.0.0.1:5999/`. An operator who believed they were on Postgres wrote to that local file. This broke the host's own rule that an unknown URL scheme is refused and never falls back to SQLite. -- **Before, a `.wasm.db` ending.** It was tested ahead of every scheme check, so `OS_DATABASE_URL=redis://h/x.wasm.db` booted on a WASM SQLite file written under `./redis:/h/`. -- **After.** Both shapes get the refusal an unrecognised `://` already got, unchanged: `[StandaloneStack] Unsupported database URL scheme "jdbc:". Supported schemes: …`. The boot stops before any plugin starts and creates nothing on disk. The message names only the scheme, never the URL, so credentials in the URL are not printed. A `*.wasm.db` name still selects the WASM engine for a `file:` URL or a path with no scheme. The CLI's legacy `os serve` path already refuses these values, so the two hosts now give the same answer. -- **A one-letter drive is a path, not a scheme** (`C:\data\app.db`, `C:/data/app.db`). A file name whose first segment contains a colon now reads as a scheme, as it does on the CLI path: `backup:2026.db` was a SQLite file and is now refused. Write `./backup:2026.db` or `file:backup:2026.db` for that file. - -`postgres://`, `pg://`, `mysql://`, `mongodb://`, `libsql://`, `wasm-sqlite://`, `file:`, `sqlite:`, `:memory:` and paths with no scheme resolve as before. An explicit `OS_DATABASE_DRIVER` still wins over the URL, which is then not classified. diff --git a/.changeset/pre/23768-datasources-default-host-reserved.md b/.changeset/pre/23768-datasources-default-host-reserved.md deleted file mode 100644 index 249058a77d2..00000000000 --- a/.changeset/pre/23768-datasources-default-host-reserved.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -docs(skills): the `objectstack-data` datasources rule states that `default` is host-reserved — the host connects it from `OS_DATABASE_URL`, and a stack that declares a datasource named `default` is refused (#23768) - -Clause-②: no - -`skills/objectstack-data/rules/datasources.md`'s opening paragraph read "Most apps need only the `default` datasource; declare more to read/write a separate or external database." "Declare more" implied that `default` is declared too, and a stack whose `datasources` collection carries an entry named `default` is refused at `defineStack`, `os validate`, publish and boot: the host declares and connects `default` itself. The sentence is replaced by the rule the spec and runtime hold: `default` is host-reserved, the host connects it from `OS_DATABASE_URL`, a stack that declares a datasource named `default` is refused, and a datasource is declared under its own name only to read/write a separate or external database. The added words are paid for inside the same file by deleting two restatements of the field reference: "`external` settings are required when `schemaMode !== 'managed'`" (the schema refuses the omission itself) and "Set `autoConnect: true` to force a live connection" (the auto-connect list two lines above already names it). No behaviour changes; nothing an author writes changes. diff --git a/.changeset/pre/23772-principal-session-mint.md b/.changeset/pre/23772-principal-session-mint.md deleted file mode 100644 index d5486fbc947..00000000000 --- a/.changeset/pre/23772-principal-session-mint.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/plugin-auth': minor ---- - -feat(spec,plugin-auth): a short-lived principal-session mint, `IAuthService.mintPrincipalSession`, implemented by `AuthManager` - -Clause-②: yes (widening) - -An in-process service can now act as the requesting user through a door that only accepts a session, such as a headless browser rendering a page the user may see, under the user's own principal and never a system one. Nothing is removed and no HTTP route is added. - -- **`@objectstack/spec`** (`contracts`): an optional `IAuthService` member, `mintPrincipalSession(context, options?)`, its types `PrincipalSession`, `PrincipalSessionCookie` and `PrincipalSessionMintOptions`, and the constant `PRINCIPAL_SESSION_MAX_TTL_SECONDS` (`120`). The member is optional, so every existing `IAuthService` implementation compiles unchanged. -- **`@objectstack/plugin-auth`**: `AuthManager.mintPrincipalSession(context, options?)`. - -What the mint guarantees: - -- **Human principals only, refused before anything is minted.** A context with no user, a system context, or a `principalKind` other than `human` gets `PERMISSION_DENIED` / `403`. So does a user row that is missing, the system user or banned. A user an authentication policy holds gets `403` with the policy's own code (`MFA_REQUIRED`, `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED`), whether the posture rides on the context or is computed at the call. A `ttlSeconds` outside 1 to 120 gets `VALIDATION_ERROR` / `400`: it is refused, never clamped. -- **Short and never renewed.** The session lives at most 120 seconds. Reading it never extends it, on the cookie lane, the bearer lane or the in-process session read alike. -- **Revocable.** `revoke()` ends the session. Afterwards neither the token nor the cookie resolves one. It is bound to that session alone and safe to call twice, so call it in a `finally`. -- **The deployment's own cookie.** The cookie carries the name and attributes of the plugin's session cookie, the cookie prefix and, on a secure deployment, `__Secure-` included. The session's active organization is the context's `tenantId`. - -```ts -const auth = ctx.getService('auth'); -const session = await auth.mintPrincipalSession?.(context, { ttlSeconds: 60 }); -try { - // send session.token as `Authorization: Bearer`, or set session.cookie in a browser -} finally { - await session?.revoke(); -} -``` diff --git a/.changeset/pre/23775-flow-save-door-new-refusal.md b/.changeset/pre/23775-flow-save-door-new-refusal.md deleted file mode 100644 index 890ddfafe97..00000000000 --- a/.changeset/pre/23775-flow-save-door-new-refusal.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor -'@objectstack/service-automation': minor ---- - -feat(metadata-protocol)!: the metadata save door refuses a retired flow spelling on a NEW flow, and a stored flow keeps re-saving - -Clause-②: yes (narrowing) - -**BREAKING (API save door):** a flow saved through `saveMetaItem` (`PUT /api/v1/meta/flow/:name`, the runtime dispatcher, and every caller of the protocol) with no stored row of that name at the save's address is now judged as authored input. It is canonicalized without the ADR-0087 D2 conversions retired from the authoring funnel, so a spelling the loader stopped lifting at 18 is no longer rewritten and stored: it meets the flow parse's refusal, `422 INVALID_METADATA`, with an issue located at the key and the same prescription `os validate` and `os build` print. Draft and publish mode alike. Before, the door lifted the old spelling, stored the new one and said nothing, so the client that wrote it (an AI writing flows through MCP or REST above all) was never told and wrote it again. - -What counts as a stored row: the row the save overwrites, at its state and package, and for a draft save with no draft yet the active row the draft overlays, so Studio's first draft edit of a stored flow is a re-save. - -Not changed: - -- A re-save of a stored flow keeps replaying the full chain, retired entries included, so a legacy row stays editable and is healed by the save, exactly as before. -- The server's own writes of bodies at rest keep the full chain: the stored-metadata migration (`os migrate meta --stored`), a package duplicate and an unmanaged package import. -- The automation engine's flow rehydration (`registerFlow`) and the read path, which still serves a stored flow verbatim. - -Migration, for a client that creates flows through the API: - -- `wait` node loose `config.duration` / `config.timerDuration` → `waitEventConfig.timerDuration`; `config.signal` / `config.signalName` → `waitEventConfig.signalName`; `config.eventType` → `waitEventConfig.eventType`. On the block itself, `timeoutMs` → `timerDuration` and `onTimeout` is removed. -- `connector_action` node loose `config.connectorId` / `config.actionId` / `config.input` → its `connectorConfig` block. -- CRUD node `config.filters` → `config.filter`; `config.object` → `config.objectName`. -- `map` and `subflow` node `config.flow` → `config.flowName`. -- `notify` node `config.to` → `recipients`, `subject` → `title`, `body` → `message`, `url` → `actionUrl`, `source: { object, id }` → `sourceObject` / `sourceId`. -- `script` node `config.functionName` → `function`, `input` → `inputs`; `actionType` / `template` / `recipients` / `variables` / `script` are removed. -- `errorHandling.retryDelayMs` and a `try_catch` node's `retry.retryDelayMs` → `backoffMs`; the flow keys `active` / `template`, a node's `outputSchema` and `errorHandling.fallbackNodeId` are removed. - -Each refusal names the key to write. `os migrate meta --from 16` lists the same edits for source files. Rows already stored need nothing. - -`@objectstack/service-automation`: `AutomationEngine.canonicalizeStoredFlow(name, definition, options?)` takes `{ includeRetired?: boolean }`. Omitted, it is `true` (the full chain, as every caller holding a body at rest wants). `false` is the authoring posture the save door asks for. `registerFlow` does not take the option. - - diff --git a/.changeset/pre/23787-api-skill-retired-route-door.md b/.changeset/pre/23787-api-skill-retired-route-door.md deleted file mode 100644 index 2bc77f978dc..00000000000 --- a/.changeset/pre/23787-api-skill-retired-route-door.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -docs(skills): the `objectstack-api` skill stops teaching an app to mount routes on `http.server` — an app declares `apis:`, computes in a flow, and registers code as a stack function or a connector (#23787) - -Clause-②: no - -`skills/objectstack-api/SKILL.md`'s decision table sent an endpoint that needs code to an imperative `http.server` mount on `kernel:ready`, and its "Code routes" section taught that mount. Ruling C′ item 2 on #23128 retired the mount as an app-author surface: the `http.server` service is the platform plugins' own door-building primitive. The table now points code at a `type: 'flow'` endpoint whose `script` node runs a stack function (`defineStack({ functions })`) or whose `connector_action` node calls a declared connector, names the inbound `type: 'api'` flow hook for a webhook signed with ObjectStack's own `x-objectstack-signature`, and states the three cases that have no app surface today (a third-party callback verified by its own signature over the raw body, a streaming response, a protocol the platform does not speak) instead of inventing one. The "Code routes" section is deleted, and eval 3 expects that answer. Skill text only; nothing an author writes changes. diff --git a/.changeset/pre/23788-blank-scaffold-npm10-vitest-override.md b/.changeset/pre/23788-blank-scaffold-npm10-vitest-override.md deleted file mode 100644 index e4299427b3a..00000000000 --- a/.changeset/pre/23788-blank-scaffold-npm10-vitest-override.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'create-objectstack': patch ---- - -fix(create-objectstack): a blank scaffold installs on npm 10 again — its `package.json` overrides `vitest` to the range it already declares - -Clause-②: no - -A project from `npm create objectstack` (or `npx create-objectstack`) could not be installed with npm 10, the npm that Node 22 bundles and the one the scaffolder falls back to when pnpm is absent. `npm install` exited 1 with `Cannot read properties of null (reading 'edgesOut')`. The template's own dependencies were not the cause. Optional `vitest` peers deeper in the dependency tree led npm 10 to vitest 4.1.12, whose peer declarations contradict each other, and npm 10's resolver crashes on that. npm 11 installs the same project. - -The blank template now ships: - -```json -"overrides": { - "vitest": "~4.0.0" -} -``` - -This routes every `vitest` in the npm dependency graph to the range the project declares in `devDependencies`. npm 11 already resolves the whole tree to that version, and with the override npm 10.9.x installs the identical tree. pnpm does not read npm's `overrides` field, so pnpm installs are unchanged. - -If you change `devDependencies.vitest` in a scaffolded project, change `overrides.vitest` to the same value. npm refuses an override that differs from the direct dependency (`EOVERRIDE: Override for vitest@… conflicts with direct dependency`). An existing project that hits the `edgesOut` crash on npm 10 can install with npm 11 or later. diff --git a/.changeset/pre/23789-page-props-save-door-sentences.md b/.changeset/pre/23789-page-props-save-door-sentences.md deleted file mode 100644 index 9acc002a709..00000000000 --- a/.changeset/pre/23789-page-props-save-door-sentences.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -docs(spec): the step-18 upgrade text and the `exportOptions` TSDoc say a stored page's next save meets the component-props gate at the metadata save door - -Clause-②: no - -Text only; no accept set moves. Since the component-props gate (`validateComponentProps` / `validateComponentTypes`) runs on a `page` write at the metadata save door, three step-18 semantic entries that `os migrate meta` and the protocol upgrade guide print — `ui-page-action-row-operation-declared`, `ui-object-block-grouping-config-typed` and `element-text-variant-heading-subheading-retired` — and the `object-grid` `exportOptions` TSDoc in the `.d.ts` still said a page component's `properties` is not parsed on the save path. They now say what runs: a stored page still loads, because `properties` is not parsed on the metadata load path, and the next save of the page meets the component-props gate at the metadata save door (422 `INVALID_METADATA` on a finding), beside `os validate`, `os build` and `os lint`. diff --git a/.changeset/pre/23792-memory-url-refusal-no-url.md b/.changeset/pre/23792-memory-url-refusal-no-url.md deleted file mode 100644 index f7e77e4dce4..00000000000 --- a/.changeset/pre/23792-memory-url-refusal-no-url.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os serve`'s refusal of a `memory://` or `mingo://` `OS_DATABASE_URL` names the URL's scheme and never prints the URL, so a password or token in it no longer reaches the console - -Clause-②: no - -This is the `os serve` path a host config takes (one whose `plugins` hold plugin instances), and the one `OS_MODE=off|none|legacy` takes. `os dev` and `os start` reach it through their `serve` child. Both schemes name the retired in-memory (mingo) engine, and both accept anything after the `//`, a `user:password@` and a query string included. The refusal interpolated the whole URL, and the CLI prints it as it exits 1. - -With `OS_DATABASE_URL=memory://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN`: - -- FROM: `✗ OS_DATABASE_URL "memory://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN" selects the in-memory (mingo) engine, …` -- TO: `✗ An OS_DATABASE_URL with the "memory://" scheme selects the in-memory (mingo) engine, …` - -`mingo://` reads the same way, with `"mingo://"`. These are the runtime host's words for the same URL (`os dev` / `os start` over a plain config, and `os migrate`), so the two hosts print one sentence. The rest of the sentence, which names the SQLite replacements (`os dev --fresh`, and the URL `:memory:`), is unchanged. - -What does not change: both schemes are still refused, with the same error class and exit code, in dev and production; every other URL resolves as before, to the same driver. diff --git a/.changeset/pre/23794-file-url-filename-rule.md b/.changeset/pre/23794-file-url-filename-rule.md deleted file mode 100644 index ddeb2ac13bc..00000000000 --- a/.changeset/pre/23794-file-url-filename-rule.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/runtime': minor -'@objectstack/cli': patch ---- - -fix(runtime,cli): an upper-case `FILE:` database URL opens the file its lower-case spelling opens, `file://NAME` opens `NAME` on both hosts, and the URL-to-filename rule is exported as `sqliteFilenameFromUrl` - -Clause-②: yes (widening) - -- **`FILE:` is stripped case-insensitively.** The standalone host classified `FILE:./app.db` as SQLite case-insensitively but stripped the prefix case-sensitively, so the prefix stayed in the file name: `OS_DATABASE_URL=FILE:./app.db` created a directory literally named `FILE:.` and put the database inside it, and `File:///tmp/app.db` was refused as an unsupported `File://` scheme. Both now open the file the lower-case spelling opens (`./app.db`, `/tmp/app.db`), as the `sqlite-wasm` path already did. Lower-case `file:` URLs and bare paths are unchanged. -- **New export: `sqliteFilenameFromUrl(url, kind)`** from `@objectstack/runtime`, `kind` being `'sqlite'` or `'sqlite-wasm'`. It is the one rule that turns a SQLite database URL (after `normalizeDatabaseUrl`) into a file name: `file:` / `file://` stripped case-insensitively, plus `wasm-sqlite:` for the WASM kind. For `'sqlite'` it throws a plain `Error` on a URL that is not a file path, naming the URL's scheme and never the URL. Nothing is removed. -- **`os serve`'s legacy storage path (`OS_MODE=off`, `bootMode: 'off'`, or a host config) turns a SQLite URL into a file by that same rule**, so both hosts open one file per URL. It used to strip only a lower-case `file:`: `OS_DATABASE_URL=file://NAME` opened `//NAME` at the filesystem root, where the standalone host opens `./NAME`, and `FILE:./app.db` made a `FILE:.` directory there too. `file:///abs/app.db` opens `/abs/app.db`; `sqlite:` / `sqlite://` URLs are unchanged. -- **What that rule refuses, the legacy path now refuses too, and fatally**, with the standalone host's own sentence (it names the URL's scheme, never the URL): an explicit `OS_DATABASE_DRIVER=sqlite` over a server URL such as `postgres://user:password@host/db`, which used to create directories named after the URL, credentials included, and a `file:` URL that names no file, which used to declare a database with an empty file name. Fix: point `OS_DATABASE_URL` at a file (`file:./app.db`), or select the driver the URL is for. The undocumented `sql://` prefix that path also stripped under an explicit `sqlite` selection is refused the same way; write `file:` instead. A blank `OS_DATABASE_URL` under an explicit `sqlite` selection is read as absent (`:memory:`), as an absent one always was. diff --git a/.changeset/pre/23802-setup-copy.md b/.changeset/pre/23802-setup-copy.md deleted file mode 100644 index bf141cf265e..00000000000 --- a/.changeset/pre/23802-setup-copy.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/platform-objects': patch -'@objectstack/metadata-core': patch -'@objectstack/service-messaging': patch ---- - -Clause-②: no - -Setup pages describe their records in plain admin sentences. Business Units now reads "Your -company structure — divisions, departments, regions and offices — used to scope data and -reports.", Notification Events "Every notification the platform sent, one row per event.", -Notification Preferences "Which topics and channels each user receives; admins set the -defaults.", Notification Templates "The subject and body each notification topic sends, per -channel and language." and HTTP Deliveries "Outgoing HTTP calls made by webhooks and flows, one -row per call, with its delivery status." Field help on Users, Organizations, Invitations and SSO -Providers, and the metadata-storage and notification-delivery object descriptions, no longer -carry design-record numbers; those references stay in the code comments. The zh-CN, ja-JP and -es-ES translations follow. Wording only: no field, permission or behaviour changes. diff --git a/.changeset/pre/23803-setup-nav-user-entries.md b/.changeset/pre/23803-setup-nav-user-entries.md deleted file mode 100644 index 48e1b64025d..00000000000 --- a/.changeset/pre/23803-setup-nav-user-entries.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -feat(platform-objects): Setup's menu no longer lists "User Preferences" or "Identity Links". You reach both from the user they belong to: open a user in Setup › Users, and the user's page has a "Preferences" tab and its existing "Linked Accounts" tab. - -Clause-②: no - -Both lists held rows that belong to one user each: identity links are written when a user signs in with a provider, and preferences are what the console remembers as a user works (recent items, favourites, list settings). A menu entry listing them for every user at once was not a page an administrator works from. - -- **Where they are now.** On a user's page, "Preferences" lists that user's preference rows (key, created, updated) and "Linked Accounts" lists their identity links, with the same Unlink action. New, Edit and Delete on the Preferences tab follow the same rules as the old list did. Search finds the user, and the tabs are on the user's page. -- **What did not change.** The two objects, their list views and their addresses are unchanged, so a bookmarked list still opens, and a preference row's breadcrumb still leads to the full list. -- **Nothing to migrate.** No data moves and no setting changes. diff --git a/.changeset/pre/23804-own-ui-state-no-ledger-row.md b/.changeset/pre/23804-own-ui-state-no-ledger-row.md deleted file mode 100644 index 6645e948e39..00000000000 --- a/.changeset/pre/23804-own-ui-state-no-ledger-row.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -fix(plugin-audit): the audit ledger no longer records a user's own writes to their own UI-state row (`sys_user_preference`). A deployment that exports `sys_audit_log` to a SIEM or a compliance archive stops receiving those rows from this release on, and nothing else tells that consumer. - -Clause-②: no - -The console saves a user's list preferences, recent items, favourites and palette recents as that user's own `sys_user_preference` rows, one write per click. Each write landed an `update` row in `sys_audit_log`, so Setup's Audit Logs opened on a run of one user's clicks, and the System Overview's "Events by User" chart counted them. - -- **What changed.** When the acting user is the row's owner (`user_id`) on both sides of the write, the audit writer records neither the `sys_audit_log` row nor the `sys_activity` row. It already skipped the activity row for such a write; it now skips the ledger row by the same test. A create, an update and a delete are all covered. -- **What is still recorded.** Every other actor's write to the same rows: an administrator editing someone's preferences from Setup, a system write with no user, and a write that moves a row to another owner. `sys_user_preference` is not an excluded object, and every other object is unchanged. -- **Nothing to migrate.** Rows already in `sys_audit_log` stay. - -The list of per-user UI-state objects is closed and declared inside the plugin, and today holds one object, `sys_user_preference`. No metadata key adds an object to it. diff --git a/.changeset/pre/23805-scaffold-vitest-override.md b/.changeset/pre/23805-scaffold-vitest-override.md deleted file mode 100644 index 2b57b03185a..00000000000 --- a/.changeset/pre/23805-scaffold-vitest-override.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -Clause-②: no - -`os init` (every template: `app`, `plugin`, `empty`) and `os create plugin` now emit -`"overrides": { "vitest": "~4.0.0" }` in the scaffolded `package.json`, so the project installs on -npm 10, the npm Node 22 bundles. Since vitest 4.1.12 shipped a peer set that contradicts itself, -`npm install` of a fresh `os init` project crashed on npm 10.9.x with -`Cannot read properties of null (reading 'edgesOut')`. The override routes every `vitest` edge in -the npm graph to the range the scaffold declares; npm 11 installs the same tree, and pnpm ignores -the field. - -The override equals `devDependencies.vitest` wherever the manifest declares vitest directly, -because npm refuses an override that differs from the direct dependency (`EOVERRIDE`). In an `app` -or `empty` project, add vitest at the same range (`npm install -D vitest@~4.0.0`); a bare -`npm install -D vitest` is refused with `EOVERRIDE` while the override is in place. Existing -projects can add the same `overrides` block by hand. `os create plugin --in-repo` is unchanged. diff --git a/.changeset/pre/23806-metadata-core-vitest-peer.md b/.changeset/pre/23806-metadata-core-vitest-peer.md deleted file mode 100644 index f654b9de11f..00000000000 --- a/.changeset/pre/23806-metadata-core-vitest-peer.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/metadata-core': patch ---- - -fix(metadata-core): the package installs on npm 10 again, and into projects that use vitest 5, because it no longer declares an optional `vitest` peer - -Clause-②: no - -On npm 10, the npm that Node 22 bundles, `npm install @objectstack/metadata-core` exited 1 with `Cannot read properties of null (reading 'edgesOut')`. So did installing a package that depends on it, such as `@objectstack/objectql`. The cause was this package's optional peer `vitest: ^4.0.0`. It led npm 10 to vitest 4.1.12, whose own peer declarations contradict each other, and npm 10's resolver crashes on that. The same peer also made npm refuse (`ERESOLVE`) to install the package into a project that already uses vitest 5. - -The package's main entry never imports `vitest`. Only the test-only `@objectstack/metadata-core/testing` entry does, so the peer is removed. Nothing the package exports changes. - -If you import `@objectstack/metadata-core/testing`, list `vitest` in your own `devDependencies`, as that entry's documentation already says. This package runs that suite on vitest 4. In a pnpm workspace with hoisting turned off (`hoist=false`), that entry can no longer find `vitest` from inside a workspace package. Declare the peer back in your `pnpm-workspace.yaml`: - -```yaml -packageExtensions: - '@objectstack/metadata-core': - peerDependencies: - vitest: '*' -``` - -`@objectstack/plugin-auth` still fails the same way on npm 10, because its `better-auth` dependency declares its own optional `vitest` peer. Install it with npm 11, or add `"overrides": { "vitest": "~4.0.0" }` to your project's `package.json`. diff --git a/.changeset/pre/23808-type-descriptions-plain.md b/.changeset/pre/23808-type-descriptions-plain.md deleted file mode 100644 index 444d3fa52d3..00000000000 --- a/.changeset/pre/23808-type-descriptions-plain.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/platform-objects': patch ---- - -Clause-②: no - -The `description` of four metadata types in `DEFAULT_METADATA_TYPE_REGISTRY`, which Setup shows under the -type's title, is now one plain sentence for an administrator instead of ADR vocabulary: - -- `api`: "A stable HTTP URL that other systems call to run a flow or work with records in the app" -- `doc`: "A help page written in Markdown that a package ships or you write yourself" -- `book`: "A table of contents that groups help pages into ordered sections" -- `capability`: "A permission a package defines; you grant it through permission sets" - -The ADR references (ADR-0121, ADR-0046, ADR-0046 §6, ADR-0066 D1) moved to the comments beside the -registry rows. The generated metadata-forms translations carry the new sentences in en, zh-CN, ja-JP -and es-ES. No key, type or accept/refuse result changes. diff --git a/.changeset/pre/23809-select-option-kind.md b/.changeset/pre/23809-select-option-kind.md deleted file mode 100644 index 1b14b9febe0..00000000000 --- a/.changeset/pre/23809-select-option-kind.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/platform-objects': patch -'@objectstack/metadata-core': patch ---- - -feat(spec)!: a select option declares its lifecycle `kind` (`open` / `won` / `lost`), and every option of an object's `stageField` must declare it - -Clause-②: yes (narrowing) - -`SelectOptionSchema` gains one optional key, `kind`, a closed vocabulary of three lifecycle categories that means the same on every object: `open` (a step on the way), `won` (the successful end: a deal's Closed Won, a lead's Converted, a task's Done, a case's Resolved) and `lost` (the unsuccessful end: Closed Lost, Unqualified, Cancelled). It is the spelling `record:path` `stages[].terminal` already takes, so the data layer and the page say one thing one way. Example: `{ label: 'Closed Lost', value: 'closed_lost', kind: 'lost' }`. The exported type `SelectOptionKind` names the three values. - -- On a plain single-value `select` / `radio` the key is optional, and an absent kind reads as `open`. Nothing is written in at parse: an option without `kind` parses without it. -- On any other field's options (`multiselect`, `checkboxes`, `tags`, a `multiple: true` select, a lookup's static options) `FieldSchema` refuses it at `options..kind`: a lifecycle is one value per record. -- A shared `picklist`'s options accept it (the same option shape). A form view's option list does not (`FormSelectOptionSchema`): it is refused there as an unknown key whose message points at the object field's own option. - -**BREAKING (authoring):** when an object's `stageField` names a field, every option of that field must declare `kind`. `ObjectSchema` refuses the object otherwise, at every door that parses one (`ObjectSchema.create()`, `defineStack`, `os validate`, `os build`, the metadata save door), with one issue per option at `fields..options..kind` naming the field and the option. There is no default under `stageField`. Also refused, at `stageField`: a stage field that takes its options from a `picklist` (the object cannot see them to check), and a stage field carrying options that holds no single option code per record (`multiselect`, a `multiple: true` select). Unchanged: `stageField: false`, a `stageField` naming no declared field, and one naming a field with no options. - -Migration: add `kind` to each option of the stage field, marking each terminal state `won` or `lost` and every other state `open`: - -- `status: Field.select({ options: [{ label: 'New', value: 'new' }, { label: 'Converted', value: 'converted' }, { label: 'Unqualified', value: 'unqualified' }] })` with `stageField: 'status'` → the same options with `kind: 'open'`, `kind: 'won'` and `kind: 'lost'`. -- A picklist-backed stage field → declare its options inline on the field. -- An object with no linear lifecycle → `stageField: false`. - -No conversion makes this edit, because which option ends a record's life successfully is the author's to say. `os migrate meta` lists the D3 entry `stage-field-option-kind-required` with the prescription. - -The Studio object designer's options repeater offers `kind`; the field form's options repeater lists it. - -The other packages follow the new key: - -- **`@objectstack/platform-objects`.** The metadata-form translation bundles (`./metadata-translations`) carry the options repeater's new `kind` row, with its label and help text in zh-CN, ja-JP and es-ES. -- **`@objectstack/metadata-core`.** The published contract fixture `FLS_CONTRACT_OBJECT` (`@objectstack/metadata-core/testing`) names `salary_grade` as its `stageField`, so its two options now declare `kind: 'open'`, and the fixture meets the stage-field rule: `ObjectSchema` raises no issue at `stageField` or under `fields.salary_grade.options`. The fixture is not otherwise a spec-valid object (its `script` actions carry no `body` or `target`), and this change leaves that as it was. - - diff --git a/.changeset/pre/23813-minted-session-no-refresh.md b/.changeset/pre/23813-minted-session-no-refresh.md deleted file mode 100644 index b100f4deab7..00000000000 --- a/.changeset/pre/23813-minted-session-no-refresh.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -Clause-②: no - -A session minted by `IAuthService.mintPrincipalSession` is no longer refreshed by a read. Before, -`get-session` (and every endpoint that reads the session) still renewed it on each request: #23772's -guard held its expiry, but the row's `updatedAt` was rewritten, the session cookie was re-set with a -7-day `Max-Age`, and `bearer()` answered `set-auth-token`. The console reads that header as a token -rotation, so a browser carrying a minted cookie stayed on "Opening your console…" and the print -service could not render under the requesting user. A request that carries a minted session now -sets better-auth's own per-request skip-refresh switch, on the cookie lane, the bearer lane and -in-process. The mint's contract, TTL, refusals and revoke are unchanged. diff --git a/.changeset/pre/23816-turso-remote-ownership-ceremony.md b/.changeset/pre/23816-turso-remote-ownership-ceremony.md deleted file mode 100644 index e01df417435..00000000000 --- a/.changeset/pre/23816-turso-remote-ownership-ceremony.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/driver-turso': minor -'@objectstack/cli': minor -'@objectstack/runtime': minor ---- - -feat(driver-turso,cli): the v18 organization-ownership ceremony runs on a Turso remote database - -Clause-②: yes (widening) - -`os migrate organization-ownership`, the plan and `--apply --backup-taken`, now completes against a Turso database reached over the remote transport (`libsql://…`), the transport hosted environments use. Before, the plan stopped at its boot with `Deferred schema DDL is not supported by the Turso REMOTE transport`, and the apply's column drops had no remote path either. - -- **`@objectstack/driver-turso`**, remote face: - - `detectManagedDrift()` answers instead of refusing with `NOT_IMPLEMENTED` / 501. The verdict is the shared differ's, the one the local face runs; the physical schema it diffs is read from the remote catalog (`pragma_table_info`, `sqlite_master`, `pragma_index_list` / `pragma_index_info`, and the ADR-0120 D4 duplicate probe). The declarations it diffs against are recorded by every remote schema door and by the DDL-free `registerObjectMetadata`, so a boot that skipped schema sync is covered too. - - `applyMigrationEntries()` applies `drop_index`, `create_index`, `recreate_index`, `replace_unique_index` and `drop_column` instead of refusing. Each entry runs as one libSQL `write` batch, so it lands whole or not at all: a `drop_column` drops the column's indexes with it in the same transaction, and a re-created index that fails to build leaves the old one in place. An entry is reported `applied` only once a read-back shows its target state. Every other column entry (nullability, width, a dropped DEFAULT) needs the SQLite table rebuild, which this transport does not run, so it is reported `skipped` and logged, as the in-place dialects report an op they cannot perform. - - The remote face declares `driftReportCaveat`: a sentence saying its drift report is for reading only and naming the reason. The OS_ARTIFACT_URL boot gate reads it (below). The local and embedded-replica faces declare `null`. - - The remote driver's `config` now carries its `url`, so the CLI labels a remote database by its URL (credential-free) instead of `better-sqlite3`. The ceremony writes that label into its plan and refuses an apply whose plan names another database; that refusal can now tell two remote databases apart. -- **`@objectstack/cli`**: the ceremony's plan and apply boots skip schema sync, instead of arming deferred DDL, when the primary datasource is a Turso remote one. No DDL is attempted, so the plan stays read-only, and every other datasource the boot connects is still deferred. The environment's own boot has already run the additive sync the ceremony reads. Every other `os migrate` command keeps refusing on that transport: their dry runs list held-back schema work, which a boot that holds nothing back cannot list. -- **`@objectstack/runtime`**: `createStandaloneStack` takes `skipSchemaSync`, passed to `ObjectQLPlugin` as the option `OS_SKIP_SCHEMA_SYNC=1` names. Unset, the plugin reads the variable as before. - -On a Turso remote primary, the `OS_ARTIFACT_URL` boot migration gate on `os serve` keeps its earlier answer: cannot judge, warn and boot. It applies no drift and refuses no boot. Only its warning's wording changes, from `Could not check the physical schema` to `Did not check the physical schema against the artifact: …`, followed by the driver's reason. The gate now decides this from a capability the driver declares (`driftReportCaveat`, a string on the remote face and `null` on the local and embedded-replica faces), not from a refusal it catches. Two follow-ups, filed separately and not closed by this change, have to land before that gate may act on a remote report: -- #23847: the remote transport's schema sync honours `storage.notNull` on create and add column, so the report stops naming a tightening its own sync produced; -- #23848: `os migrate apply` works on a Turso remote database, so the destructive drift the gate would refuse on has a remedy there. diff --git a/.changeset/pre/23820-dev-watch-compile-refusal.md b/.changeset/pre/23820-dev-watch-compile-refusal.md deleted file mode 100644 index 0bf3283d0e5..00000000000 --- a/.changeset/pre/23820-dev-watch-compile-refusal.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os dev` (and `pnpm dev` on a scaffold) prints why a watched save was refused, not only `✗ compile failed` - -Clause-②: no - -When a saved file makes the compile refuse, the watcher now prints the compile's output under its failure line: the same lines `pnpm build` prints on that tree, without `--verbose`. - -- **Before.** On a fresh `os init my-app -t app` scaffold (namespace `my_app`), saving an object named `task` printed `↻ recompiling (2 files)...` and then a bare `✗ compile failed (3782ms)`. The reason never appeared. The compile writes its refusals to stdout, and the watcher discarded stdout and printed stderr only. -- **After.** The failure line is followed by the compile's stdout, then its stderr. For that save it shows `✗ Object 'task' is missing the package namespace prefix. Rename it to 'my_app_task' (namespace = 'my_app').` and the lines around it. - -What does not change: a successful recompile prints exactly what it printed before, and the compile's progress output stays hidden on success. `--verbose` still passes the compile's output straight through, so on a failure nothing is printed twice. A failed compile still writes no artifact and still triggers no restart. diff --git a/.changeset/pre/23826-ceremony-report-file-reserved.md b/.changeset/pre/23826-ceremony-report-file-reserved.md deleted file mode 100644 index 5f361968f9e..00000000000 --- a/.changeset/pre/23826-ceremony-report-file-reserved.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os migrate organization-ownership --apply` refuses an `--out` it cannot write before it executes anything - -Clause-②: no - -**What was wrong.** The apply wrote its report file only after the ceremony had run, with an exclusive create. Handed an `--out` that already existed (a re-run with the same path), it executed every step (attribution, the id-to-name rewrite, mirror deletion, the column drops) and only then exited 1 with `apply_refused` / `report-file-unwritable`, the same `--json` code a refusal before any write prints. An operator read a completed ceremony as one that never ran. - -**What an operator now sees.** - -- An `--out` that cannot be written (an existing file, a directory that does not exist, no write permission) is refused before the database is opened: exit 1, `{"error":"apply_refused","reason":"report-file-unwritable","file":"FILE","code":"EEXIST",…}`, and nothing is executed. The existing file is left as it was. Re-run with `--out` naming a new path. -- The report file is reserved (created exclusively) before the boot, and the report is written into that same file at the end, so no other process can take the path in between. Until then the file holds a short JSON note (`"kind": "organization-ownership-apply-reservation"`) saying a run reserved it. A run that is refused, fails a step or cannot boot removes it again; a run killed before its report leaves that note behind, never an empty file. -- A report that still cannot be kept after the ceremony executed (a write error, or the reserved file removed while the run went on) gets its own code: exit 1, `{"error":"apply_report_unwritable","reason":"report-write-failed","executed":true,"report":{…}}`, with the full report in the payload. The text output still says "The ceremony ran". Re-run `--apply` with a new `--out` to write the report; finished steps are not repeated. -- Without `--out`, the default `organization-ownership-apply-TIMESTAMP.json` is stamped with the moment the run reserves it (before the boot), no longer with the moment the report was generated. - -The plan run (without `--apply`) is unchanged: it changes nothing in the database, so writing its file last was never a hazard. diff --git a/.changeset/pre/23829-setup-copy-services.md b/.changeset/pre/23829-setup-copy-services.md deleted file mode 100644 index 2842d8563f5..00000000000 --- a/.changeset/pre/23829-setup-copy-services.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch -'@objectstack/plugin-security': patch -'@objectstack/service-automation': patch -'@objectstack/plugin-sharing': patch ---- - -Clause-②: no - -The approval, security, automation and sharing objects describe their records in plain admin -sentences. Approval Delegations now reads "While someone is away, approvals assigned to them -personally go to their delegate.", Record Shares "Records shared beyond their owner, one row per -grant.", User Positions "Which positions each user holds, one row per assignment.", Audience -Binding Suggestions "A package's suggestion to give one of its permission sets to everyone who -signs in, and the decision taken on it." and Automation Runs "Paused automation runs waiting to -resume, and the recent history of finished runs." Field help on Approval Requests, Approval -Delegations, User Positions, User Permission Sets and Audience Binding Suggestions, the -descriptions of the `organization_admin_no_bypass`, `mcp_agent_data_read` and -`mcp_agent_data_write` permission sets, the flow-dispatch ledger's description and the -reserved-position refusal no longer carry design-record numbers or runtime vocabulary; those -references stay in the code comments. The zh-CN, ja-JP and es-ES translations follow. Wording -only: no field, permission or behaviour changes. diff --git a/.changeset/pre/23838-identity-feeds-off.md b/.changeset/pre/23838-identity-feeds-off.md deleted file mode 100644 index 7a2a56c47d8..00000000000 --- a/.changeset/pre/23838-identity-feeds-off.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'@objectstack/platform-objects': minor ---- - -feat(platform-objects)!: Setup's organization, team, business-unit and user records carry no Discussion feed: `sys_organization`, `sys_team`, `sys_business_unit` and `sys_user` declare `enable.feeds: false`, so a comment create on one of their records answers `403 FEEDS_DISABLED` - -Clause-②: no (narrowing: the comment create door refuses on four platform objects that accept it today) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -Setup's identity records are directory entries, not discussion surfaces. The four objects declared no `enable.feeds`, so they took the default (`true`), and the console drew "Discussion (0)" with a comment composer on every organization, team, business unit and user record. - -What changes: - -- **The record page.** The console reads the flag (`enable.feeds !== false`), so it no longer draws the Discussion feed or its composer on these four objects' records. The record's activity history is unchanged. -- **The data API.** A `sys_comment` create whose `thread_id` names one of these objects (`sys_user:USER_ID`, `sys_organization:ORG_ID`, `sys_team:TEAM_ID` or `sys_business_unit:UNIT_ID`) answers `403 FEEDS_DISABLED` and writes nothing, through REST, the SDK and every other caller of the data engine. An update that moves an existing comment into one of these threads is refused the same way. -- **Comments already stored** on these records are kept. They stay readable through the data API, and an edit that does not change `thread_id`, such as a body edit, still saves. - -Unchanged: every other platform object and every application object keeps its own `enable.feeds` setting, and an object that declares none still accepts comments. - -Remedy: stop creating comments on these four objects' records. A discussion about a user, a team or an organization belongs on a record of an object that keeps its feed on. diff --git a/.changeset/pre/23839-settings-warning-banners.md b/.changeset/pre/23839-settings-warning-banners.md deleted file mode 100644 index 9fc25c47b8f..00000000000 --- a/.changeset/pre/23839-settings-warning-banners.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/service-settings': patch ---- - -Clause-②: no - -fix(service-settings): the File Storage, Knowledge and AI settings pages show their warnings as `info_banner` callouts with `bannerSeverity: 'warning'`, not as a sentence inside a subtitle or a field's help text - -Four warnings were written into ordinary strings with a hand-typed warning glyph, so the admin read them as one more sentence of the page subtitle or of a toggle's help. Each one is now an `info_banner` row in the section it concerns, directly under the field it warns about: - -- `storage` — `adapter_switch_warning`, under the Adapter select: switching adapter does not migrate existing files. -- `knowledge` — `adapter_switch_warning`, under the Adapter select: switching adapter does not migrate existing indices. -- `knowledge` — `enforce_rls_warning`, under the Enforce RLS on search toggle: disabling it skips the vector-store leakage safeguard. -- `ai` — `log_prompts_warning`, under the Log full prompts toggle: full prompts may leak PII. - -The namespace description or field help of each keeps its non-warning text. The translations moved with them: in the `en`, `zh-CN`, `ja-JP` and `es-ES` bundles each warning sentence left the `description` / `help` copy and sits under `settings.NAMESPACE.banners.BANNER_ID.{title,text}`, with a translated title per banner. The existing Feature Flags `beta_notice` banner gained the same copies, so no shipped locale reads it in English any more. - -No setting key, value, default or save behaviour changes. A host that renders `info_banner` rows from the manifest literals draws the English title and text; translated copy is read through `resolveSettingsBannerTitle` / `resolveSettingsBannerText` from `@objectstack/spec/system`. diff --git a/.changeset/pre/23842-automation-stamps-unbound-cause.md b/.changeset/pre/23842-automation-stamps-unbound-cause.md deleted file mode 100644 index bc2d1ba1630..00000000000 --- a/.changeset/pre/23842-automation-stamps-unbound-cause.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/service-automation': minor ---- - -feat(automation): `getFlowRuntimeStates()` stamps `unboundCause` beside `reason` - -Clause-②: yes (widening) - -Each branch that decides why an enabled, triggered flow is not armed now answers its closed `FlowUnboundCause` together with its sentence: `deployment_policy`, `disabled_subflow`, `missing_trigger` or `binding_failure`. `GET /automation/_status` passes the rows through unchanged, so the field reaches the console with no route change. Bound, disabled and trigger-less rows carry neither `reason` nor `unboundCause`. The sentences and `getTriggerBindingAudit()` are unchanged. diff --git a/.changeset/pre/23842-flow-runtime-state-unbound-cause.md b/.changeset/pre/23842-flow-runtime-state-unbound-cause.md deleted file mode 100644 index d8d3b4281cf..00000000000 --- a/.changeset/pre/23842-flow-runtime-state-unbound-cause.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): `FlowRuntimeState` gains `unboundCause`, a closed classifier beside `reason` - -Clause-②: yes (widening) - -`FlowRuntimeState.reason` is a sentence a consumer renders and must not parse, so until now a console could not tell a flow left unarmed by deployment policy from one whose binding is broken: both arrive as `enabled: true, bound: false` with a `reason`. The row now also carries an optional `unboundCause`, present on exactly the rows that carry `reason` and absent on every other row. - -Its values are published as `FLOW_UNBOUND_CAUSES` (`@objectstack/spec/contracts`), with the derived type `FlowUnboundCause`: - -- `deployment_policy`: package-authored scheduled work is switched off on this deployment. An operator remedy, not a defect. -- `disabled_subflow`: the flow calls a packaged subflow that is disabled (ADR-0126 §7.3). Enable the subflow and the caller arms. -- `missing_trigger`: no trigger of the declared type is registered. Depend on the trigger package (`requires: ['triggers']`). -- `binding_failure`: the trigger is registered but binding the flow to it failed. The earlier boot warnings name the error. - -Additive: existing producers and consumers keep working unchanged, and `reason` keeps its text. diff --git a/.changeset/pre/23847-turso-remote-not-null.md b/.changeset/pre/23847-turso-remote-not-null.md deleted file mode 100644 index 4d11253ff9d..00000000000 --- a/.changeset/pre/23847-turso-remote-not-null.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/driver-turso': patch ---- - -fix(driver-turso): a Turso remote database enforces the `storage.notNull` a field declares, on the tables and columns its schema sync creates - -Clause-②: no - -**What was wrong.** On a Turso database reached over the remote transport (`libsql://…`, the transport hosted environments use), schema sync created every declared column nullable. A field declared `storage: { notNull: true }` was not enforced there: an insert that left it empty was stored. The same declaration on a local SQLite file (`file:`) has always produced a `NOT NULL` column. The drift report, which compares the database with the declarations through the shared differ, also read each such column as destructive `tighten_not_null` drift on a database the remote sync had just created. - -**What changes.** The remote face's `CREATE TABLE` and `ALTER TABLE … ADD COLUMN` add `NOT NULL` to a column whose field declares `storage.notNull`, wherever the local face adds it: on every field type, multi-value fields included, and never for `required` alone. On a remote database synced after this release, an insert that leaves such a field empty is refused by the database, and the drift report names no `tighten_not_null` for it. - -**What does not change.** - -- A column that already exists is not touched. A remote database synced by an earlier release keeps its nullable columns, and its drift report still names them `tighten_not_null`; no data at rest is migrated. So the remote face still declares its drift-report caveat (reworded to say this), and the `OS_ARTIFACT_URL` boot gate still does not act on a remote report. -- Adding a `storage.notNull` field to a remote table that already holds rows is refused by SQLite (`Cannot add a NOT NULL column with default value NULL`), as it is on a local SQLite file, and that object's schema sync fails loudly. The remote error now names the refused statement. One case still differs: when the field also declares a literal `defaultValue`, the local face creates the column with that DEFAULT and accepts it, while the remote face emits no declared column DEFAULT and refuses it. diff --git a/.changeset/pre/23848-migrate-apply-turso-remote.md b/.changeset/pre/23848-migrate-apply-turso-remote.md deleted file mode 100644 index d37a028b2ec..00000000000 --- a/.changeset/pre/23848-migrate-apply-turso-remote.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/cli': minor -'@objectstack/driver-turso': minor ---- - -fix(cli,driver-turso): `os migrate plan` and `os migrate apply` run on a Turso REMOTE database, and the apply names every drift entry it could not apply - -Clause-②: yes - -On a primary reached over the Turso REMOTE transport (`libsql://…`, every hosted tenant), `os migrate plan` and `os migrate apply` used to stop at their boot with `Deferred schema DDL is not supported by the Turso REMOTE transport`, so the remedy the drift report and the `OS_ARTIFACT_URL` boot gate print for destructive drift, `os migrate apply --allow-destructive`, could not run there at all. Both commands now boot the way `os migrate organization-ownership` does on that transport: with schema sync skipped instead of deferred. What an operator on a remote primary now sees: - -- **The drift half runs.** `os migrate plan` lists the drift; `os migrate apply` applies what the transport can express, after the same confirmation: a column drop (with the indexes keyed on it, in one transaction) and every index entry. Nothing is written before the prompt, as before. -- **The additive half is reported as NOT examined, never as "nothing to create".** Both commands print a warning under the database line saying that tables and columns the metadata declares and the database lacks are neither listed nor created by this run, and that the serving boot (`os serve` / `os start`) creates them on this transport. The `--json` documents carry `schemaSync: "skipped"`, so their `pending: []` reads as not examined; an apply with no drift answers `message: "in_sync_partial"`, not `"in_sync"`. -- **Every entry the driver did not apply is named,** by table, column and kind, in both faces (`--json`: a new `notApplied` array on the post-apply document). The REMOTE transport runs no SQLite table rebuild, so a tightening, a relaxed NOT NULL, a width change or a dropped DEFAULT is not applied there. -- **A destructive entry confirmed with `--allow-destructive` that was not applied makes the run PARTIAL:** the human face ends `PARTIAL apply: …` and the command exits `1`; `--json` carries `message: "partial"` and exits `1`. A non-destructive entry that was not applied is named and the exit stays `0`. - -On every other database the apply behaves as before, with one difference in the same direction: a destructive change confirmed with `--allow-destructive` that the driver could not apply (its DDL failed, or the dialect cannot perform it) now exits `1` and is named, where the run used to exit `0` with a count. Its human line for the entries held back without `--allow-destructive` now reads `Skipped N destructive change(s) (re-run with --allow-destructive to include them)`. - -`@objectstack/driver-turso`: the REMOTE face's `driftReportCaveat` no longer says a destructive entry has no remedy on this transport; it says `os migrate apply --allow-destructive` applies a column drop or an index change there and names the rest. - -The other `os migrate` commands whose dry run arms deferred DDL (`duplicates`, `account-issuer`, `unmapped-columns`, `multi-value-columns`, and the dry runs of the data commands) still refuse on the REMOTE transport, unchanged. diff --git a/.changeset/pre/23852-setup-empty-states.md b/.changeset/pre/23852-setup-empty-states.md deleted file mode 100644 index 3c0a6394217..00000000000 --- a/.changeset/pre/23852-setup-empty-states.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/platform-objects': patch -'@objectstack/service-messaging': patch ---- - -Setup's Invitations, API Keys, OAuth Applications and Notification Preferences, Subscriptions and Templates lists say what belongs on them when they are empty, and API Keys gains a Create Key button (#23852) - -Clause-②: no - -**What an admin saw.** An empty Invitations, API Keys or OAuth Applications list read "No identity records — these records are created by the authentication provider … not added by hand here", which is false for all three. The three notification lists read the generic "Nothing here yet". API Keys had no way to create a key at all. - -**What changes.** - -- Every list view of `sys_invitation`, `sys_api_key` and `sys_oauth_application` declares its own `emptyState` (a title and one sentence), and a filtered view names itself: Invitations opens on "No pending invitations", the Revoked keys view reads "No revoked keys". -- `sys_notification_preference`, `sys_notification_subscription` and `sys_notification_template` each ship one list view, `all`, declared only for its `emptyState`: it sets no columns (`columns: []`, so the grid keeps the columns it derived before), no filter, no sort and no page size. -- `sys_api_key` gains `create_api_key` ("Create Key"), a primary list-toolbar action on every API Keys list, Setup's and the Account app's. It calls the existing `POST /api/v1/keys` route with the name you give it; the key is created for you, in the organization you are working in, and the raw key is shown once in a dialog you close after copying it. Nothing about the route changes, and the data API still does not create keys. -- The new copy ships in `en`, `zh-CN`, `ja-JP` and `es-ES`. - -Unchanged: every field, index, permission and the `revoke_api_key` / `restore_api_key` row actions. diff --git a/.changeset/pre/23853-form-help-texts-plain.md b/.changeset/pre/23853-form-help-texts-plain.md deleted file mode 100644 index a12d9559fb3..00000000000 --- a/.changeset/pre/23853-form-help-texts-plain.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/platform-objects': patch ---- - -Clause-②: no - -Six help texts an administrator reads in Setup no longer carry ADR numbers. On the Permission Sets edit form, -`isDefault`, `managedBy` and `packageId` are rewritten as plain sentences that say what the field does, and -`adminScope` drops its "(ADR-0090 D12)" prefix. On the Positions edit form, `delegatable` drops "(ADR-0091 D3)". -The view form's End-user controls description drops "(ADR-0047, Airtable Interface parity)". - -The ADR references moved to the comments beside the form rows. The generated metadata-forms translations carry -the new wording in en, zh-CN, ja-JP and es-ES. No key, type or accept/refuse result changes. diff --git a/.changeset/pre/23855-settings-banner-translation-slot.md b/.changeset/pre/23855-settings-banner-translation-slot.md deleted file mode 100644 index 7fec24d3909..00000000000 --- a/.changeset/pre/23855-settings-banner-translation-slot.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Clause-②: yes (widening) - -The platform translation bundle (`PlatformTranslationDataSchema`) gains a slot for a settings -`info_banner` row: `settings..banners..title` and `.text`, keyed by the -specifier's `id`, beside `groups`, `keys` and `actions`. `title` translates the specifier's `label` -(the heading spelling `groups` already uses) and `text` translates its `bannerText` (markdown). -Before this, a translated banner was refused with `unrecognized_keys`, so a banner reached every -locale in its manifest English. The slot is strict: a borrowed `label`, `bannerText` or -`description` leaf, or a singular `banner` group, is refused with the spelling it meant. - -Two resolvers read it, `resolveSettingsBannerTitle` and `resolveSettingsBannerText`, from the -`resolveSettings*` family: they walk the requested locale and the declared fallback chain, then -return the manifest literal. The `info_banner` specifier's own accept set is unchanged. Give the -banner an `id` to make it addressable; a banner without one keeps rendering its literals. diff --git a/.changeset/pre/23857-comment-parent-pair.md b/.changeset/pre/23857-comment-parent-pair.md deleted file mode 100644 index 648c724ad5d..00000000000 --- a/.changeset/pre/23857-comment-parent-pair.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/plugin-audit': minor -'@objectstack/cli': minor ---- - -feat(plugin-audit): `sys_comment` rows carry a writer-stamped parent pair, `object_name` + `record_id`, and the comment-thread read gate narrows by it with one `ReadableIds` leaf per parent object, with no pre-scan (ADR-0139 E7) - -**Run `os migrate comment-parent-pair --apply` at once after upgrading.** After the upgrade, and until it runs, the `sys_comment` rows written before the upgrade carry no pair, and the read gate answers them as it answered every comment before: by a SYSTEM pre-scan of at most 2,000 candidate rows per read, fail closed. So a broad comment list — one not scoped to a single record's thread — can omit comments the reader may see, and its `total` agrees with the short list; a record's own comment thread is unaffected. The door is `os migrate comment-parent-pair`, dry run by default, `--apply` to write, idempotent, and it stamps both columns in one run, keeping each row's `updated_at`. Its flags are `os migrate activity-changed-fields`'s, one for one (`--database-url`, `--apply`, `--yes`, `--force`, `--json`). The columns arrive with the upgraded deployment's schema sync, which an `--apply` run performs itself; a dry run against a database no upgraded process has opened cannot read them and exits `1`. - -Clause-②: yes - -`sys_comment` gains two `readonly` text columns and an index over them. They are derived from `thread_id` (`{object}:{record_id}`, split on the first colon) by one planner, `planCommentParentPair`, and `thread_id` stays the key authors write: nothing changes for a writer. A `sys_comment` before-insert hook stamps every inserted row, whoever writes it, over whatever value the writer sent; an update that names `thread_id` (a re-point) is re-stamped from it; and an update that names either column without a thread has the pair re-derived from the row's stored thread, for every caller — a historical import (`preserveAudit`) included, where the engine's update-side `readonly` strip keeps a value — so no writer's value for either column lands. A multi-row update that names either column without a thread is refused whole (`VALIDATION_ERROR` / 400, nothing written) when its rows' threads name different records, since one write cannot store each row's own pair; over rows sharing one thread it stores that thread's pair. A thread that names no record stamps `null` / `null`, and such a comment stays hidden from every non-system reader, as before. - -The comment-thread read gate (`installCommentReadVisibility`) is now the parent-record gate the activity stream and the compliance ledger use, over the comment's own pair: one branch `{ object_name: P, record_id: { $in: ReadableIds.of(P) } }` per registered object other than `sys_comment`, ORed and ANDed into the read's `where`, on `find`, `findOne`, `count` and `aggregate`. A broad comment read is served every comment the reader may read, on any table size, with a true `total`. What keeps the bounded 2,000-row fail-closed probe, chosen up front by name, never by catching a refusal: - -- ADR-0139 D5's three classes: a delegated (on-behalf-of) caller (the whole read); a parent narrowed by an object-scoped read middleware (`sys_attachment`, `sys_comment_reaction`, `sys_approval_request`, …); a parent bound to another datasource; -- a security service that answers no `canReadObject`, or none at all (no leaf is written without it), and an engine that cannot classify a parent — each sends the whole read to the probe and is said once; -- a row with no stamped pair (until the backfill reaches it): probed by its `thread_id`, restricted to rows with no pair, never leafed; -- a composition whose registered `sys_comment` declares no pair: the thread probe over every row, said once. - -**The cost:** a deployment whose driver compiles no `ReadableIds` leaf — `driver-mongodb`, until ADR-0139's O3 — now fails a non-system comment read with `INTERNAL_ERROR` / 500 naming the driver in the log, as the activity, ledger and attachment reads already do (ADR-0139 D3); the gate does not catch that refusal into an empty list. - -Comment reactions are unchanged: their only parent is `sys_comment`, which this gate narrows by an object-scoped middleware, so the reaction gate keeps the bounded probe (ADR-0139 D5.2). - -`installCommentReadVisibility` takes an optional third argument, the security source whose `canReadObject` admits a leaf (`ParentObjectReadAdmissionSource`); without it every comment read is the bounded probe, as before. Its first parameter widens from `CommentAccessEngine` to `ParentRecordGateEngine` (`CommentAccessEngine` plus three optional members: a registry view, `hasObjectMiddleware` and `resolveEffectiveDatasource`); every existing caller's engine still satisfies it. Both types reach the published declaration through that signature. `CommentAccessEngine` gains an optional `getSchema`. `@objectstack/plugin-audit` exports the backfill, `backfillCommentParentPair` (with `COMMENT_PARENT_PAIR_BACKFILL_PAGE_SIZE` and its report and engine types), the planner `planCommentParentPair` (with `COMMENT_PARENT_OBJECT_COLUMN`, `COMMENT_PARENT_RECORD_COLUMN` and the `CommentParentPair` type). diff --git a/.changeset/pre/23858-serve-state-file-directory.md b/.changeset/pre/23858-serve-state-file-directory.md deleted file mode 100644 index db39fd864f6..00000000000 --- a/.changeset/pre/23858-serve-state-file-directory.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os serve` no longer hangs after binding its port when its runtime state file's directory can never be made - -Clause-②: no - -This covers the runtime state file that the entry `fix(cli,runtime): a state, output or cache directory that can never be made is refused at once, naming the path and the operating system's code, instead of hanging` lists as `Not covered yet`. - -**What was wrong.** Once listening, `os serve` writes its runtime state file, `runtime...json`, into `OS_HOME` before it prints the ready banner. It made that directory with Node's recursive `mkdir`, which never returns when a level answers `ENOENT` although its parent exists, as `/proc` and `/sys` answer for a name they do not have. Under such an `OS_HOME`, with the database outside it, the server bound its port and then looped in its own process, holding the event loop: no `Press Ctrl+C to stop`, no listening message to an `os dev` parent, and no request answered. - -**What an operator sees now.** The directory is made with the same bounded walk as the other `OS_HOME` paths, so a directory that can never be made is refused at once. The state file has always been best-effort: the boot goes on without it and prints its banner, as it already did when the file could not be written for any other reason. - -Nothing else changes. Every state file written before is still written, in the same place, before the banner; a home that can be made is still made, every missing level of it. diff --git a/.changeset/pre/23858-uncreatable-directory-refusal.md b/.changeset/pre/23858-uncreatable-directory-refusal.md deleted file mode 100644 index 372ecd0bd09..00000000000 --- a/.changeset/pre/23858-uncreatable-directory-refusal.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/runtime': patch ---- - -fix(cli,runtime): a state, output or cache directory that can never be made is refused at once, naming the path and the operating system's code, instead of hanging - -Clause-②: no - -**What was wrong.** These commands and runtime paths created their directories with Node's recursive `mkdir`. Node's recursive mkdir never returns when a level answers `ENOENT` although its parent exists — what `/proc` and `/sys` answer for a name they do not have. It looped `mkdir("/proc/x")` and `mkdir("/proc")` without end, holding the event loop, so the command printed nothing more and no timeout could fire: - -- `os start` with `--home` / `OS_HOME` on such a path hung after its header. -- `os serve` hung the same way on the empty-boot artifact stub under `OS_HOME`, and on the cache of an `http(s):` `OS_ARTIFACT_URL` under `OS_HOME`. -- `os create --dir`, `os init `, `os compile --output`, `os generate` (`--dir`, and `--output` for types, client and migration), `os i18n extract --out` and `os plugin build --out` hung on an output path there. -- `os login` and `os cloud login` never finished writing credentials under a `HOME` there, and the package enable / disable state the packages routes write under `OS_HOME` held the event loop of the host serving them. - -**What an operator sees now.** The command exits non-zero at once with the operating system's own error, which names the level that cannot be made: - -```text -✗ Cannot create home directory at /proc/os-x: ENOENT: no such file or directory, mkdir '/proc/os-x' -``` - -Not covered yet: the runtime state file `os serve` writes into `OS_HOME` once it is listening. - -Nothing else changes. Every directory these paths made before is still made, every missing level of it; every path that was refused before is still refused, with the operating system's code. The one difference is the path that was never answered at all. diff --git a/.changeset/pre/23859-client-runtime-status-type.md b/.changeset/pre/23859-client-runtime-status-type.md deleted file mode 100644 index 368f0b3256d..00000000000 --- a/.changeset/pre/23859-client-runtime-status-type.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/client': patch ---- - -Clause-②: no - -fix(client): `client.automation.getRuntimeStatus()` types its rows as the declared contract `FlowRuntimeState` (#23859) - -**What was wrong.** The method declared its answer as `{ flows: Array<{ name, enabled, bound }>; total }`. `GET /automation/_status` serves the automation engine's `getFlowRuntimeStates()` rows unchanged, and each row is the contract `FlowRuntimeState` from `@objectstack/spec/contracts`. The SDK's type therefore hid five optional fields the wire already carries: `status`, `triggerType`, `object`, `reason` (one sentence saying why an enabled, triggered flow is not armed) and `unboundCause` (which of the closed `FLOW_UNBOUND_CAUSES` that sentence describes). A caller that read `row.reason` or `row.unboundCause` got a type error and either cast past it or concluded the field did not exist. - -**What changes.** The return type is now `Promise<{ flows: FlowRuntimeState[]; total: number }>`. `name`, `enabled` and `bound` stay required, so existing callers compile unchanged. Read `row.unboundCause` to classify an unarmed flow, and render `row.reason` without parsing it. The request, the route and the values on the wire are unchanged. diff --git a/.changeset/pre/23863-explicit-sqlite-non-file-refusal.md b/.changeset/pre/23863-explicit-sqlite-non-file-refusal.md deleted file mode 100644 index 66721b526d0..00000000000 --- a/.changeset/pre/23863-explicit-sqlite-non-file-refusal.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): an explicit `sqlite` or `sqlite-wasm` driver selection refuses a database URL that is not a file path, instead of opening a SQLite file at a path made of the URL - -Clause-②: no - -An explicit selection (`OS_DATABASE_DRIVER`, `--database-driver`, or the `databaseDriver` option) skips the URL classification, so the only check its URL meets is the SQLite file-name rule, `sqliteFilenameFromUrl`. Both hosts call that rule: the runtime's standalone host (`os dev`, `os start` and `os serve` over a plain config or an artifact, every `os migrate` subcommand, every embedder of `createStandaloneStack`) and the CLI's legacy `os serve` path, which re-raises its refusal as the fatal `UnsupportedDriverError`. - -- **Before, an opaque scheme (two or more characters and a `:` with no `//`).** `OS_DATABASE_DRIVER=sqlite OS_DATABASE_URL=jdbc:postgresql://127.0.0.1:5999/db os serve --dev` printed `✓ Server is ready` and created the directories `./jdbc:postgresql:/127.0.0.1:5999/`. `redis:h` opened a file named `redis:h`. This happened under both kinds. -- **Before, a server URL under `sqlite-wasm`.** The WASM kind refused nothing: `OS_DATABASE_DRIVER=sqlite-wasm` beside `OS_DATABASE_URL=postgres://USER:PASSWORD@127.0.0.1:5999/db` booted ready and created `./postgres:/USER:PASSWORD@127.0.0.1:5999/`, which put the credentials in a directory name. `redis://h/x.db` opened a file too. -- **After.** Under either kind, all of these get the refusal the `sqlite` kind already gave a `postgres://` URL. The kind is named in the sentence: `[StandaloneStack] sqlite-wasm driver was selected but the URL does not look like a file path (its scheme is "postgres://"). Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.` The message names the scheme only, never the URL, so credentials in the URL are not printed. The boot stops before it creates anything on disk. -- **One file name changes answer.** Under an explicit selection a first path segment that holds a colon now reads as a scheme, as it already does when the URL selects the driver: `backup:2026.db` was a SQLite file and is now refused as the scheme `backup:`. Write `./backup:2026.db` or `file:backup:2026.db` for that file. A one-letter drive is a path, not a scheme (`C:\data\app.db`, `C:/data/app.db`). -- **A `sqlite:` alias on a rung that does not normalize it.** `sqlite:` and `sqlite://` are aliases of `file:` in `OS_DATABASE_URL`, `--database` and the `databaseUrl` option. A declared default datasource's `url` and `TURSO_DATABASE_URL` reach the rule as written. There, under an explicit selection, `sqlite:./x.db` is now refused as the scheme `sqlite:`, where it used to open a file named `sqlite:./x.db`. `sqlite:///…` was already refused there under `sqlite` and is now refused under `sqlite-wasm` as well. - -`file:./x.db`, `./x.db`, `:memory:`, `wasm-sqlite:///path` (under `sqlite-wasm`), `sqlite:./x.db` in `OS_DATABASE_URL`, and paths with no scheme resolve to the same file as before. An empty name under `sqlite-wasm` is still `:memory:`. diff --git a/.changeset/pre/23865-next-steps-after-failed-install.md b/.changeset/pre/23865-next-steps-after-failed-install.md deleted file mode 100644 index 3a22094d5f7..00000000000 --- a/.changeset/pre/23865-next-steps-after-failed-install.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'create-objectstack': patch ---- - -Clause-②: no - -When the dependency install fails (a network error, a registry outage, an engine mismatch, an npm crash), the -scaffolder's closing "Next steps" now lists `npm install` (or `pnpm install`, whichever package manager the run -detected) before `npm run dev` and `npm run validate`, the same as it does under `--skip-install`. Before, a -failed install printed the same list as a successful one, so the next command it named ran against an empty -`node_modules`. A successful install still prints no install step. No flag, exit code, prompt or scaffolded file -changes. diff --git a/.changeset/pre/23877-visibility-alias-fold-unexported.md b/.changeset/pre/23877-visibility-alias-fold-unexported.md deleted file mode 100644 index c6a3643918c..00000000000 --- a/.changeset/pre/23877-visibility-alias-fold-unexported.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: `@objectstack/spec/shared` no longer exports `normalizeVisibleWhen` or `VISIBILITY_ALIAS_KEYS`; read `visibleWhen` directly - -Clause-②: yes (narrowing) - -**BREAKING (TS/API surface):** two exports of `@objectstack/spec/shared` are gone — the function `normalizeVisibleWhen`, which folded the retired `visibleOn` / `visibility` keys onto the canonical `visibleWhen`, and the const `VISIBILITY_ALIAS_KEYS`, which listed those two keys. Use `visibleWhen` directly instead: it is the one conditional-visibility key, and since 18 every schema that had the two old keys (the form field, the form section and the page component) refuses them by name. No schema used the fold after those keys retired, and no caller of either export was found in this repository or in the pinned renderer. - -## FROM → TO - -| you wrote | write instead | -| --- | --- | -| `import { normalizeVisibleWhen } from '@objectstack/spec/shared'`, then `normalizeVisibleWhen(node)` | read and write `node.visibleWhen`; there is no alias left to fold | -| `import { VISIBILITY_ALIAS_KEYS } from '@objectstack/spec/shared'` | nothing — the two keys it named are refused by the schemas; to reject them yourself, parse with the schema | - -**The one-line fix:** delete the import and the call, and read `visibleWhen`. - -This supersedes the sentence in this release's `visibleOn` / `visibility` retirement note that says the two exports "stay exported": they leave in the same release. - -**What an author who still imports them sees:** `tsc` fails with TS2305 (`Module '"@objectstack/spec/shared"' has no exported member …`); an ESM named import fails at link time with a `SyntaxError` (the module does not provide an export of that name); a CommonJS `require` reads the member as `undefined`. - -**Nothing to migrate in metadata.** No authored document ever carried either export, so `os migrate meta` has nothing to visit. The stored-row conversions `view-visibleOn-to-visibleWhen` and `page-component-visibility-to-visibleWhen` are unchanged: a pre-18 `sys_metadata` view or page row that still carries an old key keeps loading with `visibleWhen`. - - diff --git a/.changeset/pre/23887-anchor-docblock-guest-no-door.md b/.changeset/pre/23887-anchor-docblock-guest-no-door.md deleted file mode 100644 index a0b5f77d276..00000000000 --- a/.changeset/pre/23887-anchor-docblock-guest-no-door.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Clause-②: no - -The docblock on `EVERYONE_POSITION` and `GUEST_POSITION` no longer says that packages suggest -bindings to both audience anchors. It now reads that packages suggest bindings to `everyone` at -install time and the admin confirms each one, never auto-bound, and that no door binds a -permission set to `guest` until ADR-0138 D3 item 5's bind door is built, an act of the -environment's administrator and never of a package. The docblock ships in the package's -`src/**/*.zod.ts` files and in the JSDoc of `dist/*.d.ts`, so it is the hover text an author or an -AI reads on the two constants. No key, schema, export or accepted value changes. diff --git a/.changeset/pre/23889-approval-list-display-labels.md b/.changeset/pre/23889-approval-list-display-labels.md deleted file mode 100644 index 6238439fcb3..00000000000 --- a/.changeset/pre/23889-approval-list-display-labels.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -'@objectstack/plugin-approvals': minor ---- - -Clause-②: yes (widening) - -feat(plugin-approvals): the approval lists show engine-written display labels, which are the step and flow labels, whom a pending request waits on, the record an action is about, and the slot it was taken as (#23889) - -The Requests and Action History lists are generic list views. They show stored columns only, so admins used to read node ids (`manager_review`), `flow:` api names, slot addresses and raw user ids there. The approval engine now writes display columns beside the machine ones, and the list views show those columns. Nothing is removed or renamed. The machine columns (`process_name`, `current_step`, `pending_approvers`, `step_name`, `acted_as`) keep their values, and every query and decision still reads them. - -**`sys_approval_request`**, written by `ApprovalService`: - -- `process_label`: the flow's label, and `current_step_label`: the Approval node's label. Both are snapshots taken when the request opens, from the labels the flow engine already hands to `openNodeRequest`. Renaming the flow later does not rewrite them. -- `pending_approver_labels`: whom the request waits on, one label per pending slot, joined with `, `. A slot that names a person (a user id, or an email an account carries) gets that account's name. Any other slot (a `position:` or `team:` literal) shows its address. The column is rewritten whenever the slate is written (open, a partial tally, reassign, SLA reassign) and cleared with the slate on every terminal write. -- The list views: `process_label` replaces `process_name` in every view. `current_step_label` replaces `current_step` in `all_requests`, `my_pending` and `submitted_by_me`. `pending_approver_labels` is added to `all_requests` and `my_pending`. - -**`sys_approval_action`**: each new row copies these columns from its request: - -- `step_label`: the request's step label. -- `object_name` + `record_id`: the request's record pointer (`record_id` declares `referenceVia: 'object_name'`), so a list can name the record. -- `acted_as_label`: `acted_as` labelled the same way as the waiting-on column. It is null when `acted_as` is null, and also while a person slot's name could not be read (see below). -- The list views: `step_label` replaces `step_name` in `recent`, `all_actions` and `by_actor`. `recent` and `all_actions` add the record pointer and show `acted_as_label` in place of `acted_as`. - -The inbox door's `process_label` / `step_label` now read the new columns first. They fall back to the config snapshot for rows opened earlier, so what they serve is unchanged. - -**Read security.** Every new value is either a column of the request the row's readers can already read, or a name the approvals door already serves those readers for that request: `pending_approver_names`, and the timeline's names. The names come from the same resolver, under the same system read. The generic data door serves both objects only to the callers the approvals door would serve. - -**Rows written before this release are filled at boot.** A new boot-time pass runs on `kernel:ready`, after the action-slot repair. It fills the new columns on rows that hold null. Each request is re-read just before it is written, and the write names only the columns that are still null on that re-read row. Whom a request waits on is written only while the request is still pending on the slate its names were read for. A value a live writer stored, such as the boot sweep or another node, is therefore kept. The only window left is the single engine call between the re-read and the write. Every row it fills keeps its own `updated_at`: the write passes the stored value back under `preserveAudit`, so the request lists, which sort on `updated_at`, keep their order. The action-slot repair's writes keep it the same way. Where `plugin-audit` is composed, the first boot after the upgrade writes one system entry in the audit log and one in the record's activity timeline for each row it fills. A second boot writes nothing. - -- On every request, the flow and step labels come from the chain the approvals door serves: the authored labels the config snapshot carries, else the humanized machine name. The list and the inbox therefore show the same label for the same row, and no request is left blank. -- On a pending request, whom it waits on is filled. -- On every action, the step label and the record pointer are copied from its request, and its acting-as label is filled. An action whose request no longer exists keeps a null pointer. -- The action-slot repair now labels every `acted_as` it writes. - -**Two details.** - -- An empty authored label (the spec allows `label: ''`) is stored as the humanized machine name, never `''`. -- A name that cannot be read because the `sys_user` read failed is stored as null, never as the raw slot address. The failure is logged once at `warn`, and the boot pass fills it on the next boot. A slot that names no person still shows its address. diff --git a/.changeset/pre/23890-one-audit-entry.md b/.changeset/pre/23890-one-audit-entry.md deleted file mode 100644 index 647ab41f945..00000000000 --- a/.changeset/pre/23890-one-audit-entry.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-audit': patch -'@objectstack/platform-objects': patch ---- - -Clause-②: no - -Setup's Diagnostics group has one audit entry, "Audit Log", and it opens the audit browser - -- **What changed.** `@objectstack/plugin-audit` contributed two Setup entries onto the same rows: "Audit Logs" (the `sys_audit_log` record list) and "Audit Log Browser" (the console's `audit:log` page). It now contributes one, "Audit Log", which opens the browser. The browser filters by action, object, actor and date, offers the record list's views (Writes, Auth, Record Views, …) as presets, and links to the record list ("Open as list"). Those two parts of the page ship in the console this release pins. -- **Ids.** The entry keeps the id `nav_audit_logs`, so a sidebar pin a user set on "Audit Logs" stays on the new entry. The id `nav_audit_log_browser` is no longer served; a pin set on it no longer shows, and the user can pin "Audit Log" instead. -- **Labels.** `@objectstack/platform-objects` serves the entry's label in the four shipped locales (Audit Log, 审计日志, 監査ログ, Registro de auditoría), and drops the "Audit Log Browser" label. -- **Unchanged.** The `sys_audit_log` object, its list views, its routes and its data API answer as before. The record list is no longer an entry in Setup's menu, so the ⌘K palette's record search in Setup no longer searches audit rows. diff --git a/.changeset/pre/23891-permission-set-managed-by-retired.md b/.changeset/pre/23891-permission-set-managed-by-retired.md deleted file mode 100644 index e944db41157..00000000000 --- a/.changeset/pre/23891-permission-set-managed-by-retired.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/platform-objects': patch ---- - -feat(spec)!: retire the permission set's authored `managedBy` — a provenance key nothing read (#23891) - -**BREAKING** — an author can no longer write `managedBy` (`'package' | 'platform' | 'user'`) on a permission set (`PermissionSetSchema`, ADR-0086 D3): it is refused at parse. Nothing ever read the authored key. No seeder, package upgrade or lock consulted it, so `package` never made a set upgrade-owned, and `platform` or `user` never kept an upgrade off a set. Delete the key. A set's provenance is the package that ships it, decided from that package's artifact and recorded as `packageId`; a set created in this environment carries neither. The `sys_permission_set.managed_by` column is a different surface and is untouched, as is `ObjectSchema.managedBy`. - -Clause-②: yes (narrowing) - -Measured before removal, at `6d1660dc3b`: no shipped permission set authored the key (the example apps, `plugin-security`'s default sets, and HotCRM 4.0.1's seven sets at `1d7148bf2d`: zero, against seven `objects:` controls). Its one writer was Setup's Permission Sets form row, which wrote it into stored rows. The liveness ledger had ruled it `dead` on 2026-10-11. - -## FROM → TO - -| you wrote (17.7 and earlier) | write instead | -| --- | --- | -| `definePermissionSet({ …, managedBy: 'package' })` | nothing: delete the key; a package-shipped set is the package's because the package ships it (`packageId`) | -| `managedBy: 'platform'` or `managedBy: 'user'` | nothing: delete the key; a set created in this environment carries neither `managedBy` nor `packageId` | -| relying on `managedBy` to keep a package upgrade off a set | clone the packaged set and edit the clone (the packaged-set lock refuses in-place edits) | - -**The one-line fix:** delete `managedBy` from every permission set. - -**What an author who still writes it sees.** `tsc` fails at the authoring site (`PermissionSet` types the key as retired), and the parse (`definePermissionSet()`, `defineStack({ permissions })`, `PUT /api/v1/meta/permission/:name`) refuses it at its own path with the prescription: - -> `permission.managedBy` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0086 D3) — nothing ever read the authored key: no seeder, package upgrade or lock consulted it, so `package` never made a set upgrade-owned and `platform` or `user` never shielded one from an upgrade. Delete the key; a set's provenance is the package that ships it, decided from that package's artifact and recorded as `packageId`, and a set created in this environment carries neither. 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. - -**What does not stop on it.** A stored `permission` row that Setup's form wrote, and an installed package's built artifact, still load: the rehydration seams (`applyConversionsToStoredItem`, `applyArtifactForwardConversions`) replay the D2 conversion below and strip the key, with a notice naming the set. Only the authoring, save and publish doors refuse it. - -## The retirement kit - -- **A `retiredKey()` tombstone** on `PermissionSetSchema`, a `strictObject`, so the refusal carries its prescription and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `security/PermissionSet:managedBy`. -- **The D2 conversion `permission-set-managed-by-removed`** (protocol 18, retired from the load path) deletes the key from every `permissions[]` item, whatever it holds, one notice per set. -- **The D3 entry `permission-set-managed-by-retired`** records the judgement the strip cannot make: any runbook that relied on the value to keep an upgrade off a set needs another path. -- **`@objectstack/platform-objects`:** the Permission Sets form's `managedBy` row, its help text and its three option labels (Package / Platform / User) leave Setup in all four locales. -- **No deprecation window**, per the project's startup-stage posture. - -⚠️ **The out-of-repo consumer population is NOT MEASURED** beyond HotCRM 4.0.1. `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. Studio's provenance badge reads the value as one of three inputs; it retires in an objectui follow-up. - - diff --git a/.changeset/pre/23898-sqlite-refusal-remedy.md b/.changeset/pre/23898-sqlite-refusal-remedy.md deleted file mode 100644 index df2989caf17..00000000000 --- a/.changeset/pre/23898-sqlite-refusal-remedy.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): the SQLite "does not look like a file path" refusal names what the operator can change, instead of telling an operator who set `OS_DATABASE_DRIVER` to set it explicitly - -Clause-②: no - -`sqliteFilenameFromUrl` raises this refusal, and both hosts print it: the runtime's standalone host with its `[StandaloneStack]` tag, and the CLI's legacy `os serve` path, untagged, as the fatal `UnsupportedDriverError`. The first clause is unchanged and only the advice after it changes. The same URLs are refused, with the same error class and the same exit, and the message still names the URL's scheme and never the URL. Nothing that resolved before resolves differently. - -- **A URL with a scheme.** This is how an explicit `sqlite` or `sqlite-wasm` selection (`OS_DATABASE_DRIVER`, `--database-driver`, or the `databaseDriver` option) meets a server URL, an opaque scheme such as `jdbc:`, or a `://` nothing recognises. - - FROM: `… (its scheme is "jdbc:"). Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.` The operator who reads this has already set the driver explicitly: that selection is what led here. - - TO: `… (its scheme is "jdbc:"). Use file:/path/to/db.sqlite to keep the sqlite driver, or use a driver that matches the URL: unset the driver selection (OS_DATABASE_DRIVER, --database-driver or the databaseDriver option) so the URL selects it, or set the selection to the URL's driver.` With the selection unset, a scheme no driver claims, such as `jdbc:`, gets the unsupported-scheme refusal, which lists the supported schemes. -- **A `file:` that names no file** (`OS_DATABASE_URL=file:`, whether the URL or an explicit `sqlite` selection chose the driver). - - FROM: `… (it names no file after "file:"). Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.` - - TO: `… (it names no file after "file:"). Name the file after "file:", as in file:/path/to/db.sqlite.` A `file:` URL is a SQLite URL whichever way the driver was chosen, so changing the driver never fixed it. diff --git a/.changeset/pre/23899-read-only-arms-four-more.md b/.changeset/pre/23899-read-only-arms-four-more.md deleted file mode 100644 index 85c3640c592..00000000000 --- a/.changeset/pre/23899-read-only-arms-four-more.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/objectql': patch ---- - -Clause-②: no - -fix(cli): `os migrate plan`, a refused `os migrate summary-nulls --recompute-undefined-on-empty` name and every `os secret rewrap` run say what they vouch for, and that opening a SQLite file can still switch its journal mode, instead of saying nothing was written - -**What was wrong.** Each of these runs 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, each run 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 "a plan writes nothing", "nothing was written" or "a finished run writes nothing". Nothing was lost. - -**What changed.** Each sentence below now names what it vouches for. On a SQLite file, its line is followed by the note the other read-only 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 line ends without it. - -- `os migrate plan`, the unmanaged-table section. FROM: "They are reported for information only — nothing here drops them, and a plan writes nothing." TO: "… and a plan writes no row or table." and the note. -- `os migrate plan`, the declaration boot's note when a plugin's write was refused. FROM: "Refused N write(s) during the declaration boot — a plan writes nothing: …" TO: "… — a plan writes no row or table: …", and the note at the end of that note's line. -- `os migrate plan`, the same note when a raw command was forwarded. FROM: "If those commands only read, nothing was written; if one wrote, it landed." TO: "If those commands only read, no row or table was written; if one wrote, it landed.", and the note at the end of that note's line. -- `os migrate summary-nulls --recompute-undefined-on-empty` naming a roll-up the run cannot find (`backfillSummaryNulls`, `@objectstack/objectql`), in either mode. FROM: "Refused before any row was read; nothing was written." TO: "Refused before the walk read or wrote any row." and the note. Under `--apply` the boot before it can also create a table a never-started database lacks, so the sentence speaks for the walk only. -- `os secret rewrap`, the "Re-running is safe" note, in both modes and in `--json` `report.notes`. FROM: "… and a finished run writes nothing." TO: "… and a finished run writes no sys_secret row." Under `--apply` the note follows it; a dry run's closing line already carries it. A finished `--apply` run on a database that has never been started also creates its tables, so the sentence names the rows the re-wrap writes. - -**Unchanged.** No exit code, flag, prompt, refusal or export signature changes, and no `--json` document gains a field: the `--json` documents carry the changed sentences, and the note is printed in the text output only. What the driver does on connect is unchanged. diff --git a/.changeset/pre/23903-flow-save-reintroduced-retired.md b/.changeset/pre/23903-flow-save-reintroduced-retired.md deleted file mode 100644 index 3be44555b48..00000000000 --- a/.changeset/pre/23903-flow-save-reintroduced-retired.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -feat(metadata-protocol)!: a flow re-save that newly introduces a retired spelling is refused like a new flow's - -Clause-②: yes (narrowing) - -**BREAKING (API save door), the re-save case:** a flow saved through `saveMetaItem` (`PUT /api/v1/meta/flow/:name`, the runtime dispatcher, and every caller of the protocol) over a stored row is now judged as authored input when its body introduces a spelling retired from the authoring funnel that the stored row does not carry. Adding a node with an old spelling to a legacy flow, or writing one on a node the stored row holds in the canonical spelling, is no longer rewritten and stored: the save meets the flow parse's refusal, `422 INVALID_METADATA`, with an issue located at the key and the same prescription `os validate` and `os build` print, exactly as a new flow's does. Draft and publish mode alike. The whole body is judged that way, so the refusal also lists the retired spellings the stored row already carries; fixing the added one alone is enough to save. Before, a re-save healed every retired spelling in the body, the new one included, and said nothing. - -What counts as introduced: a retired conversion that fires on the request at a site where it does not fire on the stored row. A site is the key's path, with each node (and edge) named by its `id` rather than its position, so moving, adding or removing nodes does not change it. The stored rows compared are the row the save overwrites, and for a draft save the active row the draft overlays as well, so Studio's repeated draft saves of a legacy flow keep saving. - -Not changed: - -- A re-save whose retired spellings the stored row all carries, such as the served legacy body saved back unchanged, keeps replaying the full chain and is healed as before. -- A new flow with a retired spelling is refused, as it was. -- The server's own writes of bodies at rest keep the full chain: the stored-metadata migration (`os migrate meta --stored`), a package duplicate and an unmanaged package import. -- The automation engine's flow rehydration and the read path, which still serves a stored flow verbatim. - -Migration, for a client that edits flows through the API: write the spellings below on any node or key you add or change. The legacy spellings already on the stored row may stay until the row is healed. - -- `wait` node loose `config.duration` / `config.timerDuration` → `waitEventConfig.timerDuration`; `config.signal` / `config.signalName` → `waitEventConfig.signalName`; `config.eventType` → `waitEventConfig.eventType`. On the block itself, `timeoutMs` → `timerDuration` and `onTimeout` is removed. -- `connector_action` node loose `config.connectorId` / `config.actionId` / `config.input` → its `connectorConfig` block. -- CRUD node `config.filters` → `config.filter`; `config.object` → `config.objectName`. -- `map` and `subflow` node `config.flow` → `config.flowName`. -- `notify` node `config.to` → `recipients`, `subject` → `title`, `body` → `message`, `url` → `actionUrl`, `source: { object, id }` → `sourceObject` / `sourceId`. -- `script` node `config.functionName` → `function`, `input` → `inputs`; `actionType` / `template` / `recipients` / `variables` / `script` are removed. -- `errorHandling.retryDelayMs` and a `try_catch` node's `retry.retryDelayMs` → `backoffMs`; the flow keys `active` / `template`, a node's `outputSchema` and `errorHandling.fallbackNodeId` are removed. - -Each refusal names the key to write. - - diff --git a/.changeset/pre/23904-installed-page-retired-declarations-convert-at-load.md b/.changeset/pre/23904-installed-page-retired-declarations-convert-at-load.md deleted file mode 100644 index 7cc02d3516b..00000000000 --- a/.changeset/pre/23904-installed-page-retired-declarations-convert-at-load.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/objectql': minor -'@objectstack/metadata-core': minor ---- - -fix(spec,objectql)!: an installed or stored page carrying a declaration spec 18 retires converts at load instead of stopping the kernel build — `isDefault` off a bound record page is deleted and `template: 'full-width'` re-spelled `default`, each with a named notice; the authoring, save and publish doors keep refusing - -Clause-②: yes (widening) - -**What changes.** Protocol 18 refused `isDefault: true` on a page that is not a record page bound to an object, and closed the page `template` set (retiring `full-width`), at every door — the page parse (`definePage`, `defineStack`, so `os validate` / `os build`, and the metadata save door) and the kernel's package registration (`SchemaRegistry.installPackage` / `registerItem`, `MisplacedDefaultPageError`). The registration refusal also fired when a kernel was built from a package that was installed BEFORE the retirement: every hosted environment holding such a package failed its kernel build, with no remedy short of upgrading the package in each one. Data at rest now converts, and only data at rest: - -| Door | Before | Now | -| :--- | :--- | :--- | -| `definePage` / `defineStack` (`os validate`, `os build`), the metadata save door, package publish | refused at `isDefault` / `template` | **unchanged** — refused with the same sentence | -| a `sys_metadata` page row read back | served with `_diagnostics.valid: false` | converted on read: the flag deleted, `full-width` → `default`, `OS_METADATA_CONVERTED` notice | -| a compiled artifact inside the window its `engines.protocol` opens | the artifact door refused the artifact | converted before the strict parse, as every retired entry is | -| a package body registered through `manifest.register` / `ObjectQL.registerApp` that declares `engines.protocol` (an installed package, a marketplace install) | `MisplacedDefaultPageError`, kernel build stopped | converted before anything registers, with one `warn` per (package, conversion) naming the conversion id and the first site; the body is left unchanged on disk | -| the same body with NO declared range (a manifest a plugin builds in code), or declaring a floor newer than every retirement | refused | **unchanged** — refused | - -**The two ADR-0087 D2 entries**, both `retiredFromLoadPath` from the day they land (the authoring funnel never replays them): - -- `page-is-default-off-record-page-removed` (`page.isDefault`): deletes `isDefault: true` from exactly the pages `misplacedDefaultPageRefusal` refuses — a `home`, `app`, `utility` or `list` page, or a record page with no `object`. Lossless: nothing read the flag there (an app opens on its first navigation item). A default record page bound to an object keeps its flag. -- `page-template-full-width-to-default` (`page.template`): `'full-width'` → `'default'`. Lossless: the console drew both names with one template component. An unregistered template name (`centered`, `two-column`) and a region the template does not place have no lossless rewrite and are NOT converted — they stay with the parse, and the D3 entries `page-is-default-off-record-page-refused` / `page-template-closed-region-unplaced-refused` name the hand edits. - -**`@objectstack/objectql`** — `ObjectQL.registerApp` (and the `manifest` service, for an artifact carrying `packages[]`) replays the versioned forward conversion (`applyArtifactForwardConversions`, `@objectstack/metadata-core`) over a package body that declares its protocol range, before `installPackage`; the converted copy is what the package record and every registered item carry. Public: `ObjectQL.convertPackageBodyForward(body)`. - -**`@objectstack/metadata-core`** — `applyArtifactForwardConversions` reads the declared range off a FLAT package body (the manifest half beside its collections, no `manifest` key) when the definition carries no `manifest`; a definition with a `manifest` object is read there, as before. - -Migration: none for authors — the authoring doors refuse exactly what they refused. An installed package converts again at every registration until it is rebuilt (`os build`) and re-published with tooling from a release that ships the retirement; the boot log's warn names the conversion and the site. - - diff --git a/.changeset/pre/23909-keys-every-verb-anonymous-floor.md b/.changeset/pre/23909-keys-every-verb-anonymous-floor.md deleted file mode 100644 index deb82c394da..00000000000 --- a/.changeset/pre/23909-keys-every-verb-anonymous-floor.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): the standalone server hands every `/api/v1/keys` verb to the `keys` domain, so an anonymous caller is answered `401 UNAUTHENTICATED` whatever the verb - -Clause-②: no - -The v18 text for this door reads "`/keys`: `401 UNAUTHENTICATED` for every verb". The `keys` domain takes that decision first, but the standalone server (the one `os dev` and `os serve` run) mounted `/api/v1/keys` for `POST` only. Any other verb never reached the domain: the HTTP adapter answered it itself, with a `405` that named `POST` as the allowed method. - -What a `GET`, `PUT`, `PATCH` or `DELETE` call to `/api/v1/keys` is answered on the standalone server: - -- **Anonymous caller.** Before: `405 METHOD_NOT_ALLOWED` with `Allow: POST`, from the adapter. Now: `401 UNAUTHENTICATED` in the dispatcher's standard envelope, `{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }`. -- **Signed-in caller.** Before: `405 METHOD_NOT_ALLOWED` with `Allow: POST`, from the adapter. Now: still `405 METHOD_NOT_ALLOWED`, answered by the `keys` domain in the same standard envelope (`error.httpStatus: 405`, no `error.details`), and without an `Allow` header. - -`POST /api/v1/keys` is unchanged, and so is every answer on a host that reaches the dispatcher through `@objectstack/hono`'s catch-all, which already answered this way. A `HEAD` request follows `GET`: `401` anonymous, `405` signed in. A sub-path such as `/api/v1/keys/` is still not mounted on the standalone server and is answered `404`. diff --git a/.changeset/pre/23910-validation-previous-insert.md b/.changeset/pre/23910-validation-previous-insert.md deleted file mode 100644 index 6093555eb24..00000000000 --- a/.changeset/pre/23910-validation-previous-insert.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -Clause-②: yes (widening) - -On insert, `previous` is now bound to `null` in validation rules and in declarative hook -conditions, so a rule or condition that reads it decides the insert — the binding a flow's start -condition already gets on its create leg. Until now `previous` was unbound on insert: any -reference to it faulted `Unknown variable: previous`, and the write was refused (validation) or -aborted (hook) as `unevaluable`. So this widens what an insert can pass: a rule shaped -`previous != null && …` ("judge updates only") now ADMITS the inserts it used to refuse with that -fault, and a field's `requiredWhen: previous == null` now requires the field on insert instead of -refusing every insert. `previous == null || …` judges an insert, `previous != null && …` skips -one. A column read off `previous` on insert (`previous.stage`) still has no value and still -refuses the write; the refusal now names that read and the guard to add. - -A violating insert under a rule that reads `previous` now answers with the rule's own message. -The platform's own `validity_window_order` on `sys_user_permission_set` is the measured case: an -inverted grant window answers `400 VALIDATION_FAILED` with "Valid Until must be later than Valid -From …", not a CEL fault. - -The binding is the same for every predicate a write evaluates — a `script` / `cross_field` -condition, a `conditional`'s `when`, a field's `requiredWhen` and an option's `visibleWhen` — -and for a hook's `condition` on `beforeInsert` / `afterInsert`; the validate-only preview judges -an insert the same way. Only the CEL binding is `null`: a hook's `ctx.previous` is unchanged. No -shipped rule's verdict changes: every rule and hook this repository ships that reads `previous` -refused and admitted the same inserts before and after; only the refusal's reason moved. A write -to an existing row whose prior state is not in hand keeps `previous` unbound and still fails -closed, so `previous == null` never reads an update as an insert. diff --git a/.changeset/pre/23914-cloud-connection-body-refusal.md b/.changeset/pre/23914-cloud-connection-body-refusal.md deleted file mode 100644 index 227b685f314..00000000000 --- a/.changeset/pre/23914-cloud-connection-body-refusal.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/cloud-connection': minor ---- - -fix(cloud-connection)!: the cloud-connection and install-local routes refuse a declared JSON body that does not parse with `400 INVALID_REQUEST`, instead of answering as if no body was sent - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -`POST /api/v1/cloud-connection/bind/start`, `/bind/poll`, `/install` and `POST /api/v1/marketplace/install-local` read their bodies as `try { body = await c.req.json() } catch { body = {} }`, so a truncated body was answered as if the client had sent none: `/bind/poll` answered `400 "device_code is required"` for a body that carried `device_code` and lacked a closing brace. - -What changes for an integrator. A body sent as `application/json` (matched case-insensitively) that is not blank and does not parse: FROM the route's answer for an empty body, TO `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "The request body could not be parsed as JSON (it was sent as application/json). It was refused rather than read as an empty body: send a well-formed JSON document, or no body at all." } }`. Install-local refuses it after its sign-in and capability checks and before anything is fetched, registered or written. - -**Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body. - -**Unchanged.** An empty or whitespace-only JSON body is still no body, and every route answers it as before (for `/bind/poll`, `400 "device_code is required"`). A body that parses, and a body that does not declare JSON, are read as before. diff --git a/.changeset/pre/23914-hono-body-refusal.md b/.changeset/pre/23914-hono-body-refusal.md deleted file mode 100644 index f1c571b0cc5..00000000000 --- a/.changeset/pre/23914-hono-body-refusal.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -'@objectstack/hono': minor ---- - -fix(hono)!: `createHonoApp` refuses a declared JSON body that does not parse with `400 INVALID_REQUEST` when the dispatched domain reads it, instead of dispatching it as `{}` - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The `${prefix}/*` catch-all handed `dispatch()` `{}` for a body that did not parse, and the data domain read that as an empty query or an empty update: a truncated `POST /data/OBJECT/query` answered every row the caller could read, and a truncated `PATCH` answered `200` for an update that changed nothing. - -What changes for an integrator, on a host that serves `createHonoApp`: - -- A `POST`, `PUT` or `PATCH` body sent as `application/json` (matched case-insensitively) that is not blank and does not parse, to a domain that reads the parsed body (the data, metadata and every other domain that takes `body`): FROM that domain's answer for `{}`, TO `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "The request body could not be parsed as JSON (it was sent as application/json). It was refused rather than read as an empty body: send a well-formed JSON document, or no body at all." } }`. The domain acts on nothing past its read of the body, and the dispatcher's own gates still answer first. This is the same status and body `@objectstack/plugin-hono-server` answers for the same request. -- The same applies to the `/auth/*` legacy fallback that runs when no auth service is registered, for every method it hands a body (all but `GET` and `HEAD`). - -**Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body. - -**Unchanged.** - -- An empty or whitespace-only JSON body is dispatched as `{}` (an empty query), as before. -- A body that does not declare JSON is read as before: parsed when it is JSON, `{}` when it is not. -- The request handed on beside the body still arrives unread, in every case. A domain that forwards it and reads its own bytes answers exactly as before, a malformed JSON body included: `/webhooks/redeliver` and the inbound automation hooks give their own refusal after their own sign-in or signature check, and the `/approvals/act` form `POST` reads its form. -- The catch-all dispatches `GET`, `HEAD` and `DELETE` with no body, and the `/auth/*` fallback hands `GET` and `HEAD` `{}`, as before. diff --git a/.changeset/pre/23914-hono-server-body-refusal.md b/.changeset/pre/23914-hono-server-body-refusal.md deleted file mode 100644 index 0e74e31a1f7..00000000000 --- a/.changeset/pre/23914-hono-server-body-refusal.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/plugin-hono-server': minor ---- - -fix(plugin-hono-server)!: a request body the adapter cannot parse is refused `400 INVALID_REQUEST` when a route handler reads it, never handed to the handler as `{}` - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The adapter used to build a route's `req.body` as `{}` and overwrite it only when a parse succeeded, so a body it could not parse reached the handler as an empty object. Measured on a fresh `objectstack dev` showcase boot: `POST /api/v1/data/showcase_account/query` with its closing brace missing, with `{ not json`, or with a valid body sent as `text/plain` answered `200` with all 14 rows where the filter matched 2; a truncated `PATCH` answered `200`, changed no field and moved `updated_at`; and a truncated create answered `400 VALIDATION_FAILED` "Account Name is required", for a body that carried a name. - -What changes for an integrator. Each refusal is `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "…" } }`, raised when the handler reads `req.body`, so the handler acts on nothing past that read and nothing is written. The message names the failure and never echoes the body. - -- A body sent as `application/json` (matched case-insensitively) that is not blank and does not parse: FROM the handler reading `{}`, TO the refusal "The request body could not be parsed as JSON (it was sent as application/json). It was refused rather than read as an empty body: send a well-formed JSON document, or no body at all." -- A body in a media type the adapter does not parse (`text/plain`, `application/octet-stream`, any other type, or no `Content-Type` at all) sent to a route that reads `req.body`: FROM `{}`, TO the refusal "The request body was sent as text/plain, which this server does not read into a request body. It was refused rather than read as an empty body: send it as application/json." -- A `multipart/form-data` or `application/x-www-form-urlencoded` body that does not parse: FROM `{}`, TO the refusal naming form data. - -**Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body. - -**Unchanged.** - -- An empty or whitespace-only JSON body is still `{}`, which the REST data doors read as an empty query; a request with no body is still `{}`, whatever it declares. -- Well-formed JSON and well-formed forms reach the handler parsed, as before. -- A route that reads `req.rawBody()` and never `req.body` receives any media type byte for byte, as before: the storage raw upload stores a JSON-with-comments file sent as `application/json` (the type its presign tells the client to send for a `.json` file) and a text file sent as `text/plain`. -- A route, or the unmatched-request fallback, that never reads `req.body` answers as before. -- A body is judged present by its framing headers (`Content-Length` other than `0`, or `Transfer-Encoding`), as RFC 9112 defines it; nothing is read to find out. diff --git a/.changeset/pre/23919-builtin-position-copy.md b/.changeset/pre/23919-builtin-position-copy.md deleted file mode 100644 index 0c8df64cf9b..00000000000 --- a/.changeset/pre/23919-builtin-position-copy.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -Clause-②: no - -The Everyone and Guest built-in positions describe themselves in plain admin sentences. Setup's -Positions page now reads "Built-in audience anchor: every authenticated member holds this position -implicitly. Permission sets bound to it are the default grants for the tenant. High-privilege sets -cannot be bound here." for Everyone, and "Built-in audience anchor: unauthenticated principals hold -this position implicitly and exclusively. Bindings face the strictest checks — named objects only, -read-mostly, never a wildcard." for Guest; the design-record references stay in the code comments. -An upgraded deployment shows the same text: the page lists the declared positions, and a stored -position row an earlier release wrote is neither read nor rewritten. Wording only: no permission or -behaviour change. diff --git a/.changeset/pre/23925-remote-plan-remedy.md b/.changeset/pre/23925-remote-plan-remedy.md deleted file mode 100644 index 70e99a1e614..00000000000 --- a/.changeset/pre/23925-remote-plan-remedy.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/driver-sql': minor -'@objectstack/driver-turso': minor ---- - -fix(cli,driver-turso): `os migrate plan` and `os migrate apply` on a Turso REMOTE database no longer offer `--allow-destructive` for a change that database cannot apply - -Clause-②: yes (widening) - -On a primary reached over the Turso REMOTE transport (`libsql://…`), `os migrate plan` listed a NOT NULL tightening with the remedy `Run "os migrate apply --allow-destructive".` and closed with `(add --allow-destructive for drops / tightenings)`. That transport runs no SQLite table rebuild, so the apply then named the tightening as not applied and exited 1. The plan now says so before the apply: each change the database cannot apply (a tightened or relaxed NOT NULL, a dropped DEFAULT) is still listed, with its finding and `"os migrate apply" cannot apply it on this database: it needs the SQLite table rebuild, which the Turso REMOTE transport does not run.` in place of the remedy. The footer offers `--allow-destructive` only for the destructive changes the database can apply (a column drop, an index change) and says how many listed changes remain drift after the apply. The listing `os migrate apply` prints before its prompt reads the same way. Its counts of held-back destructive changes, before the prompt and after the run, offer a re-run with `--allow-destructive` only for the ones that re-run could apply, and name the rest as drift it cannot apply either. On every other database both commands read exactly as before, and their `--json` documents are unchanged on every database. - -`@objectstack/driver-turso`: `TursoDriver.driftApplyLimit(opType)` is new. It returns why the driver's `applyMigrationEntries` cannot perform a drift op of that type on this face, or `null` when it can. It answers from the same per-op declaration the REMOTE apply door dispatches by, and returns `null` for every op on the local and embedded-replica faces. A driver that does not implement it is read as applying every op the differ means it to. - -`@objectstack/driver-sql`: `driftFindingWithoutRemedy(entry)` is a new export. It returns a drift entry's finding without the `os migrate apply --allow-destructive` sentence that a destructive column entry's message ends with, and returns every other message unchanged; the messages themselves keep their exact text. diff --git a/.changeset/pre/23928-parallel-failed-branch-steps.md b/.changeset/pre/23928-parallel-failed-branch-steps.md deleted file mode 100644 index c3a3a781a51..00000000000 --- a/.changeset/pre/23928-parallel-failed-branch-steps.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/service-automation': patch ---- - -Clause-②: no - -A failed `parallel` run now shows what its branches did. Under the block's own `failure` step, the run's step log lists every branch's steps, each tagged with `parentNodeId`, `regionKind: 'parallel-branch'` and its `branch` index: a branch that completed, with its `success` step, and the failing branch's steps up to and including the one that failed, with its error. The run summary counts what those steps did. A fan-out whose notify branch delivered and whose http branch failed now reports `acted: 1` and `failed: 2` (the http step and the block), the way a failed `loop` already does. Before, the log held only the block's failure step and the summary read `acted: 0`. That reads as "nothing happened, safe to re-run" over a notification that had already been sent. - -The block now reports its failure once every branch has settled, not at the first branch to fail, so a branch that finishes later keeps its step and its effect is counted. Routing is unchanged. The failure is still the block's returned failure, so a `fault` edge on the `parallel` routes it with the same step code, error text and `$error`. A durable pause refused inside a branch still fails the run. diff --git a/.changeset/pre/23932-declared-client-refusal-log.md b/.changeset/pre/23932-declared-client-refusal-log.md deleted file mode 100644 index ad7822f2d25..00000000000 --- a/.changeset/pre/23932-declared-client-refusal-log.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -fix(rest): a client refusal its producer declared is no longer logged as `[REST] Unhandled error` with a stack trace - -Clause-②: no - -- **What the server log stops printing.** A refused request whose thrown error declares both a 4xx `status` (or `statusCode`) and a registered ADR-0112 `code`, and whose answer carries that same status and code, now prints nothing on the `[REST] Unhandled error` channel every REST route catch shares. Before, each one printed an error-level `[REST] Unhandled error:` line plus the error's full stack, once per request. Two measured cases: a record write whose file violates a field's declared `accept` / `maxSize` (`400 ERR_FILE_CONSTRAINT` on `POST /api/v1/data/:object` and `PATCH /api/v1/data/:object/:id`), and a metadata save that fails spec validation (`422 INVALID_METADATA` on `PUT /api/v1/meta/:type/:name`). Other registered codes thrown with a declared 4xx, such as `FILE_FIELD_BULK_WRITE_REFUSED`, go quiet too, with no per-code list to extend. -- **What still logs, unchanged:** a handler bug or any other undeclared fault (answered `500`); a 4xx that carries no `code` (for example a sandboxed hook's business-rule `400`); a 4xx whose `code` is not registered (it reaches the wire as `declaredCode`); a registered `code` on a status the producer did not declare; a declared `5xx` without `refusal: true`; and a sandboxed body that crashed, whatever it declared. The anonymous public-form submit route keeps its own `[REST] Public form submit error` line as before. -- **Unchanged:** every response's status, `code` and body. Only the server log line moves. diff --git a/.changeset/pre/23933-installed-apps-packages-filter.md b/.changeset/pre/23933-installed-apps-packages-filter.md deleted file mode 100644 index 0a4bed47afc..00000000000 --- a/.changeset/pre/23933-installed-apps-packages-filter.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/cloud-connection': minor -'@objectstack/platform-objects': patch ---- - -feat(cloud-connection)!: Setup's "Installed Apps" entry opens Setup › Packages filtered to marketplace installs, and the separate Installed Apps page is no longer shipped - -Clause-②: no (narrowing) - - - -- `MARKETPLACE_INSTALLED_UI_BUNDLE.pages` -> delete (the bundle carries no page; its entry is read from `navigationContributions`) - -Setup › Packages is now the one list of what is installed: every row says where the package came from (Source: code, marketplace or authored), and the rows whose Source is marketplace carry the actions the Installed Apps page had, Details and Uninstall. - -- **What changes.** The "Installed Apps" entry in Setup's Apps group, which the install-local plugin (`MarketplaceInstallLocalPlugin`) contributes, now opens Packages with the Source filter set to marketplace. Its label and icon are unchanged. In `MARKETPLACE_INSTALLED_UI_BUNDLE` the entry `nav_marketplace_installed` is a `component` entry on `developer:packages` with `params: { source: 'marketplace' }`, which the console opens as `/apps/setup/component/developer/packages?source=marketplace`. -- **What is no longer shipped.** The bundle no longer carries the `marketplace_installed` page, so `GET /api/v1/meta/page` stops listing it and `MARKETPLACE_INSTALLED_UI_BUNDLE` has no `pages` key. In the console that page only redirected to the filtered Packages list. The `pages.marketplace_installed` entries leave the Setup translation bundle in `@objectstack/platform-objects` (en, zh-CN, ja-JP, es-ES). The Setup nav label `nav_marketplace_installed` keeps its translations. -- **Unchanged.** The entry still appears only when the install-local plugin is mounted, so a runtime without it shows no Installed Apps entry. "Browse Marketplace" and its route are not touched. The marketplace's own package page still carries Update and the sample-data Re-seed / Purge menu, and Details on a Packages row opens it. - -What to do. Nothing in a normal deployment. If you linked to the `marketplace_installed` page directly, link to Setup › Packages with `?source=marketplace` instead. diff --git a/.changeset/pre/23944-revoked-key-refusal-text.md b/.changeset/pre/23944-revoked-key-refusal-text.md deleted file mode 100644 index f8d2d219f24..00000000000 --- a/.changeset/pre/23944-revoked-key-refusal-text.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/core': patch ---- - -Clause-②: no - -A call with a revoked API key no longer hears "a revoked key never works again". The key row's own Restore API Key action clears the flag and the same key works again at once, so the `403 API_KEY_REVOKED` message now says the key was revoked, that its owner or a platform administrator can restore it (the Restore API Key action on the API Keys list), and otherwise to create a new key (`POST /api/v1/keys`, or the API Keys page) and replace this one wherever it is configured. The code, the status, the fields the refusal carries and the expired and inactive messages are unchanged. The error catalog's `API_KEY_REVOKED` Fix line says the same. diff --git a/.changeset/pre/23945-invite-role-cap-codes.md b/.changeset/pre/23945-invite-role-cap-codes.md deleted file mode 100644 index 5d1e89b75ba..00000000000 --- a/.changeset/pre/23945-invite-role-cap-codes.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth): the invitation role cap's refusals on `POST /organization/invite-member` carry an error code, and a membership read that fails answers `503` instead of a refusal - -Clause-②: no - -The role cap in `beforeCreateInvitation` refused with `403` and a message but no `code`, while better-auth's own `owner` refusal on the same endpoint carries one. A client, the console's Invite User dialog among them, could not tell the refusals apart without reading English prose. Each refusal now carries the code the error-code ledger registers for it under `@objectstack/plugin-auth`, in the same `{ message, code }` body: - -- `INVITE_ROLE_ABOVE_ISSUER`: the requested role grades above the issuer's own, for example a `delegated_admin` inviting as `admin`. -- `INVITE_ISSUER_MEMBER_ONLY`: an issuer below admin grade asked for a role other than a plain `member`, for example a `delegated_admin` inviting as `delegated_admin`. -- `INVITE_ISSUER_UNVERIFIED`: fail-closed. The issuer's membership role was not established: the read found no usable `sys_member` row, or the `AuthManager` has no data engine to read from. - -A membership read that throws is a store outage, not a verdict about the issuer. It used to answer the same `403` as an unverified issuer. It now answers `503` with the code `SERVICE_UNAVAILABLE`, as the organization slug guard does for its own read faults. No invitation is created either way. - -Unchanged: which invitations are admitted, the status of every refusal (`403`), better-auth's own `owner` refusal, and the exports. diff --git a/.changeset/pre/23946-email-template-loader-boot-order.md b/.changeset/pre/23946-email-template-loader-boot-order.md deleted file mode 100644 index 0030f2eecf8..00000000000 --- a/.changeset/pre/23946-email-template-loader-boot-order.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/plugin-email": patch ---- - -Clause-②: no - -fix(plugin-email): a template send issued before `EmailServicePlugin`'s own `kernel:ready` renders, persists its `sys_email` row and delivers, instead of throwing `TEMPLATE_NOT_FOUND: no templateLoader configured on EmailService` - -`EmailServicePlugin` now wires `sys_email` persistence and the registry template loader in `init`, before it registers the `email` service. Before this, both were wired in the plugin's own `kernel:ready` hook, and a plugin that starts earlier runs its `kernel:ready` hooks first. The dev admin seed (`objectstack dev --seed-admin`, the default) signs its account up from one of those hooks, so on a boot with email verification required the seed's verification mail failed. Each such boot logged `ERROR [Better Auth]: Failed to run background task: Error: TEMPLATE_NOT_FOUND …` and left no `sys_email` row. That send now goes through the transport and default sender the boot was configured with. The `mail` settings namespace is still applied at `kernel:ready`. Sends made after boot behave as before. diff --git a/.changeset/pre/23948-declared-rule-mirrors-advisory.md b/.changeset/pre/23948-declared-rule-mirrors-advisory.md deleted file mode 100644 index 068d58b1c14..00000000000 --- a/.changeset/pre/23948-declared-rule-mirrors-advisory.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -feat(cli): `os migrate organization-ownership --apply` certifies a database whose packages declare sharing rules — the declared sharing-rule mirrors this release's boot still seeds are reported as advisory, not as discrepancies - -Clause-②: yes (widening — the apply report file, the plan file and both `--json` payloads gain an `advisory` field on a departure entry, and the run's `advisory` count includes that departure's rows) - -- **What an operator saw.** On a v17 database whose packages declare sharing rules (HotCRM declares eleven), `--apply` ended with `complete: true` and `blocking: 11`, and never certified: the `adr-0131-organization-ownership` row of `sys_migration` kept `verified_at` null. The post-check waited for the `declared-rule-mirrors` departure on `sys_sharing_rule` to leave. That departure is the uncustomized `managed_by` `package` / `platform` rows with no organization. This release's own served boot writes those rows on every start, including the first v18 boot that the organization-ownership refusal stops, so they could never leave on this release. -- **What it sees now.** `--apply` reports `blocking: 0` and certifies: `verified_at` is set, with `blocking` 0 in the marker every reader checks. The mirror rows are named, counted and kept: - - the plan and the apply print a warning before the first write: `sys_sharing_rule holds N organization-less row(s) of … which stay: …`. The plan file's and the report's `warnings` carry it too; - - in the plan file and in the apply report (`--json` and the `--out` file), the `declared-rule-mirrors` entry of `tables[].departures[]` keeps `id`, `label`, `as` and `rows`, and gains `advisory`: why the rows stay, and what removes them. A departure without `advisory` still blocks; - - `tables[].remaining` no longer counts these rows. The run's `advisory` and the ledger's `advisory` count them, beside the unattributable rows; - - the rows keep `organization_id` NULL, so `sys_sharing_rule`'s `notNull` reads `willReceive: false` with `N row(s) stay NULL on this release, reported as advisory: …`. The plan file used to read `willReceive: true` for that table; - - the human summary prints `N row(s) advisory, not held against certification — …` under the table. Its closing warnings name the unattributable rows and the advisory rows separately; - - nothing is deleted. Every rule row stays as it was. -- **What still blocks.** Every other departure: `global-rung-move`, `template-promotion`, and a host fate supplement's own departures, even one named `declared-rule-mirrors`. Every other remaining row blocks too. -- **Already ran `--apply` and stopped at `blocking: 11`?** Plan again on this release, review the plan, and run `--apply` with it. This change leaves the inventory digest as it was, so a checkpoint recorded under this release's inventory resumes: only the post-check runs, and it certifies. A checkpoint recorded by a release whose inventory differed is superseded: every step runs again, each one idempotent, and the post-check certifies. -- **When blocking returns.** The release that retires the declared sharing-rule seeder (ADR-0131 D2) sets `DECLARED_SHARING_RULE_SEEDER_RUNS` to `false` in the same change. From that release on, these rows hold certification again until its ceremony deletes them. diff --git a/.changeset/pre/23952-declared-sharing-rules-registry.md b/.changeset/pre/23952-declared-sharing-rules-registry.md deleted file mode 100644 index 6683ed38d20..00000000000 --- a/.changeset/pre/23952-declared-sharing-rules-registry.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -'@objectstack/plugin-sharing': minor -'@objectstack/cli': minor -'@objectstack/lint': patch ---- - -feat(plugin-sharing,cli)!: declared sharing rules are read from the registry — the boot writes no `sys_sharing_rule` row for them, and the 17 → 18 ownership ceremony deletes the copies the retired seeder wrote - -Clause-②: no (narrowing) - - - -The narrowings, FROM → TO, each with its one-line remedy: - -- `DELETE /api/v1/sharing/rules/:idOrName` on a declared rule — FROM `204`, the seeded row deleted (and written again by the next boot), TO `403 PERMISSION_DENIED`. Remedy: change or remove the rule in its declaration in the package; an organization's own rule is deleted as before. -- `sys_sharing_rule` — FROM one `managed_by: package` row per declared rule, TO none: the boot writes none and the ownership ceremony deletes the copies the seeder wrote (Setup's Sharing Rules object list, which lists rows, no longer shows declared rules). Remedy: read declared rules through `GET /api/v1/sharing/rules`, which lists them beside the organization's own; on an upgraded database run `os migrate organization-ownership --apply` with a fresh plan. - -The retirement note for the seeder is the ADR-0087 semantic entry `sharing-rule-declared-mirror-retired`. - -**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. - -`@objectstack/plugin-sharing` no longer copies a stack's declared `sharingRules` into `sys_sharing_rule` at boot. ADR-0131 D2 retires that seeder (`bootstrapDeclaredSharingRules`). The sharing-rule service reads each declared rule from the registry whenever it reads the rule set: listing, getting, evaluating, the per-record hooks, the boot backfill and the orphaned-grant sweep. Rules an organization authors keep their `sys_sharing_rule` rows. - -- **A declared rule still grants, and stops granting when its declaration goes.** It is translated from the registry's copy exactly as the seeder translated it: the CEL `condition` is lowered to a `criteria` filter, and `sharedWith` becomes the recipient. A condition that does not lower is not served, and never as a match-all. It says so in one WARN line per rule per process. A declared rule's id is its NAME, and the grants it materialises carry the name as `sys_record_share.source_id`. Remove the declaration and the next boot's orphaned-grant sweep revokes those grants. -- **Scopes follow the posture, as the seeder's passes did.** Under `single` a declared rule is served once, organization-less, so every organization reads it. Under a wall it is served once per organization — every organization, read page by page. An organization created after boot is served from its creation on. If the organizations cannot be read at boot, no declared rule is served until the next boot, and the boot's orphaned-grant sweep revokes none of their grants meanwhile: a failed read is never a revocation. -- **A stored row of the same name answers for it.** Under `single` that is a row of that name in any organization, and under a wall that organization's own row. Such a row is an organization's own rule, a seeded copy an administrator customized, or a seeded copy the ceremony has not deleted yet. The declared rule is not served beside it, the same as while the seeder ran: it never overwrote an admin-authored or customized row. -- **`DELETE /api/v1/sharing/rules/:idOrName` refuses a declared rule** with `403 PERMISSION_DENIED`. It has no row: it is changed or removed in its declaration. Before this release a platform administrator could delete the seeded row, and the next boot wrote it again. An organization's own rule is deleted as before. -- **Setup's Sharing Rules object list no longer shows declared rules.** It lists `sys_sharing_rule` rows. `GET /api/v1/sharing/rules` lists both. - -`@objectstack/cli` — **`os migrate organization-ownership --apply` deletes the declared-rule mirrors.** The `declared-rule-mirrors` rows on `sys_sharing_rule` are the seeder's uncustomized copies (`managed_by` `package` or `platform`, `customized` not true). They now block certification until the ceremony's `mirror-deletion` step removes them. It works in the ruled order. It renames every grant naming a mirror by row id (`sys_record_share.source_id`, `source = 'rule'`) to the rule's name and verifies that no grant names a mirror any more. Only then does it delete the mirrors. Under a wall it deletes the organization-stamped copies too. -- A mirror whose rule no installed package declares, and that grants still name, blocks with nothing written. The blocker names the rows and the remedy. A mirror no grant names is deleted. -- The step also runs on a database whose ceremony already completed while an earlier release still seeded the rows, where they were advisory. Re-run `--apply` with a fresh plan. -- `DECLARED_SHARING_RULE_SEEDER_RUNS` is now `false`. A customized copy and an organization's own rule stay. Under `single` they take the Default Organization, as before. - -`@objectstack/lint` — the sharing-rule enforceability verdicts and `os explain` say what now happens to a declared rule whose condition does not lower. It is "never served", not "never seeded", because it is never written to `sys_sharing_rule`. The rule ids, severities, paths and what each rule accepts or refuses are unchanged. diff --git a/.changeset/pre/23957-invite-role-options-per-caller.md b/.changeset/pre/23957-invite-role-options-per-caller.md deleted file mode 100644 index dd0d9a0bd14..00000000000 --- a/.changeset/pre/23957-invite-role-options-per-caller.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/platform-objects': patch -'@objectstack/plugin-auth': patch ---- - -fix(identity): the Invite User dialog offers only the roles the caller may grant, read from the same table the invitation role cap enforces - -Clause-②: yes (widening) - -**What was wrong.** A `delegated_admin` who opened Invite User was offered Owner, Admin and Delegated Admin. The server's invitation role cap refuses all three for that issuer. The `role` param inherited one static option list from the field, and nothing told the client which roles this caller may grant. - -**What is new in `@objectstack/spec` (`identity`).** One table, `INVITATION_ROLE_GRANTORS`, says which issuer grades may invite as each membership role: - -| role | issuers that may grant it | -|---|---| -| `owner` | `owner` | -| `admin`, `delegated_admin` | `owner`, `admin` | -| `member` | any issuer (`INVITATION_ROLE_ANY_ISSUER`) | - -New exports beside it: `invitationRoleGrantors(role)` (a role the closed vocabulary does not list takes the `owner`, `admin` row), `invitationRoleGrantPredicate(role)` (the CEL gate, one `'' in current_user.positions` term per grade, `undefined` for an any-issuer row), `INVITATION_ROLE_OPTIONS` (the role picker's options, each carrying its gate as `visibleWhen`), and the types `InvitationRoleGrantors` and `InvitationRoleOption`. - -**`@objectstack/plugin-auth`.** The invitation role cap now reads that table to decide which invitations it admits. It refuses exactly what it refused before, with the same refusal text. A pin compares it with the previous implementation on 1,517 request and issuer pairs. - -**`@objectstack/platform-objects`.** The `role` param of the three `invite_user` actions (`sys_user`, `sys_member`, `sys_invitation`) declares `INVITATION_ROLE_OPTIONS` itself instead of inheriting the field's list. The option values and English labels are unchanged. A `delegated_admin` is now offered Member only, an admin Admin, Delegated Admin and Member, and an owner all four. The per-option gate is a UI courtesy; the server cap remains the authority. Because the options are declared on the param, their labels now translate under `_actions.invite_user.params.role.options.`, and the bundles carry those keys in every locale. diff --git a/.changeset/pre/23963-resume-gone-run-settled.md b/.changeset/pre/23963-resume-gone-run-settled.md deleted file mode 100644 index 47a9bdc1b74..00000000000 --- a/.changeset/pre/23963-resume-gone-run-settled.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -Clause-②: no - -fix(service-automation): a resume refused because the run's flow or parked node is gone now settles the run's `sys_automation_run` row - -- **What changed.** A resume refused with `RUN_NOT_FOUND` because the run's flow is no longer registered, or because the node it parked on was edited away, now settles the run in the same call. The paused row is consumed and a `failed` history row records the refusal's own text in `error`. Before, the row kept `status: 'paused'` through every later resume, an uninstall and a cold restart. Run lists and paused-run counts kept showing a run the engine already knew could never continue, and retention never aged the row out, because it prunes terminal statuses only. -- **Unchanged:** the answer. It has the same code and the same text, and a transport still answers 404. A second resume answers `RUN_NOT_FOUND` again (`No suspended run`) and writes nothing. Retention is unchanged: `failed` was already in its scope, and paused rows are still never pruned. -- **When nothing is recorded.** Before the host seals the boot, a missing flow may be one that is not registered yet. `AutomationServicePlugin` re-arms overdue wait timers in `start()`, before flows bound at `kernel:ready` arrive. So before the seal the refusal records nothing, as before. The host seals with `sealNodeTypeVocabulary()`, which the plugin calls at `kernel:bootstrapped`. An embedded host that never calls it keeps the previous behaviour. -- **Concurrency.** The settle consumes the row through the same compare-and-set a resume takes. If a concurrent resume or cancel consumed the row first, the call writes nothing. If the store cannot answer the compare-and-set, the resume answers `STORE_UNAVAILABLE` (503), and a retry settles the run. diff --git a/.changeset/pre/23964-record-surface-docblock.md b/.changeset/pre/23964-record-surface-docblock.md deleted file mode 100644 index 29db91bd23b..00000000000 --- a/.changeset/pre/23964-record-surface-docblock.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): `deriveRecordFlowSurface`'s docblock no longer calls its override list closed, and names `editMode` - -Clause-②: no - -- **The override sentence.** The shipped docblock said the explicit overrides of the derived default were `navigation.mode` / `navigation.size`, `FormView.type` / `modalSize` or an assigned page — "the sanctioned per-object overrides — no new authorable key". The same package declares `ObjectSchema.editMode` (`'modal' | 'page'`), and the renderer applies it. The docblock now says what the function reads (`def.fields`, `flow`, `opts`, nothing else) and lists only the explicit config the console renderer measurably lets win over this default: `navigation.mode` / `navigation.size`, an assigned page, and `editMode`. The function still does not read `editMode`. `FormView.type` / `modalSize` are no longer listed: an authored `FormView.modalSize` does not reach the console's create/edit modal, and `FormView.type` changes that modal's layout, not its surface. -- **Unchanged:** no behaviour, no schema, no export changes. `deriveRecordSurface` and `deriveRecordFlowSurface` return what they returned before. diff --git a/.changeset/pre/23967-view-label-not-invented.md b/.changeset/pre/23967-view-label-not-invented.md deleted file mode 100644 index 9cee155f45d..00000000000 --- a/.changeset/pre/23967-view-label-not-invented.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest)!: the `/meta` item and list reads serve a view stored with no `label` without the `label` the translator invents from its name - -Clause-②: no (narrowing) - - - -**BREAKING** (a `/meta` read stops serving a value nobody authored), shipped as `minor` under the launch-window convention for breaking changes. - -A view's `label` is optional in the spec, with no default. When the translation catalog carried no string for a view, the read-side translator answered the view's own name, so a view stored without a `label` was served one: `GET /api/v1/meta/view/NAME` and `GET /api/v1/meta/view` returned `label: "OBJECT.KEY"`, on the standalone server and on the runtime dispatcher alike. The console drew that machine name as the view tab. The view-container read already dropped this value; the item read and the list read now drop it too. - -What a client reads now: - -- A view stored with no `label` is served with no `label` key, in every locale, with or without a translation catalog. -- A view with an authored `label` is served that label, unchanged, including a label that happens to equal the view's name. -- A catalog translation (`objects.OBJECT._views.KEY.label`) is still served, for a view with a `label` and for one without. -- A read that resolves no locale is served the stored document as before, and the layered read (`?layers=true`) never carried the invented value. - -Who reads differently: a client that displays a served view's `label` now finds the key absent for a view stored without one, where it used to find the qualified name; it shows its own wording for such a view (the object's plural label, for example). The console's view tab falls back to the view's name on its own, so it keeps showing a machine name for such a view until its own fallback changes. diff --git a/.changeset/pre/23968-print-local-loopback-host.md b/.changeset/pre/23968-print-local-loopback-host.md deleted file mode 100644 index 37727200536..00000000000 --- a/.changeset/pre/23968-print-local-loopback-host.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/service-print': patch ---- - -**Shipped in 18.0.0-next.3**, whose CHANGELOG missed this entry; listed here one prerelease late. - -Clause-②: no - -fix(service-print): the local placement binds its browser server's control socket to `127.0.0.1` itself, instead of relying on `playwright-core`'s default host - -- **What changed.** With `OS_PRINT_CHROMIUM_PATH` (option `chromiumPath`), each render and each boot probe starts a Chromium browser server and connects to it. That server's control socket is now opened with `host: '127.0.0.1'`. Before, no host was passed, so the bind followed `playwright-core`'s default (`localhost`, loopback only in the pinned 1.63.0). Whoever reaches that socket and knows its path drives a browser signed in as the requesting user. How far it reaches is now set by this package, and a `playwright-core` upgrade cannot widen it without a diff here. -- **What it means for a deployment.** Nothing changes on a host whose `localhost` is `127.0.0.1`, which is the usual case. On a host with no IPv4 loopback address, the local placement cannot open the socket. Its boot probe logs `Chromium could not be reached at the local executable …: listen EADDRNOTAVAIL …` and printing stays off. The socket is never bound to another address instead. On such a host, use the remote placement (`OS_PRINT_BROWSER_ENDPOINT`). -- **Unchanged:** the remote placement, the port (still random), every option and environment variable, and the exports. diff --git a/.changeset/pre/23972-installed-package-source.md b/.changeset/pre/23972-installed-package-source.md deleted file mode 100644 index 6365d0bd9df..00000000000 --- a/.changeset/pre/23972-installed-package-source.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/objectql': minor -'@objectstack/metadata-protocol': patch -'@objectstack/runtime': patch -'@objectstack/service-package': patch -'@objectstack/cloud-connection': patch -'@objectstack/client': patch ---- - -feat(spec)!: every installed-package row declares where it came from, a closed `source: 'code' | 'marketplace' | 'authored'`, and the `GET /packages` rows declare the `writable` verdict they already served (#23972) - -Clause-②: yes (narrowing — the served row gains two declared keys, and the record schema, the served-row union and the registry's installPackage signature each refuse what they accepted before) - -**BREAKING** — three published accept sets narrow. `InstalledPackageSchema` refuses a row without `source`, or with a value outside `code` / `marketplace` / `authored`. The served-row union `InstalledPackageAtEitherStageSchema` refuses a row without a boolean `writable`. `SchemaRegistry.installPackage` and `EngineSchemaRegistryView.installPackage` take `source` as their second argument and refuse a call without it. The migration is below. None of it is mechanically convertible, because only the door that registers a package knows where it came from. - -**What is new on the wire.** `GET /api/v1/packages` and `GET /api/v1/packages/:id` serve two declared keys on every row: - -- `source` names the package's origin. `code` means the deployment's own code registered it (a `defineStack()` host, a plugin's bundled manifest, a platform package). `marketplace` means the marketplace install-local plugin installed it, and that holds after a restart too, when the plugin rehydrates it from its ledger. `authored` means it is a database-authored package (a Studio base, a duplicate, an HTTP install), including after the restart that rehydrates it from `sys_packages`. The door that registers the package stamps the value. No reader derives it. -- `writable` is the server's own writability verdict (ADR-0070 D2), served on these rows since #14375 and now declared. It is computed per read and never stored, so it is declared on the served row (`InstalledPackageAtEitherStageSchema`) and not on the registry record. - -FROM → TO for a reader of these rows: - -| you did | do instead | -| --- | --- | -| treated `writable: true` as authored, and looked up each non-writable row in the marketplace's installed list (the control plane's, else install-local's) to tell marketplace from code | read `row.source` | -| read the undeclared `writable` | read the declared `writable` (unchanged value) | - -The two keys are separate statements: `source` says where the package came from, and `writable` says what this server lets an org do to it. - -**`InstalledPackageSchema` requires `source`.** It is `InstalledPackageSourceSchema`, exported from `@objectstack/spec/kernel`. A row without it, or with a value outside the closed set, is refused at the `source` key. - -**API change for code that registers packages (`@objectstack/objectql`).** The registry records the origin and never infers it: - -| you wrote | write instead | -| --- | --- | -| `registry.installPackage(manifest)` | `registry.installPackage(manifest, 'code')` (or `'marketplace'` / `'authored'`, whichever is where the package came from) | -| `registry.installPackage(manifest, settings, scope)` | `registry.installPackage(manifest, source, settings, scope)` | -| `ctx.getService('manifest').register(artifact)` for a marketplace install | `ctx.getService('manifest').register(artifact, { source: 'marketplace' })` | - -`SchemaRegistry.installPackage` throws a `TypeError` naming the closed set when `source` is missing or unknown, before anything is recorded. `ObjectQL.registerApp(body, scope?, source?)` and the `manifest` service record `'code'` when no source is given. They accept `'code'` or `'marketplace'` and refuse `'authored'`. The `IObjectQLEngine` registry contract (`@objectstack/spec/contracts`) declares the same `installPackage(manifest, source, settings?)`. - -**The one-line fix:** pass the package's origin as `installPackage`'s second argument. - -Readers: objectui's Setup → Packages Source column and its `?source=` filter (it switches from the two-read join to `source` in its own follow-up), and the client SDK's `packages.list()` / `packages.get()` row types. - - diff --git a/.changeset/pre/23974-package-doors-convert-forward.md b/.changeset/pre/23974-package-doors-convert-forward.md deleted file mode 100644 index 321f423914f..00000000000 --- a/.changeset/pre/23974-package-doors-convert-forward.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/service-package': minor -'@objectstack/cloud-connection': minor ---- - -Clause-②: yes (widening) - -fix(service-package,cloud-connection): a stored or installed package body converts forward under ADR-0087 D2 at the `sys_packages` boot hydration and at the marketplace install, and one refused `sys_packages` record no longer stops the hydration of the rest - -The registration door (`ObjectQL.convertPackageBodyForward`, `@objectstack/objectql`) already replays the versioned forward conversion for a package body that declares its `engines.protocol` range. Two more doors that load a body at rest now go through that same entry point, before the registry judges the body: - -- **`sys_packages` boot hydration** (`PackageServicePlugin.start`). A stored record whose range admits this runtime, built before a retirement (for example a `type: 'home'` page with `isDefault: true`), now hydrates converted, with the engine's named warning, where the registry used to refuse it. The stored row is not rewritten, so it converts again at every boot. - - A record the registry still refuses is reported **by name** at `error`, with the refusal attached, and the records after it keep hydrating. Before, the first refusal threw out of the whole loop to a `debug` line, and every durable package after it was absent from the registry with nothing saying so. - - `Hydrated N package(s) from sys_packages into registry` counts the records the registry accepted in that pass. - - The ADR-0087 D1 handshake still runs first: a record whose declared range excludes this runtime's major is refused by it, named, as before. -- **The marketplace install** (`MarketplaceInstallLocalPlugin`, install and boot rehydrate). The body is converted once per load, and that one converted body is what `manifest.register`, the handler binder, the side effects (translations, seed datasets) and the rehydrate's sample-data heal read. Before, `register` converted its own copy while the side readers read the raw ledger entry. The ledger keeps the body as installed. - -Nothing is narrowed. A body with no declared range (a manifest built in code) meets the registry's refusals exactly as before at both doors. The install primitive `protocol.installPackage` (`POST /api/v1/packages`) is authoring-side and stays strict: the registry judges the manifest as handed. diff --git a/.changeset/pre/23978-approval-notification-subject-name.md b/.changeset/pre/23978-approval-notification-subject-name.md deleted file mode 100644 index adad29dd302..00000000000 --- a/.changeset/pre/23978-approval-notification-subject-name.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/plugin-approvals": patch ---- - -Clause-②: no - -fix(plugin-approvals): every approval notification body names its record by the object's label and the record id - -- **What changed.** The reminder, SLA-breach (to approvers and to the submitter), escalation, hand-off, revision-limit, send-back (with no note) and both out-of-office notification bodies named the record as `/`, for example `crm_deal/D_123`. They now name it the way `approval.requested` already did: the object's label and the record id, for example `Deal D_123`. An object whose schema declares no label is named by its API name and the id, for example `crm_memo D_123`. -- **No title.** No body carries the record's title, from the live record or from the submit snapshot. The body is stored and may be e-mailed, and it has no single reader whose field access it could be narrowed for. -- **Unchanged.** Each sentence's other wording, every topic, title, `actionUrl`, action link and audience, and the `DUPLICATE_REQUEST` refusal text. diff --git a/.changeset/pre/23983-previous-null-on-insert.md b/.changeset/pre/23983-previous-null-on-insert.md deleted file mode 100644 index b1d4b783b7b..00000000000 --- a/.changeset/pre/23983-previous-null-on-insert.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -docs(skills): `objectstack-data` and `objectstack-formula` state that `previous` is `null` on insert — `previous == null` judges an insert, a column read off it there faults, and only a write to an existing row whose prior state is not in hand leaves it unbound (#23983) - -Clause-②: no - -`skills/objectstack-data/references/data-hooks.md` and `skills/objectstack-formula/SKILL.md` said `previous` is unbound on insert events and told authors to write insert-event conditions over `record` alone. Since `@objectstack/objectql` binds `previous` to `null` on insert in validation rules and declarative hook conditions (the binding a flow's start condition already had), that text was false: `previous == null || …` judges an insert and `previous != null && …` skips one; only a column read off `previous` on insert (`previous.status`) still has no value and still fails the write, and only a write to an existing row whose prior state is not in hand leaves `previous` unbound, failing closed. The bullet, the scope-table row, the abort note and the legacy-translation note now state that binding and the `null` guard; the `record`-alone advice stays correct and is no longer presented as the rule. Text only; nothing an author writes changes, and a hook's `ctx.previous` is unchanged. diff --git a/.changeset/pre/23993-reset-revoked-key-restore.md b/.changeset/pre/23993-reset-revoked-key-restore.md deleted file mode 100644 index e4ea07aede1..00000000000 --- a/.changeset/pre/23993-reset-revoked-key-restore.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/plugin-auth': minor -'@objectstack/core': minor ---- - -feat(plugin-auth,platform-objects,core)!: an API key an administrator's forced password reset revoked is restored by a platform administrator only; its owner creates a new key (#23993) - -**BREAKING** (an accept-set narrowing at the `sys_api_key` write door), shipped as `minor` under the launch-window convention for breaking changes. For an operator: a key revoked by `POST /api/v1/auth/admin/set-user-password` (the forced reset, which revokes the user's keys by default) can no longer be restored by its owner; a platform administrator restores it with the Restore API Key action, or the owner creates a new key. For an integrator: `PATCH /api/v1/data/sys_api_key/{id}` with `{ revoked: false }` on such a key now answers `403 PERMISSION_DENIED` for its owner, where it answered `200`; and `sys_api_key` gains a readonly `revoked_reason` column. - -Clause-②: yes (narrowing) - -What widens: `sys_api_key` declares one more field, `revoked_reason`. What narrows: the owner's restore of a key whose `revoked_reason` is `admin_password_reset` is refused, where it was admitted. - - - -#23287 ruling C′ makes the forced reset revoke the user's API keys by default, for a suspected compromise: a key that outlives the reset is the hole the reset was meant to close. Its changeset names who acts afterwards: a platform administrator restores such a key, or the owner mints a replacement. The owner could nevertheless restore it through the same door as any revoked key, because the row recorded no reason. Now it does. - -- **`sys_api_key.revoked_reason` (`@objectstack/platform-objects`).** A readonly `select` whose one value is `admin_password_reset`, set when the forced reset revoked the key and cleared when the key is restored. Empty for a key revoked from the API Keys list. Translated in `zh-CN`, `ja-JP` and `es-ES`. -- **The stamp (`@objectstack/plugin-auth`).** The forced reset's revocation writes `revoked_reason: admin_password_reset` beside `revoked: true`, the one place the reason is set. A key already revoked before the reset is not written, so it keeps no reason. -- **The restore guard (`@objectstack/plugin-auth`).** One `beforeUpdate` hook on `sys_api_key`, registered after the ADR-0092 D2 identity write guard. A user-context restore of a key carrying the reason is refused `403 PERMISSION_DENIED` unless the caller has platform-administrator standing (the ADR-0068 D2 rung); the refusal names both roads. Every restore it admits, the administrator's included, clears the reason. Revoking an already-revoked key leaves the reason in place, and the column is not on the D2 whitelist, so no data-API write can clear it. On a multi-row update every row is judged by its own stored state. System-context writes (the mint, the last-used stamp, the forced reset) are never refused. -- **The key door's message (`@objectstack/core`).** `API_KEY_REVOKED` for a key the forced reset revoked now says only a platform administrator can restore it; any other revoked key keeps today's sentence. Code, status and every carried field are unchanged. - -What changes for you: - -- **Refused now:** the owner restoring a key the forced reset revoked: `403 PERMISSION_DENIED`, naming the platform administrator and the new-key road. The administrator's restore works as before and clears the reason. -- **Unchanged:** revoking and restoring a key from the API Keys list, by its owner or an administrator; a forced reset with `revokeSessionsAndKeys: false`, which revokes nothing. -- **Keys revoked by a forced reset before this release** carry no reason, so their owner can still restore them. Such keys exist only where `18.0.0-next.2` or a `main` build since 2026-10-14 ran a forced reset; nothing backfills them, because the reset's audit row records counts, not key ids. diff --git a/.changeset/pre/24001-conversion-conflict-channel-retired.md b/.changeset/pre/24001-conversion-conflict-channel-retired.md deleted file mode 100644 index 4cad979bd68..00000000000 --- a/.changeset/pre/24001-conversion-conflict-channel-retired.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/service-automation': minor -'@objectstack/metadata-protocol': minor ---- - -feat(spec)!: the ADR-0078 conversion-conflict channel is retired — `CONVERSION_CONFLICT_CODE`, `ConversionConflictNotice`, `ConversionConflictDetail`, the `onConflict` / `reservedNodeTypes` conversion options, the `conflicts` arrays and the `FLOW_CONVERSION_CONFLICT` 409 - -Clause-②: yes (narrowing) - -**BREAKING (published API):** the ADR-0087 conversion API carried a channel for a rename REFUSED because its old token was a live name in the environment: a conversion reported it through `ConversionContext.reportConflict`, `applyConversions` handed it to an `onConflict` sink as a `ConversionConflictNotice` (code `OS_METADATA_CONVERSION_CONFLICT`), the automation engine collected it in `canonicalizeStoredFlow`'s `conflicts` array, and the metadata protocol answered it with `409 FLOW_CONVERSION_CONFLICT`. Its only producer was the live-executor guard inside the flow-node HTTP callout rename. Protocol 18 reserved those three names (`FLOW_RESERVED_NODE_TYPES`), so the engine refuses an executor under one, no live owner can exist, and the guard went. Nothing can raise a conversion conflict any more, so the channel leaves the published surface rather than staying as a live-looking failure mode nothing triggers. - -## FROM → TO - -| you wrote | write instead | -| --- | --- | -| `import { CONVERSION_CONFLICT_CODE, type ConversionConflictNotice, type ConversionConflictDetail } from '@objectstack/spec'` | delete the import — no replacement | -| `applyConversions(stack, { onConflict, reservedNodeTypes, … })` (likewise `applyConversionsToFlow`, `applyConversionsToStoredItem`, `collectConversionNotices`) | delete both options; keep `onNotice` / `onTodo` | -| a conversion's `apply` reading `context.reservedNodeTypes` or calling `context.reportConflict` | a rename over an open namespace reserves its old names instead (the `FLOW_RESERVED_NODE_TYPES` pattern), so the refusal happens at registration; a site with no lossless rewrite goes to `context.reportTodo` | -| an `IAutomationService.canonicalizeStoredFlow` implementation returning `conflicts: […]` (or a `StoredFlowCanonicalization` from `@objectstack/metadata-protocol` carrying one) | drop the member; return `parsed`, `storable`, `notices` and optionally `todos` | -| a client branch on `error.code === 'FLOW_CONVERSION_CONFLICT'` / HTTP 409 from a flow save, a package duplication or an unmanaged package import | delete the branch — no request can produce it | - -**The one-line fix:** delete every reference; the branch it guarded was already dead, so deleting it changes no behaviour. - -**What an upgrader sees.** Importing a removed name is `TS2305`; passing a removed option, or returning a `conflicts` member from a typed canonicalizer, is an excess-property `tsc` error. `ErrorCode` no longer parses `FLOW_CONVERSION_CONFLICT`. - -**Runtime behaviour is unchanged.** The removed branches could not fire: a stored flow carrying a reserved callout name still loads as `http` (the rewrite has been unconditional since the names were reserved), and `saveMetaItem`, `duplicatePackage`, `importUnmanagedPackage` and `os migrate meta --stored` answer exactly as before. No metadata document needs editing. - -## The retirement kit - -- **`@objectstack/spec`:** the three exports leave the root; `ApplyConversionsOptions` loses `onConflict` and `reservedNodeTypes`, `ConversionContext` loses `reservedNodeTypes` and `reportConflict`, the `IAutomationService.canonicalizeStoredFlow` result loses `conflicts`; the error-code ledger drops `FLOW_CONVERSION_CONFLICT` by its own "Retiring a code" rule (a row comes out with its last emitter). -- **`@objectstack/service-automation`:** `AutomationEngine.canonicalizeStoredFlow` no longer returns `conflicts`, and the engine no longer builds a live-name set for the conversion pass. -- **`@objectstack/metadata-protocol`:** `StoredFlowCanonicalization` loses `conflicts`; the flow save door's 409, the stored-migration pass's `failed` row for a refused rename, `duplicatePackage`'s failed item and `importUnmanagedPackage`'s 409 are gone with it. -- **The D3 entry `conversion-conflict-channel-removed`.** No D2 conversion and no tombstone: no metadata document ever carried any of it. -- **No deprecation window**, per the project's startup-stage posture. - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** The three packages are published, so this is breaking for consumers no telemetry was consulted for. - - diff --git a/.changeset/pre/24002-start-mcp-stdio-early-initialize.md b/.changeset/pre/24002-start-mcp-stdio-early-initialize.md deleted file mode 100644 index b9fe106346e..00000000000 --- a/.changeset/pre/24002-start-mcp-stdio-early-initialize.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -Clause-②: no - -fix(cli): `os start` with the stdio MCP transport answers the `initialize` an MCP client writes when it spawns the server, and boots the configured app - -- **What was wrong.** An MCP client writes `initialize` to the server's stdin as soon as it spawns it. `os start` hands its stdin down to the processes it starts, and the CLI's argument parser fills an omitted positional argument from a pending stdin line. So the client's `initialize` became a config path. In a project with no `dist/objectstack.json`, the automatic compile step failed with `Config file not found: …/{"jsonrpc":"2.0",…}` and `os start` exited 1. In a project with a compiled artifact, the server printed `No objectstack.config.ts found — booting from artifact`, booted without the config's own plugins, and never answered `initialize`. -- **What changed.** The automatic compile step now gets no stdin, and `os start` names the config path when it starts `os serve` (the same `objectstack.config.ts` in the working directory that `os serve` used by default). The client's first message stays in the pipe for the stdio transport, and the config is loaded. -- **Unchanged:** the flags and environment variables of `os start`, `os serve` and `os compile`, and which config and artifact a boot uses. diff --git a/.changeset/pre/24007-approval-request-title-label.md b/.changeset/pre/24007-approval-request-title-label.md deleted file mode 100644 index c62d0e0be68..00000000000 --- a/.changeset/pre/24007-approval-request-title-label.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -Clause-②: no - -fix(plugin-approvals): an approval request's record title reads the flow's label, not `flow:NAME` - -- **`sys_approval_request.display_title` reads `process_label`.** The title is now the flow's label and the record id (`Discount Sign-off · opp_45`), the same label the request lists show. A row with no label stored falls back to `process_name` (`flow:deal_desk · opp_42`), so the title never reads `null · …`. Every surface that draws the request's title through `nameField` shows it: the record page's heading and breadcrumb, and the request cell of the action history list. -- **`titleFormat` still names `{process_name}`.** A placeholder template cannot fall back, and `process_label` can be null after the boot fill (a row it could not label, or a failed fill run). A renderer that reads `titleFormat` ahead of `nameField`, such as the global search palette, still shows the machine source. - -A patch: one formula on one platform object's computed title. No export, type, accepted value or stored column changes. diff --git a/.changeset/pre/24016-org-invite-action.md b/.changeset/pre/24016-org-invite-action.md deleted file mode 100644 index e21bfe8b689..00000000000 --- a/.changeset/pre/24016-org-invite-action.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/platform-objects': patch -'@objectstack/spec': patch ---- - -`sys_organization` now declares an `invite_user` action at `record_header`, so an organization record page can offer "Invite User" as its primary header button. It calls `/api/v1/auth/organization/invite-member` with the record's id as `organizationId`, asks for `email` and `role` (the role picker offers only the roles the caller may grant), and is gated like the other three invite mirrors: the `organization` capability and the owner / admin / delegated_admin grades. Its `order: -1` puts it first in the header. - -`leave_organization` keeps `variant: 'danger'` and now declares `order: 1`, so it sorts last among the header actions and folds into the `⋯` overflow menu instead of taking the third inline slot. Its confirmation now names what is lost: the organization's environments and members (en, zh-CN, ja-JP and es-ES). - -`@objectstack/spec` books the new action in `PUBLIC_AUTH_FEATURES.organization.gatedInputs`, beside the other three invite mirrors. - -Clause-②: no diff --git a/.changeset/pre/24022-datasource-admin-no-server.md b/.changeset/pre/24022-datasource-admin-no-server.md deleted file mode 100644 index fc097506db6..00000000000 --- a/.changeset/pre/24022-datasource-admin-no-server.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os serve --no-server` no longer reports an ordering fault for the datasource admin routes or for the unknown-hostname guard. A boot that composes no HTTP server used to print, in its boot diagnostics, `WARN [datasource-admin] route registration failed: [Kernel] Service 'http.server' not found (… a composition/ordering fault; registration order is not a contract.)`, although nothing was at fault. With `OS_ROOT_DOMAIN` set, the same boot also printed `WARN [unknown-hostname-guard] install failed` with the same kernel text. Each now prints its own degrade line, beside the same boot's other "not mounted" lines: `WARN [datasource-admin] http.server unavailable; REST routes not installed`, and `WARN [unknown-hostname-guard] http.server unavailable; guard not installed`. - -What still warns: on a boot that does compose an HTTP server, a route registration that throws still prints `[datasource-admin] route registration failed` with the error, and a guard install that throws still prints `[unknown-hostname-guard] install failed` with the error. A composed boot mounts `/api/v1/datasources` and installs the hostname guard as before, including under `--no-server` when the config mounts its own `HonoServerPlugin`. Both plugins now read only the canonical `http.server` service. Their old fallback to the deprecated `http-server` alias never ran: on a boot without `http.server` the canonical read threw first. So every boot that mounted these routes or installed the guard before still does. diff --git a/.changeset/pre/24023-keys-actions-405-allow.md b/.changeset/pre/24023-keys-actions-405-allow.md deleted file mode 100644 index d3b5c054fe0..00000000000 --- a/.changeset/pre/24023-keys-actions-405-allow.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): the `keys` and `actions` dispatcher domains answer their `405 METHOD_NOT_ALLOWED` with an `Allow: POST` header - -Clause-②: no - -RFC 9110 requires a `405` to carry an `Allow` header naming the methods the target resource supports. The `mcp`, `meta` and `ai` dispatcher domains already send one. The `keys` and `actions` domains answered their `405` without it, so a client could see that its verb was refused but not which verb the route takes. - -Both domains serve one method, `POST`, on every path they claim, and their `405` now carries `Allow: POST`: - -- **`/api/v1/keys`.** A signed-in `GET`, `PUT`, `PATCH` or `DELETE` is answered `405` with `Allow: POST`, on the standalone server (`os dev`, `os serve`) and on a host built with `@objectstack/hono`'s `createHonoApp`. -- **`/api/v1/actions/...`.** A signed-in non-`POST` request that reaches the `actions` domain is answered `405` with `Allow: POST`. That is every such request on a `createHonoApp` host. The standalone server mounts these paths for `POST` only, so its HTTP adapter answers other verbs itself, and that answer already carried `Allow: POST`. - -The status, the body and its `error.code` are unchanged: `{ success: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'Method not allowed', httpStatus: 405 } }`. Each domain still decides an anonymous caller before it looks at the method, so an anonymous `GET` (or any other verb the anonymous decision refuses) that reaches either domain is answered `401 UNAUTHENTICATED`, with no `Allow` header. diff --git a/.changeset/pre/24024-notifications-share-links-anonymous-floor.md b/.changeset/pre/24024-notifications-share-links-anonymous-floor.md deleted file mode 100644 index 935634f1821..00000000000 --- a/.changeset/pre/24024-notifications-share-links-anonymous-floor.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/runtime': patch -'@objectstack/plugin-sharing': patch ---- - -fix(runtime, plugin-sharing): on the standalone server, an anonymous caller of any `/api/v1/notifications` verb or any `/api/v1/share-links` path off the two token faces is answered `401 UNAUTHENTICATED` in the dispatcher's standard envelope - -Clause-②: no - -The v18 text for these two doors reads "`/notifications`: `401 UNAUTHENTICATED` on every face" and "`/share-links`: `401 UNAUTHENTICATED` on the create, list and revoke faces and on every path the domain does not serve", in "the dispatcher's standard envelope … with the shared message every other dispatcher domain answers with". A host that reaches the dispatcher through `@objectstack/hono`'s catch-all already answered that way. The standalone server (the one `os dev` and `os serve` run) did not: - -- `/api/v1/notifications`, `/api/v1/notifications/read` and `/api/v1/notifications/read/all` were each mounted for the one verb they serve, so any other verb never reached the `notifications` domain and the HTTP adapter answered `405`, naming the allowed methods, to an anonymous caller as well. -- `/api/v1/share-links` is served there by `@objectstack/plugin-sharing`'s routes, not by the dispatcher. They registered five routes and nothing else, so another verb or path got the adapter's `405` or `404`, and the create, list and revoke faces answered `401` with a sentence of their own ("Sign in to list share links") and no `error.httpStatus`. - -What the standalone server answers now: - -- **Anonymous caller.** `401 UNAUTHENTICATED`, `{ success: false, error: { code: 'UNAUTHENTICATED', message: 'Authentication is required to access this endpoint.', httpStatus: 401 } }`, on every verb of the three notifications paths, and on every verb and path under `/api/v1/share-links` except `GET /share-links/:token/resolve` and `GET /share-links/:token/messages`, which the capability token still admits. A body the server cannot parse does not outrank this answer anywhere under `/api/v1/share-links`, nor on the notifications verbs no face serves. -- **Signed-in caller of a verb or path that is not served.** Still refused. On the notifications paths the domain answers it: `404 ROUTE_NOT_FOUND` from the dispatcher (or the `501` capability answer on a deployment with no inbox), where the adapter used to answer `405`. Under `/api/v1/share-links` the share-link routes answer it themselves: `404 ENDPOINT_NOT_FOUND`, naming the request, where the adapter used to answer `405` or `404`. No `Allow` header is sent on either. - -Every request that was served is served as before. A notifications sub-path no face names, such as `/api/v1/notifications/`, is still not mounted on the standalone server and is answered `404`. diff --git a/.changeset/pre/24025-put-patch-request-id.md b/.changeset/pre/24025-put-patch-request-id.md deleted file mode 100644 index 90a9d22988a..00000000000 --- a/.changeset/pre/24025-put-patch-request-id.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): every PUT and PATCH route the dispatcher mounts answers with an `X-Request-Id` and reports its 5xx to the error reporter, as its GET, POST and DELETE routes always did - -Clause-②: no - -The dispatcher wraps each route it mounts in its observability instrumentation. That wrapper echoes a valid inbound `X-Request-Id` (and mints a `req_`-prefixed hex id otherwise), sets `req.requestId`, hands a 5xx to the configured `observability.errorReporter`, and gives the `[5xx]` log line its method, route and request id. It wrapped routes registered through `get`, `post` and `delete` only, so every route mounted through `put` or `patch` answered without any of that: `PUT` and `PATCH /api/v1/keys`, the `PUT` and `PATCH` mounts of the three `/notifications` paths, `PATCH /packages/:id` (and its `/enable` and `/disable`), `PUT /automation/:name`, the `PUT` and `PATCH /ai/*` wildcards, and PATCH routes a service emits on `ai:routes`. It now wraps all five verbs. - -- **On the wire:** a `PUT` or `PATCH` answer from these routes now carries the `X-Request-Id` header (or the header `observability.requestIdHeader` names), as "Debugging with Request IDs" in the error-handling guide describes. -- **For an operator:** a 5xx on one of these routes now reaches `observability.errorReporter`, and its `[5xx]` log line now carries the method, route and request id. -- **Metrics:** on the standalone Hono server nothing changes. The transport already counted and timed these requests through `afterResponse`, and it still does, once per request. On a transport without `afterResponse`, `PUT` and `PATCH` requests on these routes are now counted in `http_requests_total` and timed in `http_request_duration_ms`, as the other three verbs were. - -What changes for you: nothing to rewrite. diff --git a/.changeset/pre/24029-protocol-range-union.md b/.changeset/pre/24029-protocol-range-union.md deleted file mode 100644 index 2e18c9c25f2..00000000000 --- a/.changeset/pre/24029-protocol-range-union.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/metadata-core": patch ---- - -fix(metadata-core): the protocol handshake reads an `engines.protocol` `||` union, so `'^17.7.0 || ^18.0.0'` loads on protocol 18 instead of being refused as incompatible - -Clause-②: no - -- **What was wrong.** `rangeAdmitsMajor` had no branch for `||`. A union reached the caret branch and was read as a caret on its first alternative, so `'^17.7.0 || ^18.0.0'` answered `OS_PROTOCOL_INCOMPATIBLE` on protocol 18. Every door that runs the ADR-0087 D1 handshake refused it: `sys_packages` boot rehydration, the marketplace install-local POST and rehydrate, and `protocol.installPackage`. -- **What it does now.** A union admits a major when any alternative admits it. When none does and any alternative is unrecognized (an empty one included, `'^17 ||'`), the union answers `unparsed-range`: the package loads with the existing `[protocol]` warning and is never refused. A union whose every alternative refuses is refused as before. Each alternative is read with the unchanged single-range grammar (caret, tilde, hyphen, comparators, `x`, `*`), so a range without `||` reads exactly as it did. -- **The refusal's `targetMajor`.** For a refused union it is the lowest alternative's major, whatever order the alternatives are written in, so `'^17 || ^16'` and `'^16 || ^17'` both name `objectstack migrate meta --from 16`. It used to read the first alternative. -- **The artifact forward-conversion floor.** `parseRangeFloor` reads a union's floor as its lowest alternative's (`'^18.0.0 || ^17.1.0'` → `17.1.0`, as for `'^17.1.0 || ^18.0.0'`), and `null` when an alternative is unreadable. It used to read the first alternative, so a union written high-first opened a narrower conversion window than the same range written low-first. diff --git a/.changeset/pre/24030-install-overwrite-readonly-package.md b/.changeset/pre/24030-install-overwrite-readonly-package.md deleted file mode 100644 index e1245100b73..00000000000 --- a/.changeset/pre/24030-install-overwrite-readonly-package.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime)!: `POST /api/v1/packages` with `overwrite` refuses a read-only package with `422 WRITABLE_PACKAGE_REQUIRED`, as disable, publish, revert and delete already do - -ADR-0070 D2 makes a package the deployment ships read-only: one booted by code, one installed from the marketplace, and one whose manifest `scope` is `system` or `cloud`. `isWritablePackage` is that rule, and the four lifecycle doors that change a package (`PATCH /packages/:id/disable`, `POST /packages/:id/publish`, `POST /packages/:id/revert`, `DELETE /packages/:id`) refuse such a package with `422 WRITABLE_PACKAGE_REQUIRED`. The install door's `overwrite` option only bypassed the `409 … already exists` answer and never asked the rule. So an overwrite over a read-only package answered `201` and replaced its registry row with an authored one, and with a `protocol` service composed it also wrote that package's `sys_packages` row, while the server went on treating the package as read-only. The install door now asks the same rule, for the manifest's id, before either install path writes anything. - -Clause-②: no (narrowing) - - - -**BREAKING** (a REST door's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes. - -- **FROM:** `POST /api/v1/packages` with `overwrite: true` in the wrapped body, or `?overwrite=true`, and a manifest whose `id` names a read-only package, answered `201` and replaced that package's registry row. -- **TO:** the same request answers `422` with `error.code: 'WRITABLE_PACKAGE_REQUIRED'` and `error.details.packageId`, and writes nothing: no registry row, no `sys_packages` row. It is the refusal the four lifecycle doors give. -- **Unchanged:** an install of a new id, with or without `overwrite`, and an overwrite of a package an organization authored answer `201` as before. An install over an existing id without `overwrite` still answers `409`. -- **The fix for an integrator:** a package the deployment ships is managed by the deployment (its code release, or a new marketplace version), not over this API. Install the manifest under an id you own, or customise what the shipped package provides beside it as environment-level state (ADR-0131 D6). diff --git a/.changeset/pre/24031-exposure-gate-one-object-read.md b/.changeset/pre/24031-exposure-gate-one-object-read.md deleted file mode 100644 index 71e86880948..00000000000 --- a/.changeset/pre/24031-exposure-gate-one-object-read.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): the API-exposure check on every `/data` request reads only the object it checks, not the whole object list - -Clause-②: no - -- **Faster `/data` requests.** Before each `/data` read or write, the REST layer checks the object's `enable.apiEnabled` and `enable.apiMethods`. To read that one object's flags, the check used to load every object's metadata and run a full schema diagnostic on each one. It now reads only the object named in the URL. Measured on the showcase app on a shared machine: 33 `GET /api/v1/data/showcase_account` calls took 647 ms instead of 2421 ms (-73 %), and the metadata read behind the check fell from 56-61 ms to about 4.5 ms per request. -- **Same answers.** Code-registered objects, objects stored in the environment and objects that another package extends get the same `404 OBJECT_API_DISABLED` and `405 OBJECT_API_METHOD_NOT_ALLOWED` as before. An unknown object is still answered by the data path's `404 OBJECT_NOT_FOUND`. When the metadata store cannot be read, the check still lets the request through and logs its `api-exposure gate` warning. Nothing is cached, so a metadata change applies from the next request. -- **One refusal changes, for an object whose package is disabled.** A denied operation on such an object now gets the exposure answer instead of the disabled-package one: - - an operation outside `apiMethods`: FROM `404 OBJECT_PACKAGE_DISABLED` TO `405 OBJECT_API_METHOD_NOT_ALLOWED`; - - any operation on an object with `apiEnabled: false`: FROM `404 OBJECT_PACKAGE_DISABLED` TO `404 OBJECT_API_DISABLED`. - - An allowed operation still answers `404 OBJECT_PACKAGE_DISABLED`. No accepted request is now refused, and no refused request is now accepted. diff --git a/.changeset/pre/24032-plugin-auth-body-refusal.md b/.changeset/pre/24032-plugin-auth-body-refusal.md deleted file mode 100644 index fd1bad87917..00000000000 --- a/.changeset/pre/24032-plugin-auth-body-refusal.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/plugin-auth': minor ---- - -Clause-②: no (narrowing) - -fix(plugin-auth)!: sixteen of this package's own routes refuse a declared JSON body that does not parse with `400 INVALID_REQUEST`, instead of answering as if no body was sent - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -These routes read a body that does not parse as no body, through `try { body = await request.json() } catch { body = {} }` or a module-private `parseJson` that answered `{}` from its `catch`, so a truncated body was answered as if the client had sent none: - -- `POST /api/v1/auth/admin/oauth2/toggle-disabled`, `/admin/unlock-user` and `/sys-oauth-application/register`; -- `POST /api/v1/auth/admin/import-users`; -- `POST /api/v1/auth/admin/sso/register`, `/admin/sso/register-saml`, `/admin/sso/request-domain-verification` and `/admin/sso/verify-domain`; -- `POST /api/v1/auth/send-verification-email`; -- `POST /api/v1/auth/admin/ban-user` and `/admin/unban-user`; -- `POST /api/v1/auth/admin/set-user-manager`; -- `POST /api/v1/auth/admin/create-user` and `/admin/set-user-password`; -- `POST /api/v1/auth/organization/add-member`; -- `POST /api/v1/auth/set-initial-password`. - -`/admin/unlock-user` answered `400 "userId is required"` for a body that carried `userId` and lacked its closing brace. `/send-verification-email` read the same input as the one-click resend, and sent the link to the caller's own address for a body that named another one. - -What changes for an integrator. A body sent as `application/json` (matched case-insensitively) that is not blank and does not parse: FROM the route's answer for an empty body, TO `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "The request body could not be parsed as JSON (it was sent as application/json). It was refused rather than read as an empty body: send a well-formed JSON document, or no body at all." } }`, the answer the HTTP transports give the same body. A declared JSON body whose stream cannot be read is refused with the same code and "The request body could not be read. It was refused rather than read as an empty body." Each route refuses where it reads its body. The import, the four SSO bridges, ban/unban, set-user-manager, create-user, set-user-password and add-member read it after their platform-admin gate (and after the admin-plugin or organization-plugin check where the route has one). toggle-disabled, unlock-user, sys-oauth-application/register, the resend and set-initial-password read it before anything else runs. Nothing is looked up, written, sent or forwarded to better-auth. - -The exported bridges eleven of these routes mount (`runAdminImportUsers`, `runRegisterSsoProviderFromForm`, `runRegisterSamlProviderFromForm`, `runRequestDomainVerification`, `runVerifyDomain`, `runResendVerificationEmail`, `runSetInitialPassword`, `runAdminCreateUser`, `runAdminSetUserPassword`, `runAdminBanUser` and `runAdminUnbanUser`) answer the same refusal, so a host that mounts them itself refuses it too. Their signatures and result types are unchanged. - -**Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body. - -**Unchanged.** An empty body (no body at all, a zero-byte one, or whitespace only) is still no body, and every route answers it as before: `/admin/unlock-user` with `400 "userId is required"`, and `/send-verification-email` as the one-click resend to the caller's own address. A body that parses is read as before, whatever its `Content-Type`, and a body that does not declare JSON and does not parse is still read as no body. diff --git a/.changeset/pre/24040-reseed-converted-body.md b/.changeset/pre/24040-reseed-converted-body.md deleted file mode 100644 index bc97fbce930..00000000000 --- a/.changeset/pre/24040-reseed-converted-body.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/cloud-connection': minor ---- - -fix(cloud-connection)!: the marketplace `reseed-sample-data` door converts the installed package body forward once, as the install and the rehydrate do, so it reloads the translations those two doors load - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -`POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data` handed its side effects (the translations it reloads into the i18n service, and the seed datasets it runs) the package body exactly as the install ledger stores it. The install and the boot rehydrate hand the same side effects that body converted forward, through the ADR-0087 window the body's own `engines.protocol` opens (`ObjectQL.convertPackageBodyForward`). So for a package built before a translation retirement, a reseed reloaded a retired translation shape that the other two doors strip. The reseed now converts the body once, through the same entry point, after the protocol handshake has judged the entry as stored. - -- **Served less now:** for a package whose body declares a range this runtime admits, from a floor that predates a retirement, a reseed no longer reloads the translation shapes that the four translation conversions remove (`translation-per-app-settings-removed`, `translation-validation-messages-removed`, `translation-component-submit-label-removed` and `translation-widget-sub-caption-removed`). For example, a pre-17.4 per-app `settings` group is no longer loaded into the i18n service on reseed. After a reseed the i18n service holds what it holds after the install or a restart. -- **Refused now:** a stored body the converter throws on is no longer reloaded. The reseed raises the converter's error, as the install does, and loads nothing. Before, it reloaded the raw body. The protocol handshake that runs first throws on the same malformed range at every door, so this arises only for a body whose range reads differently to the handshake and to the converter. -- **Unchanged:** a body that declares no range is handed back as stored, so it reloads exactly as before. The handshake still runs first: a range that excludes this runtime is still answered `422 OS_PROTOCOL_INCOMPATIBLE`, before anything is converted or loaded. No conversion touches seed `data`, so a reseed seeds the same rows and answers the same counts. The ledger keeps the body as installed. - -What changes for you: nothing to rewrite. For such a package the install and every restart already log the conversion warning, once per conversion per process, and it names the remedy: rebuild the package with `os build` and republish it. diff --git a/.changeset/pre/24041-duplicate-install-refusal.md b/.changeset/pre/24041-duplicate-install-refusal.md deleted file mode 100644 index 879c6d87382..00000000000 --- a/.changeset/pre/24041-duplicate-install-refusal.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor ---- - -fix(metadata-protocol): `duplicatePackage` is refused, with nothing copied, when the install of the copy's package record is refused - -Clause-②: no (narrowing) - - - -- **Refused now.** `duplicatePackage`, and `POST /api/v1/packages/:id/duplicate` which calls it, installs the copy's package record before it copies any item. When that install is refused, the duplicate is now refused with that same refusal, and nothing is minted or copied. A copy whose namespace another installed package owns answers `422 NAMESPACE_CONFLICT` with the registry's own sentence (ADR-0048); that covers an explicit `targetNamespace` and one derived from the last segment of `targetPackageId`. A failed `sys_packages` write answers the install's own `500`. Before, both refusals were dropped and the items were copied anyway: bound to an id with no package record (absent from `GET /packages`), after a namespace conflict inside the namespace the other package owns, and the answer read `success: true`. -- **Unchanged.** A duplicate whose package record installs copies exactly as before, with the same answer shape, and per-item failures still ride `failed[]`. A source with no package record still copies without installing one. -- **Remedy.** Choose a target namespace no installed package owns: pass `targetNamespace` explicitly, or pick a `targetPackageId` whose last segment derives a free one. For a deliberate migration, `OS_METADATA_COLLISION=warn` still downgrades the namespace gate to a warning, and the duplicate then proceeds. -- **Logged.** The refusal is logged once at `warn` by `[protocol.duplicatePackage]`, naming the target and source ids and the refusal's code and message. For a store fault the error itself is attached, so its cause reaches the log. diff --git a/.changeset/pre/24042-hono-undeclared-body-refusal.md b/.changeset/pre/24042-hono-undeclared-body-refusal.md deleted file mode 100644 index 8f9eb4e4ad7..00000000000 --- a/.changeset/pre/24042-hono-undeclared-body-refusal.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -'@objectstack/hono': minor ---- - -fix(hono)!: `createHonoApp` reads a dispatched body by `@objectstack/plugin-hono-server`'s rule, so a JSON body sent as `text/plain`, or any other body that declares neither JSON nor a form, is refused `400 INVALID_REQUEST` when the dispatched domain reads it, instead of being parsed or dispatched as `{}` - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The `${prefix}/*` catch-all and the `/auth/*` legacy fallback parsed every body as JSON whatever its `Content-Type`, and handed `dispatch()` `{}` for any body that did not parse. So the two Hono doors answered one request two ways: `@objectstack/plugin-hono-server` (`objectstack dev`, `os serve`) refused a valid query body sent as `text/plain`, and this door honoured it; a garbled body that did not declare JSON reached the data domain here as `{}`, the empty query, and read every row the caller could read. - -What changes for an integrator, on a host that serves `createHonoApp`, for a `POST`, `PUT` or `PATCH` body read by a domain that reads the parsed body (the data, metadata and every other domain that takes `body`), and on the `/auth/*` legacy fallback for every method it hands a body (all but `GET` and `HEAD`): - -- A JSON body sent as `text/plain`, or as any media type other than JSON or a form: FROM parsed and honoured, TO `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "The request body was sent as text/plain, which this server does not read into a request body. It was refused rather than read as an empty body: send it as application/json." } }`, the message naming the media type that was sent. -- Any other body that declares neither JSON nor a form (`text/plain`, `application/octet-stream`, a `+json` suffix type such as `application/merge-patch+json`, or no `Content-Type` at all), when the request declares a body (`Content-Length` other than `0`, or `Transfer-Encoding`): FROM `{}`, TO the same refusal ("sent with no Content-Type" when there is none). -- A `multipart/form-data` or `application/x-www-form-urlencoded` body: FROM `{}`, TO its parsed fields, as `plugin-hono-server` hands them (Hono's `parseBody`: the last value of a repeated name). A form that does not parse: TO the refusal "The request body could not be parsed as form data (it was sent as multipart/form-data). It was refused rather than read as an empty body." - -The domain acts on nothing past its read of the body, and the dispatcher's own gates still answer first. Each answer is the status and body `@objectstack/plugin-hono-server` gives the same request. This replaces the earlier `createHonoApp` entry's statement that a body that does not declare JSON is read as before. - -**Remedy, in one line.** Send a JSON body with `Content-Type: application/json`, or send no body. - -**Unchanged.** - -- A body sent as `application/json` (matched case-insensitively) is parsed when well-formed, `{}` when blank or whitespace-only, and refused when it does not parse, as before. -- A request that declares no body is dispatched as `{}`, whatever its media type. -- The request handed on beside the body still arrives unread, in every case. A domain that forwards it and reads its own bytes answers exactly as before, whatever the body declares: `/webhooks/redeliver` and the inbound automation hooks give their own refusal after their own sign-in or signature check, and the `/approvals/act` form `POST` reads its form. -- The catch-all dispatches `GET`, `HEAD` and `DELETE` with no body, and the `/auth/*` fallback hands `GET` and `HEAD` `{}`, as before. diff --git a/.changeset/pre/24043-http-request-body-tsdoc.md b/.changeset/pre/24043-http-request-body-tsdoc.md deleted file mode 100644 index a0c326a1718..00000000000 --- a/.changeset/pre/24043-http-request-body-tsdoc.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -docs(spec): `IHttpRequest.body`'s TSDoc says what a handler reads, and that reading it may refuse - -Clause-②: no - -- **What it is.** The parsed JSON document for `application/json`, or the parsed fields of a `multipart/form-data` or `application/x-www-form-urlencoded` form; `{}` when the request carries no body. -- **It may refuse.** An implementation that cannot parse the body, or does not read its media type, throws from the read instead of handing over an invented value. The standard transport (`@objectstack/plugin-hono-server`) does, and answers 400 `INVALID_REQUEST` itself, replacing whatever the handler wrote. So a handler must not catch the refusal and carry on. -- **Raw bytes.** A handler that needs them reads `rawBody()` and never reads `body`. -- **Unchanged:** the type (`body?: any`), every other member, and every runtime behaviour. This is prose in the shipped `.d.ts` only. diff --git a/.changeset/pre/24047-action-param-options-comments.md b/.changeset/pre/24047-action-param-options-comments.md deleted file mode 100644 index 0c6bf95ea44..00000000000 --- a/.changeset/pre/24047-action-param-options-comments.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Clause-②: no - -The `ActionParam` `options` docblock in `src/ui/action.zod.ts`, and the comment above that option -shape's `guidance` table, no longer say that objectui's `normaliseOptions` rebuilds each option a -field-backed param inherits as a bare `{ label, value }` pair. At the console pin (`.objectui-sha` -`f8267df93aba`) it keeps every key of an inherited option by spread -(`packages/fields/src/resolveActionParams.ts:515` in objectui). The comments now give the reason -that remains for the `guidance` texts not telling an author to make a param field-backed and -inherit `color`, `icon` or `disabled`: the keys survive, but no reader of them on that path is -measured. No key, schema, export, accepted value or message text changes. diff --git a/.changeset/pre/24059-unmanaged-sweep-sys-packages.md b/.changeset/pre/24059-unmanaged-sweep-sys-packages.md deleted file mode 100644 index beef3ccf37b..00000000000 --- a/.changeset/pre/24059-unmanaged-sweep-sys-packages.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os migrate plan` no longer lists `sys_packages` among the tables "declared by no object in this plan" after `os migrate apply` has created it - -Clause-②: no - -`sys_packages` is the package registry's table. It is not an object: `PackageServicePlugin` creates it with its own DDL, and `os migrate apply` creates it from the registry's `package.storage` declaration. The plan's unmanaged-table sweep built its known set from the planned drivers' managed tables and the declared objects only, so on every applied deployment the plan reported the live registry table as if it were stranded. - -- **Now:** the sweep also counts the tables the boot's composition declares outside the object set. It reads that name from the same `package.storage` declaration the plan creates the table from. It reads it on every boot, whether or not the table exists yet, the skip-schema-sync boot of a Turso remote primary included. On `examples/app-crm` after `os migrate apply --yes`, the plan lists only a genuinely stranded `sys_*` table. -- **Still reported:** a `sys_packages` table in the database of a deployment that composes no package registry. Nothing there declares it. -- **Refused in one more case:** a `package.storage` service whose shape this CLI does not read now fails `plan`, `apply` and `organization-ownership` on a skip-schema-sync boot too, with the error those commands already gave on every other boot: `@objectstack/cli` and `@objectstack/service-package` must come from the same release. -- **Unchanged:** the `--json` keys, the section's wording, and what `apply` creates. diff --git a/.changeset/pre/24060-activity-gate-named-parents.md b/.changeset/pre/24060-activity-gate-named-parents.md deleted file mode 100644 index 5098e068b2e..00000000000 --- a/.changeset/pre/24060-activity-gate-named-parents.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -Clause-②: no - -fix(plugin-audit): a read of the activity stream, the compliance ledger or comments that names its parent objects writes one parent-record branch per named object, not one per registered object - -- **What changed.** The parent-record read gate on `sys_activity`, `sys_audit_log` and `sys_comment` ANDs one branch per parent object into every read: a `ReadableIds` leaf, or the bounded probe for an on-behalf-of caller, a middleware-narrowed parent or a parent on another datasource. It wrote one for every registered object, whatever the read asked for. When the read's own `where` names the objects its rows are about (`object_name` as a plain value, `$eq` or `$in` of machine names, at the top level or inside a top-level `$and`), the gate now writes branches for those objects only, and the bounded probe pre-scans only the named objects of its classes, or does not run. The activity stream's withheld-update rule, which wrote one branch per enumerated object too, narrows the same way. -- **What it costs now.** Measured on SQLite with 148 registered objects: a newest-first 20-row activity read filtered to 3 objects went from a 711.8 KB statement with 2,629 bindings to 14.9 KB with 60, for the rows and again for the total, and its two probe pre-scans no longer run. -- **Unchanged:** the rows, the totals and the grouped counts every reader is served. A read of any other shape (no `object_name` filter, an `$or`, a `$not`, another operator, a value that is not a machine name) keeps every branch, as before. No export, option or stored shape changes. -- **One failure mode is gone:** a leaf or a probe for an object the read does not name is no longer composed or run, so a refusal composing it, or a failing pre-scan for it, no longer fails or denies that read. diff --git a/.changeset/pre/24061-auth-prelude-queries.md b/.changeset/pre/24061-auth-prelude-queries.md deleted file mode 100644 index fcf06d2a0d5..00000000000 --- a/.changeset/pre/24061-auth-prelude-queries.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -'@objectstack/plugin-auth': patch -'@objectstack/plugin-hono-server': patch ---- - -Clause-②: no - -perf(plugin-auth, plugin-hono-server): an authenticated request reads 8 rows in 4 round trips before its handler, down from 11 in 7, and `/auth/me/*` resolves the caller's grants once - -Measured on a real engine (SQLite through `@objectstack/driver-sql`), for a signed-in user whose session has an active organization: - -| before the handler | before | after | -|:--|:--|:--| -| REST and dispatcher doors (`resolveAuthzContext` with the in-process session read) | 11 reads, 7 round trips | 8 reads, 4 round trips | -| `GET /auth/me/permissions`, `/auth/me/localization`, `/me/apps` | 17 reads, 9 round trips | 8 reads, 4 round trips | -| `GET /api/v1/auth/get-session` | 11 reads, 7 round trips | 10 reads, 6 round trips when it reads the signing key; 8 in 4 while the key is held | - -- **An in-process session read no longer signs a JWT.** better-auth runs `/get-session`'s after-hooks for a server-side `auth.api.getSession(...)` call too, so every door that asked "who is calling" had the jwt plugin read the signing key (two `sys_jwks` round trips) and sign a `set-auth-jwt` header the caller then discarded. The hook now runs only when the call answers an HTTP request. `GET /api/v1/auth/get-session` still carries `set-auth-jwt`, signed exactly as before. A server-side caller that wants the header dispatches the request through `AuthManager.handleRequest`. -- **The session token is not read back after the session was found by it.** `sys_session.token` is withheld from ordinary reads and recovered for better-auth through a privileged read. better-auth finds the session by its token, so that read had nothing to add. It was a second round trip between the session row and the user row on every session read. A read keyed on any other column still recovers the token. -- **`/auth/me/*` and `/me/apps` resolve the grants once.** These routes resolved the caller's grants twice per request: once inside the session read (for the payload's `positions[]`) and once more for their own context. They now resolve through `resolveAuthzContext`, whose request-scoped memo serves the second resolution from the first while no write has landed in between. The memo ends with the request. The routes still admit a session only, and the session's active organization stands as before. -- **An unreadable authorization store is an outage on `/auth/me/*` and `/me/apps`, not a signed-out caller.** Because these routes now authorize through `resolveAuthzContext`, they follow the rule every transport through it follows: a permission-store read that fails answers `503` with the code `SERVICE_UNAVAILABLE` (on `HonoHttpServer`'s error seam) instead of `{ authenticated: false }` or `{ apps: [] }`, which a client cannot tell from an anonymous caller. A store whose tables were never created is not an outage: those routes answer the signed-in caller with no grants, as before. -- **The signing key is held across requests, under two bounds.** The keyring the jwt plugin signs with is kept after a read. Any write to `sys_jwks` that passes this process's engine drops it at once, and it is never kept longer than 30 seconds, which bounds a write this process cannot see (another node minting a key). An engine without a middleware seam keeps nothing. On a host without Ed25519 the keyring is still filtered to keys the host can use. - -Unchanged: the session and user every door resolves, every grant, the `/get-session` body and its renewal, every option and environment variable, and the exports. No new configuration key. The one answer that changes is the outage answer above. diff --git a/.changeset/pre/24062-authored-install-scope-project.md b/.changeset/pre/24062-authored-install-scope-project.md deleted file mode 100644 index df579a6b251..00000000000 --- a/.changeset/pre/24062-authored-install-scope-project.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/runtime': patch ---- - -feat(spec)!: an install through `POST /api/v1/packages` may declare `manifest.scope` `project` only; `cloud` and `system` are refused, so an authored install can no longer lock itself - -`cloud` and `system` are the scopes of packages the platform ships, and the ADR-0070 D2 read-only rule (`isWritablePackage`) judges a registered package by its stored `manifest.scope`. An install through `POST /api/v1/packages` authors the package, yet the install declaration admitted all three scopes. So a manifest that copied `scope: 'cloud'` or `'system'` from a shipped package answered `201` and produced an authored package that `DELETE /packages/:id`, `PATCH /packages/:id/disable` and `POST /packages?overwrite=true` then refused with `422 WRITABLE_PACKAGE_REQUIRED`, and the stored row came back on every restart. The install declaration now narrows the install manifest's `scope` to `project` (new export `PackageInstallManifestSchema`, `ManifestSchema` with that one key narrowed), on both body forms of `PackageInstallBodySchema` and on `PackageInstallRequestSchema.manifest`. `ManifestSchema` itself is unchanged: platform packages still declare all three scopes, and they reach the registry by code boot, not over this door. The door already parses its body through that declaration, so it answers the refusal with no rule of its own; its bare-form refusal sentence now re-reads the union's own bare branch, so it carries the prescription too. - -Clause-②: no (narrowing) - - - -**BREAKING** (a published request declaration's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes. - -- **FROM:** `POST /api/v1/packages` with a manifest declaring `scope: 'cloud'` or `scope: 'system'`, in the wrapped body `{ manifest }` or as a bare manifest, with or without `overwrite`, answered `201`; `PackageInstallBodySchema` and `PackageInstallRequestSchema` parsed it green. -- **TO:** the same request answers `400` with `error.code: 'VALIDATION_ERROR'`, names `scope` and the value sent, and installs nothing; both schemas refuse it with an `invalid_value` issue at `manifest.scope` (wrapped) or `scope` (bare). -- **Unchanged:** a manifest with `scope: 'project'` or no `scope` installs as before (`201`), and the door still stores the manifest as sent, so an absent `scope` stays absent. `ManifestSchema` and every other producer of it keep `cloud` / `system`. -- **The fix for an integrator:** remove `scope` from the manifest you install (it defaults to `project`), or write `scope: 'project'`. -- **A package that already locked itself this way** (an authored `sys_packages` row whose manifest declares `cloud` or `system`) stays read-only after this change, because the read-only rule reads the stored scope. No API door removes it; an operator deletes its row from the environment's database (`DELETE FROM sys_packages WHERE id = 'PACKAGE_ID'`) and restarts the server. diff --git a/.changeset/pre/24063-view-liveness-evidence.md b/.changeset/pre/24063-view-liveness-evidence.md deleted file mode 100644 index cfaa38bbd66..00000000000 --- a/.changeset/pre/24063-view-liveness-evidence.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The liveness ledger's `form.sharing` and `formViews` rows quote the code that reads their keys as it now reads - -Clause-②: no (only the two rows' evidence text changes and both statuses stay `live`: `ViewSchema` accepts `form.sharing` and `formViews` exactly as before, and no state count moves) - -- `liveness/view.json`, row `form.sharing`: the evidence quoted a `slugMatchesPublicLink(sharing.publicLink, slug)` call that no longer exists. It now cites `packages/rest/src/form-routes.ts#findPublicFormView`, which loops over `anonymousFormIntakeCandidates(view)`, and the two rules in `packages/spec/src/ui/anonymous-form-intake.ts` it reads: `anonymousFormIntakeCandidates`, which hands each form's `sharing` to the slug rule, and `anonymousFormIntakeSlug`, which reads all three keys (`enabled`, `allowAnonymous` and `publicLink`). The `resolveFormBySlug` clause now says where `publicFormGrant` is set: in the submit door, for the match that `resolveFormBySlug` returns. -- `liveness/view.json`, row `formViews`: the evidence quoted `const formViews = view.formViews`, which no longer exists. It now quotes the line of `anonymousFormIntakeCandidates` that makes every `formViews` entry a candidate. -- The verdicts, the notes and every other row are unchanged. diff --git a/.changeset/pre/24065-ooo-notification-person-names.md b/.changeset/pre/24065-ooo-notification-person-names.md deleted file mode 100644 index d3c24cfa6bd..00000000000 --- a/.changeset/pre/24065-ooo-notification-person-names.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -Clause-②: no - -The two out-of-office approval notifications name people by their display name instead of their raw user id. The delegate's `approval.ooo_substituted` body used to read `You are covering an approval on Deal D_123 while u_ooo is out of office.`, and now names the absent approver: `… while Olive Away is out of office.` The absent approver's `approval.ooo_skipped` body used to read `… was routed to u_bob while you are out of office.`, and now names the delegate: `… was routed to Bob …`. - -The names come from the same user-name lookup the approvals inbox uses for pending approvers and the timeline, read once per opening for every person the out-of-office routing touched. An account row with no name is named by its email, as the inbox names it. A person with no readable name (no account row for the id, or a failed read) is still named by the user id, as before. The `ooo_substitute` entry on the request's audit trail is unchanged and still records the two user ids (`u_ooo → u_bob — reason`). diff --git a/.changeset/pre/24070-withdraw-meta-object-copies.md b/.changeset/pre/24070-withdraw-meta-object-copies.md deleted file mode 100644 index b48c5250915..00000000000 --- a/.changeset/pre/24070-withdraw-meta-object-copies.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/cloud-connection': patch ---- - -Clause-②: no - -fix(cloud-connection): an install-local uninstall takes the package's objects out of `GET /api/v1/meta/object` and `GET /api/v1/meta/object/NAME` at once, not at the next restart - -- **What was wrong.** An install-local install registers the package through the `manifest` service, and that register does two things: it puts the package in the engine's registry, and it copies each of the package's objects into the `metadata` service, stamped with the package's id. `DELETE /api/v1/marketplace/install-local/:manifestId` withdrew the registry half only. The object listing merges the `metadata` service into its answer and the by-name read asks that service first, so both kept serving every object of the uninstalled package, with its `_packageId`, until the runtime restarted, while `GET /api/v1/data/OBJECT` already answered `404`. The console's object pickers and Studio's object list offered objects that no longer existed. -- **What changes.** Right after the registry withdrawal, the DELETE removes from the `metadata` service each object copy stamped with the package's id whose name the registry no longer serves. The object listing and the by-name read stop serving the package's objects in the same process, as its data route, `GET /api/v1/meta/app` and `GET /api/v1/meta/permission` already did. -- **Not touched.** A same-name object the package did not copy there (one without its stamp) stays. The package's tables and rows are untouched, as before. A refused registry withdrawal still leaves the whole package loaded until the next restart, its copies included. -- **A copy the service refuses to remove** is reported on the DELETE answer as a failed `cleanups` entry named `metadata.unregister`, the way a refused registry withdrawal is reported, and the operator log names the objects kept and the remedy: restart the runtime. The uninstall cleanups still run, and the answer is still `200`. When nothing is refused, `cleanups` is unchanged. diff --git a/.changeset/pre/24082-invite-role-cap-codes.md b/.changeset/pre/24082-invite-role-cap-codes.md deleted file mode 100644 index 46bb7ec8a2e..00000000000 --- a/.changeset/pre/24082-invite-role-cap-codes.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): register the invitation role cap's three refusal codes under `@objectstack/plugin-auth` - -Clause-②: yes (widening) - -The error-code ledger (`ERROR_CODE_LEDGER`, and so `ErrorCode`) gains three `@objectstack/plugin-auth` codes, one per refusal the invitation role cap can return. Today the cap answers all three as a bare `403 FORBIDDEN`, so a client such as the Invite User dialog cannot tell them apart: - -- `INVITE_ROLE_ABOVE_ISSUER` — the requested role grades above the issuer's own; an invitation never confers authority above its issuer. -- `INVITE_ISSUER_MEMBER_ONLY` — an issuer below admin grade may invite only as a plain `member`; the requested role may carry capability of its own. -- `INVITE_ISSUER_UNVERIFIED` — fail-closed: the issuer's organization membership could not be established, so the invitation may confer nothing beyond `member`. - -Registration only: `@objectstack/plugin-auth` still throws `FORBIDDEN` until #23945 stamps these codes on its refusals. No existing code changes, and no standard-catalog member is shadowed (none needs a synonym waiver). diff --git a/.changeset/pre/24086-install-primitive-parses-manifest.md b/.changeset/pre/24086-install-primitive-parses-manifest.md deleted file mode 100644 index 1adef1c8278..00000000000 --- a/.changeset/pre/24086-install-primitive-parses-manifest.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol)!: `installPackage` judges the whole manifest by the install door's declaration, so the primitive refuses a manifest `POST /api/v1/packages` refuses - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -`ObjectStackProtocolImplementation.installPackage` parsed only `manifest.id`, so an in-process caller stored manifests the install door answers `400 VALIDATION_ERROR` for. The primitive now parses the manifest through `PackageInstallManifestSchema` (`@objectstack/spec/api`, `ManifestSchema` with `scope` narrowed to `project`), the declaration the door parses, after its version default and before any write. - -- **FROM:** `protocol.installPackage({ manifest })` with `type: 'application'`, `scope: 'environment'`, `scope: 'cloud'` / `'system'`, no `type`, or a key the manifest does not declare (such as a metadata collection, `pages`) registered the package and wrote its `sys_packages` row. -- **TO:** the call throws with `statusCode: 400` (an HTTP boundary answers `VALIDATION_ERROR`), the message names each refused key path (`manifest.type`, `manifest.scope`, …) with the declaration's own sentence, and neither the registry nor `sys_packages` is written. A malformed `manifest.id` still answers first, with its own sentence and repair. -- **Unchanged:** a manifest with no `version` still installs with `version: '0.1.0'`, a missing `namespace` is still derived from the id, and the manifest is stored as sent (the parse's `scope` / `defaultDatasource` defaults are not copied onto it). -- **Rows already stored** with `type: 'application'` / `scope: 'environment'` keep hydrating at boot, as stored. A new write of the same manifest, including a `duplicatePackage` of such a base, is refused. -- **The fix for a caller:** write a declared `type` (`app` for an application package) and leave `scope` out, or write `scope: 'project'`. diff --git a/.changeset/pre/24087-search-title-namefield-first.md b/.changeset/pre/24087-search-title-namefield-first.md deleted file mode 100644 index 96562644ee7..00000000000 --- a/.changeset/pre/24087-search-title-namefield-first.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch -'@objectstack/spec': patch ---- - -Clause-②: no - -fix(metadata-protocol): the global search palette titles a hit by its `nameField` before the deprecated `titleFormat` - -- **What changed.** `searchAll` (`GET /api/v1/search`) now resolves a hit's `title` in this order: the primary-title pointer (`nameField`, or the deprecated alias `displayNameField`), then the `titleFormat` template, then the conventional name fields, then first/last name, then the record id. Before, `titleFormat` came first. ADR-0079 D3 and the `titleFormat` schema description both say an explicit `nameField` takes precedence. -- **What it means for a deployment.** An object that declares both keys is now titled by its `nameField` value. For example, an approval request shows `Discount Sign-off · opp_45` instead of `flow:deal_desk · opp_45`. The registry also designates a `nameField` for any object that has a title-eligible field (a text-like field, or a formula returning text). So an object that declares only `titleFormat` is now titled by that designated field too. The template still titles a row when the object has no pointer, or when the pointer's value is empty on that row. -- **Fix for an author who wants the composite title back:** add a `formula` field with `returnType: 'text'` that builds the composite, set `nameField` to it, and drop `titleFormat`. This is the migration ADR-0079 prescribes (`os explain title-format-retired`). -- **`@objectstack/spec`:** the description of `SearchAllHitSchema.title` now states the same order. No schema shape changed. diff --git a/.changeset/pre/24090-db-dir-dangling-symlink.md b/.changeset/pre/24090-db-dir-dangling-symlink.md deleted file mode 100644 index 6b8749af299..00000000000 --- a/.changeset/pre/24090-db-dir-dangling-symlink.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/runtime': patch ---- - -fix(cli,runtime): a dangling symlink standing as the SQLite database's directory is refused at once by `os dev` and `os serve`, naming the database file and the directory, instead of failing the boot later in a plugin's start - -Clause-②: no - -**What was wrong.** `os dev` (for its default database under `OS_HOME/data`) and the standalone host behind `os serve` and `os start` (for a `file:` or `wasm-sqlite://` database URL) make the database file's directory before the driver opens it. Both accepted any `EEXIST` answer while making a missing level. A symbolic link that points nowhere answers `EEXIST` to `mkdir`, so it was taken for a directory, and the boot went on until the driver could not open the file. `os dev` then exited 1 with `Plugin com.objectstack.auth failed to start - rollback complete: The database refused to run this query for object 'sys_organization'`, and `os serve` with `Plugin package-service failed to start - rollback complete: The database refused to run a raw statement`. Neither message named the file, the directory or the link. - -**What an operator sees now.** Both make the directory with the same bounded walk as the other `OS_HOME` paths, which accepts `EEXIST` only when a directory stands there. A dangling link as the directory is refused before the boot starts: `os dev` prints `The default database '…/data/objectstack.db' cannot be created: its directory '…/data' could not be made: EEXIST: …` and exits 1, and `os serve` prints `[StandaloneStack] SQLite database file '…' cannot be created: its directory '…' could not be made: EEXIST: …` and exits 1. Remove the link, or point it at a directory that exists. A dangling link higher up the path was already refused at once, with `ENOENT` at the level below the link; it is now refused with `EEXIST` at the link itself. - -Nothing else changes. A directory that can be made is still made, every missing level of it; a directory that exists, or a symbolic link to one, is used as it stands; and a path the operating system refuses for any other reason (`ENOENT`, `ENOTDIR`, a regular file holding the directory's name) is refused with the same code and text as before. diff --git a/.changeset/pre/24091-serve-runtime-state-write-warning.md b/.changeset/pre/24091-serve-runtime-state-write-warning.md deleted file mode 100644 index 50c71b3a2c2..00000000000 --- a/.changeset/pre/24091-serve-runtime-state-write-warning.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`os serve` says once when it cannot write its runtime state file, naming the file and the operating system's error, and goes on booting - -Clause-②: no - -- **Before:** when `runtime...json` could not be written — an `OS_HOME` under `/proc`, or one below a regular file (`ENOTDIR`) — the write's catch printed nothing. The boot printed its ready banner, an `os dev` parent got its `objectstack:listening` message, and a supervisor that then read the file got `ENOENT` with no reason anywhere. -- **Now:** one warning on stderr, among the other boot warnings and before the banner, names the file it could not write and carries the operating system's error verbatim (for example `ENOTDIR: not a directory, stat '…'`). It says the server runs on without the file and that `OS_HOME` must point at a directory the process can write. -- **Unchanged:** the file stays best-effort, so the boot never fails on it; its name, its payload and its removal on exit; the banner and the IPC message; stdout, which stays the stdio MCP transport's alone. diff --git a/.changeset/pre/24097-metadata-protocol-by-name-disabled-package.md b/.changeset/pre/24097-metadata-protocol-by-name-disabled-package.md deleted file mode 100644 index ffd9944149c..00000000000 --- a/.changeset/pre/24097-metadata-protocol-by-name-disabled-package.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol)!: the by-name `/meta` read refuses an object of a disabled package with `404 OBJECT_PACKAGE_DISABLED`, as the object list and the data plane already do - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -Disabling a package already took its objects out of `GET /meta/objects` and refused their data with `404 OBJECT_PACKAGE_DISABLED`. The by-name read still served them. It now gives the same answer. - -- **Refused now:** `getMetaItem({ type: 'object', name })` (`GET /api/v1/meta/object/:name`, the plural spelling included) for an object whose owning package is disabled. The refusal applies to every state the read serves: the active read, `state: 'draft'` and `previewDrafts`. `getMetaItemLayered` (`?layers=true`) refuses it too. The answer is `404` with `code: 'OBJECT_PACKAGE_DISABLED'` and `object`, built by the same constructor as the data plane's refusal, so the two cannot drift. -- **Unchanged:** objects of enabled packages, other metadata types, and the registry's resolution reads (`registry.getObject`, `registry.listItems('object')`), which keep serving a disabled package's objects for migrations and cross-package references. -- **Still checked:** saving an object of a disabled package still runs the destructive-change check. A save that drops a field still answers `409 DESTRUCTIVE_CHANGE` until it is confirmed with `force`. The check reads the object's prior schema through the protocol's internal read, not the refusing door. - -What changes for you: a client that read a disabled package's object by name now gets `404 OBJECT_PACKAGE_DISABLED`. Re-enable the package to read it again. diff --git a/.changeset/pre/24097-rest-exposure-gate-disabled-package.md b/.changeset/pre/24097-rest-exposure-gate-disabled-package.md deleted file mode 100644 index 9fb6aed902b..00000000000 --- a/.changeset/pre/24097-rest-exposure-gate-disabled-package.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest)!: the `/data` API-exposure check treats an object of a disabled package as no object, so every operation on it answers `404 OBJECT_PACKAGE_DISABLED` again, with no fail-open warning - -Clause-②: no (narrowing) - - - -**BREAKING** (a refusal moves), shipped as `minor` under the launch-window convention for breaking changes. - -The by-name `/meta` read now refuses an object of a disabled package with `404 OBJECT_PACKAGE_DISABLED`. The API-exposure check reads that object by name. It now reads that refusal as "no object here": there is no exposure policy to judge, and nothing failed, so it logs nothing. The request then reaches the data plane, which refuses it with the same code. - -- **Changed back:** for an object whose package is disabled, a denied operation goes from `405 OBJECT_API_METHOD_NOT_ALLOWED` (an operation outside `apiMethods`) or `404 OBJECT_API_DISABLED` (an object with `apiEnabled: false`) back to `404 OBJECT_PACKAGE_DISABLED`. That is what it answered before the check read one object by name. An allowed operation still answers `404 OBJECT_PACKAGE_DISABLED`. -- **No warning now:** such a request no longer logs `api-exposure gate: object metadata read failed — failing open`. -- **Unchanged:** any other failed read still fails open and still logs that warning. Objects of enabled packages get the same exposure answers as before. - -What changes for you: nothing to rewrite. No accepted request is now refused, and no refused request is now accepted. diff --git a/.changeset/pre/24097-runtime-meta-object-disabled-package.md b/.changeset/pre/24097-runtime-meta-object-disabled-package.md deleted file mode 100644 index 7819071a3c3..00000000000 --- a/.changeset/pre/24097-runtime-meta-object-disabled-package.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime)!: the dispatcher's `GET /meta/object/:name` answers an object of a disabled package with the protocol's `404 OBJECT_PACKAGE_DISABLED`, never the registry's copy - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The dispatcher's object read had two registry reads. On an unscoped kernel it read `registry.getObject(name)` before the protocol. On a scoped kernel it fell back to `registry.getObject(name)` when the protocol read threw. The registry keeps a disabled package's objects on purpose, so both reads served such an object. - -- **Refused now:** `GET /meta/object/:name` on the dispatcher transport, for an object whose owning package is disabled. It answers the protocol's `404 OBJECT_PACKAGE_DISABLED`, on scoped and unscoped kernels alike. -- **Unchanged:** every other object read, including the registry fallback for an object the protocol misses for any other reason. - -What changes for you: re-enable the package to read the object again. diff --git a/.changeset/pre/24120-migrate-plan-declared-datasources.md b/.changeset/pre/24120-migrate-plan-declared-datasources.md deleted file mode 100644 index b6dc5b57e69..00000000000 --- a/.changeset/pre/24120-migrate-plan-declared-datasources.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os migrate plan` and `os migrate apply` examine the objects a `datasourceMapping` rule routes to a declared datasource, the declared default included, in that datasource, as `os serve` connects it (#24120) - -Clause-②: no - -A project whose primary store is a declared datasource routed as the default (`datasources: [{ name: 'qa_main', … }]` with `datasourceMapping: [{ default: true, datasource: 'qa_main' }]`) got a plan that examined nothing. The plan resolved `qa_main`'s database as its primary, but it registered no driver under the name `qa_main`, so every object the rule routes there, platform objects included, was bound to no driver. The plan printed `Examined 0 managed table(s)` and a PARTIAL plan. Meanwhile `os dev` on the same project reported drift in that database and named `os migrate` as the remedy. - -- **Now:** `plan` and `apply` connect the project's declared **managed** datasources the way `os serve` connects them: through the same `datasource-connection` service, under the declared name, for the datasources a mapping rule routes objects to, an object binds to, or that set `autoConnect`. Each datasource that objects live in is diffed and applied beside the primary. A composition note names each one and its database, in both the human output and the `--json` output. On a `datasourceMapping`-default project, the plan's drift is the drift `os dev` reports there. -- **Not connected:** a datasource whose schema the remote owns (`schemaMode: 'external'` or `'validate-only'`). Its objects are federated, and a plan has no table on it. -- **Refused now:** a routed managed datasource that cannot connect fails `plan`, `apply` and `unmapped-columns` with the connect error `os serve` gives for it. It is no longer skipped with a PARTIAL plan. `OS_ALLOW_DRIVER_CONNECT_FAILURE` degrades it here as it does there. -- **`os migrate unmapped-columns` follows:** it filters the plan's own findings, so it composes the same datasources. On such a project it reads an object's unmapped columns from the declared datasource. Before, it refused with "not in the set os migrate plan diffs: it is bound to no driver". -- **Unchanged:** a project that declares no datasource gets the plan it got before. `os migrate security-catalog-overlays` and `organization-ownership` boot as before. The unmanaged-table sweep's `physicalTables` counts each database once, although the primary and a declared default resolved from it are two connections to one file. diff --git a/.changeset/pre/24126-system-overview-metric-height.md b/.changeset/pre/24126-system-overview-metric-height.md deleted file mode 100644 index e5c4a845abc..00000000000 --- a/.changeset/pre/24126-system-overview-metric-height.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -Clause-②: no - -fix(platform-objects): the System Overview dashboard's six metric tiles declare `layout.h: 1`, one row tall, where they declared `2` - -The `system_overview` dashboard the Setup app ships (`SystemOverviewDashboard`) authored every one of its six `type: 'metric'` tiles (Total Users, Organizations, Active Sessions, Packages Installed, Login Events, Config Changes) at `layout.h: 2`. The console draws a widget over the grid rows its `h` names, each row floors at 5rem and grows to its content, and a metric card fills its box, so each tile drew a box about twice the height of its content: the title and the number in the top half, the lower half empty. The platform draws what the metadata declares, so the authored size is what is corrected. - -- **FROM:** the six metric tiles declare `layout: { …, h: 2 }`. -- **TO:** the six metric tiles declare `layout: { …, h: 1 }`; each now draws at content height. -- **Unchanged:** every `x`, `y` and `w`, the chart and table widgets (`h: 4`), every widget id, filter and binding, and every export. -- **An org that already published an overlay of `system_overview`** is served that overlay as stored (ADR-0126 Regime O), so it keeps the tile heights it was saved with. diff --git a/.changeset/pre/24129-blueprint-view-filters-sort.md b/.changeset/pre/24129-blueprint-view-filters-sort.md deleted file mode 100644 index a9338c153d8..00000000000 --- a/.changeset/pre/24129-blueprint-view-filters-sort.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): a solution blueprint view declares `filters` and `sort`, so a view named by a condition can carry that condition - -Clause-②: yes (widening) - -`BlueprintViewSchema` and the strict structured-output mirror `SolutionBlueprintStrictSchema.views[]` gain two keys. Before this, a blueprint could say "overdue only" in a view's name and nowhere else, and the built view showed every record. - -- **`filters`** — which records the view shows, as list-view filter rules `[{ field, operator, value }]`, ANDed. - - Lenient (`BlueprintViewSchema`): optional, and the array element IS `ViewFilterRuleSchema` (the rule `ListView.filter` declares), so it takes the same operators, folds the same legacy spellings, and refuses the same operator/value-shape mismatches (`in` without an array, `between` without `[min, max]`, `icontains` without a non-empty string). - - Strict (`SolutionBlueprintStrictSchema`): required and nullable (null = a view over every record). Each rule carries the same three keys, every one present: `operator` is the canonical vocabulary only (`VIEW_FILTER_OPERATORS`, no legacy shorthand such as `eq`), and `value` is nullable (null for the unary operators). The operator/value-shape coupling is not in the strict mirror's JSON Schema; the lenient parse refuses it. -- **`sort`** — the default record order, `[{ field, order }]` with `order` `asc` or `desc`, the first entry sorting first. Lenient: optional, the element is the shared `SortItemSchema` (the `{ field, order }` pair `ListView.sort` declares; array only, a `"field desc"` string is refused). Strict: required and nullable, `{ field, order }` with both keys present. -- Both describes tell the model that a view whose name or label states a condition ("overdue", "my open") must carry the matching filter, with worked rules using the platform tokens `{today}` and `{current_user_id}`. -- Additive only: every blueprint that parsed before still parses with the lenient schema. A producer of the STRICT shape must now send `filters` and `sort` on each view (null when it has none), as with every other key in that mirror. diff --git a/.changeset/pre/24130-form-submit-docstring.md b/.changeset/pre/24130-form-submit-docstring.md deleted file mode 100644 index 0a2a125a593..00000000000 --- a/.changeset/pre/24130-form-submit-docstring.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/rest': patch ---- - -docs(rest): the public form routes' docstring says what authorizes an anonymous submit — the form's own declaration (`publicFormGrant`), not a `guest_portal` permission set — and that nothing falls open without one (#24130) - -Clause-②: no - -The docstring of `RestServer#registerFormEndpoints`, which ships in `@objectstack/rest`'s published declarations, said the submit route delegates security to a `guest_portal` permission set on its execution context, that a deployment without that set "falls open", and that secure-by-default deployments must pair the form routes with one. None of that is what runs. The submit hands the engine a `publicFormGrant` scoped to the form's target object, and the security plugin admits on that grant alone, before any permission set resolves: the create and the read-back on that object only, every other operation and object refused, the server-managed anchors stripped from the payload. The grant never falls open and admits the same operations whether or not a `guest_portal` set is registered. The docstring now says this, and says what the `guest_portal` name on the context does reach: where a deployment registers a set under it, that set is what the grant's result masker reads over the record the grant hands back, and the submit answers the created id only. - -Text only; nothing the form routes do changes. diff --git a/.changeset/pre/24131-core-required-emptiness.md b/.changeset/pre/24131-core-required-emptiness.md deleted file mode 100644 index c71578d32b2..00000000000 --- a/.changeset/pre/24131-core-required-emptiness.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/core": minor ---- - -feat(core): export the emptiness rule a `required` declaration is judged by — `isEmptyForRequired(def, value)` and `isMissing(value)` - -Clause-②: yes (widening) - -- **New exports from `@objectstack/core`'s `.` entry.** `isMissing(value)` is `true` for `undefined`, `null`, and a string that is empty or only whitespace. `isEmptyForRequired(def, value)` is `isMissing`, plus an empty list `[]` on a field whose DECLARED value is a set (`@objectstack/spec/data`'s `isMultiValueField`: `tags`, `multiselect`, `checkboxes`, or a `select` / `radio` / `lookup` / `user` / `file` / `image` field flagged `multiple: true`). `def` is a `ValueShapeFieldDef` (`{ type, multiple? }`). A structured-JSON field's `[]` is a document, not an empty value. -- **Why it moved.** It was private to `@objectstack/objectql`'s record validator, where the object-level `required` check runs. `@objectstack/rest`'s anonymous public-form submit route now judges a FORM's `required` with it, and rest does not depend on the engine at runtime. So the one rule lives in the package both depend on, and the two doors call the same field missing. -- **Unchanged:** the rule itself, byte for byte — the engine's object-level `required` refusal answers exactly what it did. diff --git a/.changeset/pre/24131-objectql-required-emptiness-import.md b/.changeset/pre/24131-objectql-required-emptiness-import.md deleted file mode 100644 index 9153b9ec995..00000000000 --- a/.changeset/pre/24131-objectql-required-emptiness-import.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -refactor(objectql): the record validator reads `isMissing` / `isEmptyForRequired` from `@objectstack/core` instead of private copies - -Clause-②: no - -- **No behaviour change.** The object-level `required` check, the `required_cleared` PATCH check, the blank-to-`null` normalisation of typed columns and the value-shape scan judge "missing" exactly as before; the private copies of the two predicates are deleted and `@objectstack/core`'s exports, moved there unchanged, are imported at every read site. -- **Why:** `@objectstack/rest`'s public-form submit route now judges a form's `required` by the same rule, and it cannot import the engine. diff --git a/.changeset/pre/24131-rest-public-form-required.md b/.changeset/pre/24131-rest-public-form-required.md deleted file mode 100644 index 23be29afc7e..00000000000 --- a/.changeset/pre/24131-rest-public-form-required.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest)!: an anonymous public-form submit that leaves a form-required field empty answers `400 VALIDATION_FAILED` naming the field, and stores nothing - -Clause-②: no (narrowing) - - - -- **What changed.** `POST /forms/:slug/submit` now enforces the form's own `required`: a section entry `{ field: 'email', required: true }` is judged before the write, with the rule the engine's object-level check uses (`isEmptyForRequired` from `@objectstack/core`). A submission whose value for such a field is absent, `null`, `''`, only whitespace, or `[]` on a multi-value field is refused, and no row is created. Before, the engine judged only the fields the OBJECT requires, so a field only the form required answered `201` and was stored empty — while the rendered form refused the same submission. -- **The refusal** is the object-level one's envelope on this route, key for key: `400` with top-level `code: 'VALIDATION_FAILED'`, `error` (the messages), and `fields[]` of `{ field, code: 'required', message, label }`, worded by the same validation catalog (for example `Email is required`). Every empty form-required field is named in one refusal, in the form's order. -- **BREAKING — a narrowing, and who it affects.** A script or integration that posts to a public form and omits a field the form marks `required: true` now gets `400` instead of `201`. The fix is to send that field. Nothing that was refused before is accepted now. -- **Judged only where the form asks.** An entry the form hides (`hidden: true`), or shows only conditionally (`visibleWhen` on the entry or on its section), is not judged — nothing server-side evaluates a form-view `visibleWhen`. A server-managed anchor (`owner_id`, `organization_id`, …) is never written by this route, so it is never judged. An object `defaultValue` or a `beforeInsert` hook does not satisfy a field the visitor omitted: the route judges what the visitor sent. diff --git a/.changeset/pre/24134-console-pin-5e15928b4631.md b/.changeset/pre/24134-console-pin-5e15928b4631.md deleted file mode 100644 index 8789249bcb1..00000000000 --- a/.changeset/pre/24134-console-pin-5e15928b4631.md +++ /dev/null @@ -1,130 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `5e15928b4631`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 88 releasing of 91 changesets added across 82 non-merge commits; omitted: 3 release-nothing changesets, 2 commits carrying no changeset (they ship no package code). - -- **minor** — **The AI build conversation stays on the conversation and reads more compactly** (objectui#12724). (objectui `ac5907384`) -- **minor** — A section entry written as a bare field name no longer reads the object field's `visibleOn` when sections are normalized (`normalizeSectionField`, used by `ObjectForm`, `ModalForm… (objectui `684f22e84`) -- **minor** — Lists at narrow widths: a status chip is never cut to "Publish…", a table wider than its panel shows that it scrolls, and the Actions column is drawn only when a row offers an act… (objectui `4839f5caf`) -- **minor** — A `list-view` block drawn as a compact list in a narrow page region now shows a "View all" link under its rows (objectui#12703). (objectui `33ee9de4c`) -- **minor** — A list block in a page region narrower than half the page draws a compact list (objectui#12703). (objectui `a8add5c71`) -- **minor** — On a record page, a related list's "View all" opens the child object's list (objectui#12741). The destination is the ADR-0055 bare data surface, `/apps/APP/OBJECT/data?filter[FIEL… (objectui `8df26086d`) -- **minor** — `RelatedRecordHandlers` gains an optional `onViewAll` (objectui#12741). A host returns it from `resolve` to open the child object's full list page, scoped to the parent on the rel… (objectui `8df26086d`) -- **minor** — `record:related_list` now draws the "View all" link its `showViewAll` key declares (objectui#12741, ENFORCE, the objectui half of objectstack-ai/objectstack#23941). (objectui `8df26086d`) -- **minor** — The identity import no longer offers "Update existing (skip if no match)" (objectui#12697). (objectui `3e2c7634b`) -- **minor** — The import wizard offers only the write modes its host says the target accepts (objectui#12697). (objectui `3e2c7634b`) -- **minor** — **BREAKING** — The metadata-admin designer reads and writes the form-view visibility predicate as `visibleWhen` only (objectui#12696). **BREAKING** for a host that still spells the retired `visi… (objectui `236624d56`) -- **minor** — The console page header folds its actions before it cuts the description (objectui#12705). (objectui `80863adea`) -- **minor** — `LoginForm` no longer tells an SSO-enforced environment's users to use a password (objectui#12682). (objectui `9b69e1325`) -- **minor** — The audit log browser (`audit:log`) offers `sys_audit_log`'s list views as presets and links to the object list (objectui#12667, the objectui half of objectstack#23890). (objectui `8296c6c3c`) -- **minor** — App pages lay a region's blocks out on a 12-column grid, and the page description sits under its title (objectui#12624). (objectui `c9e67425e`) -- **minor** — Seven `marketplace.*` keys are removed from all ten language packs: `installedTitle`, `installedSubtitle`, `installedEmpty`, `installedAt`, `installedBy`, `installedPackageId` and… (objectui `9d857c998`) -- **minor** — Setup → Packages is the one list of what is installed: every package carries a Source, and marketplace installs carry their actions on their own row (objectui#12675, ruling B on o… (objectui `9d857c998`) -- **minor** — A settings page's `info_banner` row speaks the session locale, draws a warning in amber, and renders its body as Markdown (objectui#12671, the objectui half of objectstack#23855). (objectui `a2f38ddff`) -- **minor** — **BREAKING** — A page `list-view` node is judged in its `properties` bag, in `@objectstack/spec`'s list view spelling, and the retired `objectName` / `viewType` are refused by name (objectui#126… (objectui `378896da6`) -- **minor** — A navigation entry whose `visible` predicate cannot be evaluated is no longer drawn for everyone (objectui#12700). (objectui `71243db0d`) -- **minor** — New export `isRecordPageOf(page, objectName)` (objectui#12715): true when `page` is a record page bound to `objectName` (`type: 'record'` and `object` names it). It is the one rea… (objectui `c7462e7c8`) -- **minor** — The console renders a record on a NAMED record page (objectui#12715, the objectui layer of the print-page series). (objectui `c7462e7c8`) -- **minor** — Setup draws one page header on every page: the `sys_*` record lists, the metadata-type pages, the settings pages and the component pages. (objectui `c41ce3f24`) -- **minor** — The console's form page (`FormPage`, the `/forms/:name` and public `/f/:slug` routes) reads a form view field's and section's visibility predicate from `visibleWhen` only (objectu… (objectui `611566b4d`) -- **minor** — A form view field's `visibleOn` is no longer read when sections are normalized (`normalizeSectionField`, used by `ObjectForm`, `ModalForm` and the record form page) (objectui#1265… (objectui `611566b4d`) -- **minor** — `SchemaRenderer` no longer reads a page component's `visibility` key (objectui#12654). `@objectstack/spec` 18 retires it as a `retiredKey` tombstone naming `visibleWhen` (objectst… (objectui `611566b4d`) -- **minor** — **BREAKING** — A page `list-view` node is written in `@objectstack/spec`'s list view spelling, `data` and `type`, and a metadata page reads `properties.type` as the list kind (objectstack-ai/obj… (objectui `5c0846ebd`) -- **minor** — Show a write answer's advisory `warnings` to the user after save (objectui#12107). (objectui `6757a932b`) -- **minor** — A list names what an `object-ref` or `recipient-picker` field references, instead of printing its api name (objectui#12613). The Sharing Rules list read `crm_account` and `sales_m… (objectui `278bc29ea`) -- **patch** — Clause-②: no (objectui `5e15928b4`) -- **patch** — The console's startup sends one `get-session` and one `runtime/config` request instead of two of each: the session check and the early-branding request now hand their answers to t… (objectui `5d69b0e73`) -- **patch** — Opening the Cloud pricing or Welcome page now asks for the organisation's environment entitlements once. The plan cards, the onboarding next step and the workspace-timezone notice… (objectui `5d69b0e73`) -- **patch** — `AuthProvider` no longer asks `get-session` a second time when the console has just validated the stored session before rendering. The validated answer becomes the provider's firs… (objectui `5d69b0e73`) -- **patch** — Studio names what it shows by label on two more surfaces (objectui#12693). (objectui `cda4076be`) -- **patch** — `aggregate()` no longer reads a served `null` measure as a missing one. The analytics answer's `null` (the max or min over rows whose field is all NULL) now reaches the caller as… (objectui `35ef75036`) -- **patch** — The kanban board's drag-and-drop tells a screen reader the card's title and the column's label, in the console's language, instead of the record id and the option value (objectui#… (objectui `36e2f7b67`) -- **patch** — fix(app-shell): the Studio Validations editor says why it is read-only, to hover and to the keyboard (objectui#12691) (objectui `d0a00d795`) -- **patch** — The console's create / edit record modal now opens at the size an object's default form view authors in `modalSize` (`sm`, `default`, `lg`, `xl` or `full`). On desktop the authore… (objectui `2ff84f8bf`) -- **patch** — The "N eligible approver(s)" figure beside an approval's decision progress counts people, not slots (objectui#12781). The record page's approvals panel and the approval request pa… (objectui `f3a7b870a`) -- **patch** — fix(app-shell): "Discard draft" on the metadata editor names the item by its label, not its machine id (objectui#12690) (objectui `0f42aaf80`) -- **patch** — The developer Flow Runs page's flow picker names each labelled flow by its label again, on the list a real server serves (objectui#12783). (objectui `b52415010`) -- **patch** — The flow designer's start node offers each field on exactly the triggers the engine reads it for (objectui#12780). (objectui `79270e7c4`) -- **patch** — The app header titles a view stored with no `label` by its object's plural label, the name its tab shows (objectui#12805). (objectui `3288c12f7`) -- **patch** — A link from one doc to another in the docs portal now carries the console's base path (objectui#12686). The portal rewrites a doc body's `[x](./other_doc.md)` cross-reference to t… (objectui `8cf9cf305`) -- **patch** — **`marketplace.installedAdditiveNote` says what uninstall does now, in all ten packs** (objectui#12684). The note on a loaded marketplace row's Uninstall (its tooltip and accessib… (objectui `670ea1041`) -- **patch** — The metadata directory's "View all issues" link no longer paints warnings as errors (objectui#12768). (objectui `4279c2ed2`) -- **patch** — A list view's `date-range` user-filter chip draws the platform date picker on both bounds, and names them in the console's language (objectui#12784). It used to draw two native da… (objectui `99a0d3bf0`) -- **patch** — A settings row's source badge reads in the session's language, verb included (objectui#12694). The badge beside a setting's label said where its value comes from as an English ver… (objectui `ff215d934`) -- **patch** — A record on a print page prints its own title field (objectui#12787). Off a print page, `record:details` hides the row that the page header's H1 already shows: the declared name f… (objectui `6e4f14442`) -- **patch** — The ⌘K command palette closes after every command it runs, including one that does not navigate (objectui#12683). (objectui `3d39ca78c`) -- **patch** — The flow designer, canvas, Debug simulator and read-only flow viewer treat `http` as the HTTP node type, and no longer recognise `http_request`, `http_call` or `webhook` as node t… (objectui `5483297ea`) -- **patch** — `useActionTextLocalizer` resolves every `I18nLabel` copy an action declares (objectui#12757). It used to collapse a per-locale map only for `label` and `successMessage`, and only… (objectui `655e8977d`) -- **patch** — A page's `element:button` shows its inline action's copy in the user's language (objectui#12757). The spec types an inline action's `label`, `confirmText`, `successMessage` and `e… (objectui `655e8977d`) -- **patch** — A new report's or dataset's Label in a non-Latin script no longer names it `field` (objectui#12789). The report and dataset designers derive the Name from the Label until you type… (objectui `0b80f501f`) -- **patch** — A view stored with no `label` is named by its object's plural label on the object page, never by its qualified name (objectui#12774). The spec gives a view's `label` no default, a… (objectui `c4467ccb2`) -- **patch** — The permission set editor's provenance badge decides from `packageId` and the package artifact only (objectui#12753). Spec 18 retired the authored `managedBy` on a permission set… (objectui `c054d018d`) -- **patch** — An SSO sign-in reads in the session's language on both login pages (objectui#12762). The console's login page and app-shell's `DefaultLoginPage` passed `LoginForm` none of its fou… (objectui `8d80845af`) -- **patch** — The object designer no longer offers a "Relationship name" box on lookup, master-detail and tree fields (objectui#12758). (objectui `b275f5804`) -- **patch** — Clause-②: no (objectui `260ef7196`) -- **patch** — fix(components): `page:header` reads the spec's `variant: 'danger'` as destructive (objectui#12764) (objectui `69a408f64`) -- **patch** — In the collapsed (icon-mode) sidebar, a click on an icon reaches its row again. Before, a hidden group label could take the click instead (objectui#12744). (objectui `f1142634e`) -- **patch** — Studio: the "Read-only · Customize with an overlay" link at the end of an item's tab row (an open flow in Automations, an open permission set in Access) now folds to its layers ic… (objectui `2368d7af1`) -- **patch** — A new report's or dataset's Name keeps every underscore you type (objectui#12746). The report and dataset designers' Name box normalized each keystroke with the strict identifier… (objectui `94b2489f4`) -- **patch** — An inline edit of a `datetime` field on the record page keeps its time (objectui#12759). The record details section and the header highlights edited `date` and `datetime` fields w… (objectui `020d1788c`) -- **patch** — A record on a print page no longer prints its approval band: no "Recall approval" button, no approval status badge and no approval tally on paper (objectui#12751). (objectui `6367bfeba`) -- **patch** — List filters on `date` and `datetime` fields draw the platform date picker, the face record forms, action dialogs and grid cells already draw (objectui#12743). The native input to… (objectui `ed43c79ce`) -- **patch** — The flow designer no longer calls a live time-relative sweep "not in effect" (objectui#12672). (objectui `8cc02b444`) -- **patch** — The app bar's breadcrumb folds instead of cutting every segment short (objectui#12702). (objectui `4857cd284`) -- **patch** — Studio's Interfaces rail and the dashboard component list show the app's navigation and the widget titles in the session's language (objectui#12688). (objectui `41c1d9e93`) -- **patch** — The list toolbar draws Filter, Sort and Display as 28 px icon buttons at every width, on every list that mounts it: app object lists, Setup lists and related lists (objectui#12695… (objectui `0171c2494`) -- **patch** — After a reload, the console no longer keeps the previous organization's id once the server says the session has no active organization (objectui#12698). better-auth clears a sessi… (objectui `6e141d999`) -- **patch** — The derived highlight strip on a record page no longer picks a field from a field group that declares a `visibleWhen` (objectui#12720). (objectui `360e27a14`) -- **patch** — Metadata Diagnostics no longer paints a clean sweep as an error (objectui#12721). (objectui `a214fbe2e`) -- **patch** — Browse Marketplace says what it is loading, and a failed catalog read names its class and offers Retry (objectui#12710). (objectui `a23e80464`) -- **patch** — Setup → Packages: the Scope badge now reads the server's `writable` verdict for its writable / read-only half (objectui#12723). A marketplace install has no `scope` and the server… (objectui `8dc313530`) -- **patch** — The grid's inline cell editor and the kanban required-fields dialog draw `date` and `datetime` fields with the platform date picker, the face record forms and the action dialogs a… (objectui `d66d7e834`) -- **patch** — Editing a formula field no longer shows a false "retired `formula` key" error (objectui#12681). (objectui `7ead04904`) -- **patch** — Home answers a `current_user.can()` navigation gate from the caller's permissions (objectui#12700). (objectui `2dbe5f533`) -- **patch** — A period-over-period delta from a zero baseline no longer claims "100%" (objectui#12677). (objectui `0666a66f3`) -- **patch** — A collapsible record details section on a print page prints its header as a plain heading, with no disclosure chevron and no clickable-header chrome (objectui#12732). (objectui `464ae629b`) -- **patch** — Opening a record page sends one approvals read and one record read (objectui#12701). (objectui `e219a3d6c`) -- **patch** — In the metadata designer's create mode, a Name the author typed is no longer replaced when the Label is typed afterwards (objectui#12680). This holds for the object, report and da… (objectui `1d869199c`) -- **patch** — A caller the Setup app refuses gets the access-denied screen on Setup's item pages instead of those pages under another app's chrome (objectui#12706). (objectui `a88c20604`) -- **patch** — Console lists link names that point at another object's record, the way the record page does (objectui#12673). (objectui `b393152f7`) -- **patch** — A record details section on a print page prints every field it declares, with no "Show N empty fields" button on paper (objectui#12717). (objectui `263e9ddcc`) -- **patch** — A form section that declares no `columns` now draws two fields per row from the container's `@md` width (one below it) on every page form host — `ObjectForm`, `SplitForm`, `Tabbed… (objectui `96fa4b9bc`) -- **patch** — Setup's permission-set and position holders offer Add and Remove only to a caller holding the grant the server asks for those writes (objectui#12082). (objectui `fac2c2496`) -- **patch** — The app bar's Edit app pencil now appears only where Studio can edit: for a metadata author, on an app whose package Studio lists as writable. A read-only package's app (such as a… (objectui `40e587342`) - -⚠️ 3 of these carry a breaking change: 3 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 2 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. - -- _(no changeset)_ fix(e2e/live): start-backend.sh records the server's pid, not a subshell's (objectui#12676) (#12737) (objectui `c22068922`) -- _(no changeset)_ chore(scripts): raise every eager-closure ceiling by 20% and move the regression the gate must catch to 695 KiB (objectui#12653) (#12699) (objectui `e6175b57f`) - -Clause-②: no - - - -objectui range: `f8267df93aba...5e15928b4631` diff --git a/.changeset/pre/24137-oauth-token-organization.md b/.changeset/pre/24137-oauth-token-organization.md deleted file mode 100644 index a445b0fa357..00000000000 --- a/.changeset/pre/24137-oauth-token-organization.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): an OAuth MCP agent acts in the organization the user's consent bound, so the user's organization-scoped grants count for it - -Clause-②: no - -- **What was wrong.** On the `/mcp` door, `resolveExecutionContext` built a verified OAuth access token's principal with no organization. The agent's delegator (the user it acts for, ADR-0090 D10) was then resolved with no active organization and held only its global grants. Every grant an administrator assigns is scoped to an organization, so an ordinary user's agent was refused every object operation: `on-behalf-of principal may not 'find' … the delegator lacks that grant (ADR-0090 D10 intersection)`. -- **What changed.** The principal is resolved in the organization the consent bound: the `organizationId` that `IAuthService.verifyMcpAccessToken` returns from the token's `organization_id` claim. It is handed to the shared resolver where a session carries its active organization, so it is vetted on every request by the rule a browser session's organization gets (`vetOrganizationClaim`, `@objectstack/core`). The user's grants scoped to that organization count for the agent; grants scoped to the user's other organizations do not. It is never the union of the user's organizations, never the organization the user's browser session has selected now, and never a widened global grant. -- **A membership that is gone.** Under a walled tenancy posture (`isolated`, `group`), a token whose organization the user no longer belongs to resolves with no organization, so only global grants apply. Under `isolated`, a former member's agent gets an empty `200` read and a refused tenant write. The same holds for an organization the user never belonged to, whatever put it on the token. Under `single`, or with no `tenancy` service, the organization stands as claimed, exactly as a browser session's does there. -- **Unchanged.** A token with no `organization_id` (minted before consents bound one, or by a user with no active organization at consent) resolves with no organization, as before: global grants only, until the user re-confirms the consent once. A platform admin's global grant applies as before. The API-key and session tracks are untouched. diff --git a/.changeset/pre/24140-line-items-columns-hydrate.md b/.changeset/pre/24140-line-items-columns-hydrate.md deleted file mode 100644 index 43078363878..00000000000 --- a/.changeset/pre/24140-line-items-columns-hydrate.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -docs(spec): `record:line_items` `columns` states that an identity-only column hydrates from the child object's field, as on the master-detail carriers, so its heading is the child field's label and translates with it (#24140) - -Clause-②: no - -`RecordLineItemsProps.columns` said that this block does not hydrate a column from the child object's field, so authors had to declare `label`, `type` and `options` on every column, and an identity-only `{ name }` column was headed by its field name. That contradicted the shared column contract the block takes by reference: `InlineGridColumnSchema.label` says it "defaults to the child field's label via hydration". It also left a declared column heading as a plain string that no translation bundle key reaches, so a non-English print of a line-items table read English headings. The describe now states the carriers' rule in the carriers' words. Identity-only entries (`{ name }`) hydrate everything else from the child object's fields, `label`, `type` and `options` included, so the heading is the child field's label, which translates through that field's own translation. Declaring `type` opts a column out of hydration. - -Text only: every input that parsed before parses now, and every refusal is unchanged. The renderer half is objectui's `LineItemsPanel`, which will reuse the same column hydration the master-detail grid uses. Until the console carries it, an identity-only `record:line_items` column still draws as a text cell headed by its field name, so keep explicit labels on columns you need headed today. diff --git a/.changeset/pre/24142-settings-organization-noun.md b/.changeset/pre/24142-settings-organization-noun.md deleted file mode 100644 index 9885acd078b..00000000000 --- a/.changeset/pre/24142-settings-organization-noun.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/service-settings': patch ---- - -Clause-②: no - -fix(service-settings): the Setup settings pages name the organization or the environment where they said "workspace" - -- **What changed.** The built-in settings manifests and their en, zh-CN, ja-JP and es-ES bundles no longer say "workspace" (工作区, ワークスペース, espacio de trabajo). Each string now uses the noun for what the setting belongs to: - - **Organization** (组织 / 組織 / organización): the Branding name field, now **Organization name**, and the Branding description; the Company legal-name help; the Feature Flags description. These settings are stored once per organization. - - **Environment** (环境 / 環境 / entorno): the Provider section of Mail and of SMS. These settings are stored once for the running instance. - - Authentication, New user membership: the help now names the explicit act as **creating an organization**. -- **Unchanged:** every setting key, including `branding.workspace_name` and its `OS_BRANDING_WORKSPACE_NAME` override; stored values; the Settings hub `category` values; the keys and shape of the translation bundles; and the exports. Only the text a reader sees changed. diff --git a/.changeset/pre/24143-export-hidden-columns.md b/.changeset/pre/24143-export-hidden-columns.md deleted file mode 100644 index 301e31f2f8d..00000000000 --- a/.changeset/pre/24143-export-hidden-columns.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest)!: a schema-derived export skips the fields declared `hidden`, so the hidden `__search` search companion no longer adds an empty "Search Index" column - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -`GET /api/v1/data/:object/export` without `?fields=` builds its columns from the object schema. It took every field of the object, so the hidden `__search` companion, which the search-normalization seam declares on every object with an eligible name field whenever pinyin search is on, and whose value the data reads strip from every row, became a "Search Index" column that was empty on every row. The default columns are now the fields the object shows by default: a field declared `hidden` (`FieldSchema.hidden`, "Hidden from default UI") is not one. - -- **Served less now:** the CSV and XLSX default header, and its zero-row header, no longer carry a column for a field declared `hidden`. Measured on a dev boot of `examples/app-showcase`, over every object with a hidden field that the export serves: `Search Index` (`__search`, 64 objects, empty on every row), `Organization` (`organization_id`, 53 objects, the one that carries a value: the record's organization), `Owning Business Unit` (`owning_business_unit_id`, 24 objects, empty on every row because nothing stamps it yet), and `Session Token` (`sys_session`), `Previous Password Hashes` (`sys_account`) and `Hashed Key` (`sys_api_key`), all three empty because the read omits their values. No other column moved. -- **Unchanged:** an explicit `?fields=` list is answered as asked, a hidden field included: the header names it and its cells carry what the read returns (empty for `__search`, which the read strips). The field-level-security narrowing of the default columns is unchanged, and so are the zero-row header rules. `format=json` streams each row as the read returns it, has no column set, and is unchanged. `?template=true` keeps its own column rule. - -What changes for you: a consumer that wants a hidden field's column in an export names the field in `?fields=`. diff --git a/.changeset/pre/24148-cloud-connection-setup-access.md b/.changeset/pre/24148-cloud-connection-setup-access.md deleted file mode 100644 index 8038e4b50f2..00000000000 --- a/.changeset/pre/24148-cloud-connection-setup-access.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/cloud-connection': minor ---- - -fix(cloud-connection)!: `bind/start`, `bind/poll` and `unbind` require `setup.access`, and `GET /status` serves the connection's details only to a caller who holds it - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The Console's Cloud Connection page sits on the Setup app, which requires the Setup Access permission (`setup.access`). The routes behind that page did not ask for it: the three that change this runtime's cloud connection checked only that the caller was signed in, and `GET /api/v1/cloud-connection/status` served the connection record the control plane reports to every caller. Both now follow the page's rule. - -- **Refused now:** `POST /api/v1/cloud-connection/bind/start`, `POST /api/v1/cloud-connection/bind/poll` and `POST /api/v1/cloud-connection/unbind` answer a signed-in caller who does not hold `setup.access` with `403` and the standard `PERMISSION_DENIED` code, and the message names the capability. The refusal comes before any call to the control plane and before the stored credential is touched. What the caller holds is read through `resolveAuthzContext`, the platform's one authorization resolver, for the session these routes already read: a grant bound through a position, or scoped to the session's active organization, counts as it does everywhere else, and an API key sent beside the session lends its owner's grants to nobody. -- **Narrowed now:** `GET /api/v1/cloud-connection/status` still answers every caller `200`, so a probe can still tell that the plugin is mounted. A caller without `setup.access` (anonymous, signed in without it, or a session the ADR-0069 policy holds) is served `bound`, `runtimeId` and `provider` only. `environmentId` and `connection` are served to a caller who holds it. -- **Unchanged:** a caller who holds `setup.access` is served exactly as before, so the Console panel's connected view, its "Approved by" row included, keeps working for an administrator. An anonymous caller of the three connection-changing routes still gets `401`, and a held session still gets the ADR-0069 `403`. The other four session routes (`install`, `installation`, `installed`, `org-packages`) are not touched. -- **An unreadable permission store is not a denial.** When the grants cannot be read, the three connection-changing routes raise the platform's `SERVICE_UNAVAILABLE` outage (`503` through the host's error render) instead of answering a `403` that reads like a real refusal. `status` keeps answering, withholds the details, and logs the outage. - -What changes for you. Whoever manages this runtime's cloud connection needs Setup Access, which the default platform-administrator and organization-administrator permission sets already carry. A script that reads `connection` or `environmentId` from `GET /status` has to call it as a signed-in user who holds Setup Access. diff --git a/.changeset/pre/24150-cloud-connection-runtime-version.md b/.changeset/pre/24150-cloud-connection-runtime-version.md deleted file mode 100644 index e60fe3fcbca..00000000000 --- a/.changeset/pre/24150-cloud-connection-runtime-version.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/cloud-connection': patch ---- - -fix(cloud-connection): with `OS_RUNTIME_VERSION` unset, the server reports to the cloud the release `GET /api/v1/discovery` reports, not the Cloud Connection plugin's own `0.3.0` - -Clause-②: no - -`CloudConnectionPlugin` sends the server's version to the cloud in two places: as `runtime_version` on the approval links `POST /api/v1/cloud-connection/bind/start` returns, which the cloud's approval page shows next to the server's name, and as `runtime_version` in the registration request `POST /api/v1/cloud-connection/bind/poll` sends to the cloud's `/bind`. With `OS_RUNTIME_VERSION` unset, both carried the plugin's own `version` field, `0.3.0`. That string names the plugin, not the release the server runs, so an approver was shown `0.3.0` while discovery on the same server reported `18.0.0-next.*`. - -- **Unset now:** both places carry the `version` the server's discovery document reports. The plugin reads it from the discovery document itself, through the `protocol` service, each time it builds the approval links or the registration request, so the two answers cannot drift. -- **Set:** a set `OS_RUNTIME_VERSION` is sent verbatim, as before. -- **No discovery document:** on a server with no `protocol` service, or when reading it fails, neither place carries a version. The approval links have no `runtime_version` parameter and the registration request has no `runtime_version` key. Before, both carried `0.3.0`. - -What changes for you: nothing to rewrite. `OS_RUNTIME_VERSION` is now only needed to report a version other than the release, such as an image tag or a commit. diff --git a/.changeset/pre/24151-body-after-anonymous-floor.md b/.changeset/pre/24151-body-after-anonymous-floor.md deleted file mode 100644 index 3d0ccce023d..00000000000 --- a/.changeset/pre/24151-body-after-anonymous-floor.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): on the standalone server, an anonymous request whose JSON body cannot be parsed gets the domain's `401`, not the transport's `400` - -Clause-②: no - -On the standalone server (`os dev`, `os serve`: `HonoServerPlugin` with the dispatcher plugin), the routes the dispatcher mounts read the request body before handing the request to `dispatch()`. The transport refuses a body it cannot parse when the body is read, so the refusal answered first: an anonymous `POST /api/v1/keys` with `{"name": "probe"` (the closing brace missing) got `400 INVALID_REQUEST`, while the same request with a well-formed body got the promised `401 UNAUTHENTICATED`. The anonymous answer depended on whether the body parsed. - -The mounts now hand the body on unread, and each domain reads it once its anonymous decision has admitted the caller. An anonymous caller gets the same answer whatever its body, on every mounted route that reads one, unscoped and `/environments/:environmentId`-scoped: - -- `401 UNAUTHENTICATED` on `POST /analytics/query` and `/analytics/sql`; `/keys` (every verb); `POST /notifications/read` and `/notifications/read/all`; `POST /automation`, `PUT /automation/:name` and the two `trigger` doors, `toggle`, `clone`, `resume`, `cancel` and `restore-suspension`; the three `POST /actions/...` shapes; the `/ai/*` routes; the `/packages` routes; `POST /security/_activation/:type/:name` and `POST /security/permission-sets/:name/clone`. -- `/mcp` answers its own `401`, or, ahead of it, its `404` when MCP is switched off and its `501` when no MCP service is installed. None of the three reads the body, so a signed-in caller gets the `404` or `501` too. -- `/mcp/skill` is public and reads no body: a `POST` or `DELETE` gets its own `405` + `Allow: GET`, signed in or not. - -Otherwise a signed-in caller with an unparseable body still gets the transport's `400 INVALID_REQUEST`: the body is read after the anonymous decision, not dropped. A `createHonoApp` host (`@objectstack/hono`) is unchanged; it already raised the refusal only when a domain read the body. diff --git a/.changeset/pre/24154-invitation-role-label.md b/.changeset/pre/24154-invitation-role-label.md deleted file mode 100644 index 7f9cfb3f91c..00000000000 --- a/.changeset/pre/24154-invitation-role-label.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-auth': minor -'@objectstack/plugin-email': patch ---- - -fix(plugin-auth,plugin-email): the organization invitation mail names the invitee's role by its label in the mail's language (`成员`, `メンバー`, `Miembro`, `Member`), not by the stored value `member` - -Clause-②: yes (widening) - -- **What changed.** The zh-CN, ja-JP and es-ES invitation mails printed the stored membership role inside a translated sentence: 「邀请您以 member 的身份加入」. The sender now passes `{{role}}` as the `sys_member.role` option label in the locale the mail is written in, the same label the console shows for that option. It reads the label through the runtime's i18n service and the console's own object translation path, so a tenant's translation of the option, made in Studio, reaches the mail too. The four bodies keep interpolating the one `role` variable; its name and type are unchanged, and its declaration now says it carries the label. -- **The plain-text part names the role too.** The four text bodies gain the role clause their HTML bodies carry, so both parts of the mail name the same facts. For example, the English text is now `Dana (dana@example.com) invited you to join Northwind on Acme as Member.` -- **Stated fallbacks.** A role list (`admin,member`) is labelled role by role and joined as a list in the mail's language (`管理员和成员`). A value with no option label is named as stored, and one warning names it. With no i18n service, or one that fails, the mail names the option's authored English label, never an empty string. -- **New option (widening).** `AuthManagerOptions.getI18n?: () => II18nService | undefined` is a lazy accessor for the `i18n` service, read when the invitation is sent. `AuthPlugin` wires it from the kernel. An embedder that builds `AuthManager` itself and omits it gets the authored labels. -- **What changes for you.** If you have overridden `auth.invitation` and interpolate `{{role}}`, it now receives the label, not the stored value. diff --git a/.changeset/pre/24162-record-json-retired.md b/.changeset/pre/24162-record-json-retired.md deleted file mode 100644 index 5034714aa51..00000000000 --- a/.changeset/pre/24162-record-json-retired.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: `record:json` is retired — refused by name at the schema door; the approval request page's raw JSON panel it was declared for was dropped (#24162, ADR-0049) - -Clause-②: no (narrowing) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings (the `user:profile`, `element:filter`, `element:form` and `ai:chat_window` retirements shipped the same way). - -`record:json` was added to `PageComponentType` in `18.0.0-next.3`, with a strict `ComponentPropsMap` row, `RecordJsonProps` (`field`, `label`, `requiredPermissions`). It was declared spec first for one face: the approval request page's raw JSON panel. The maintainer then dropped that panel — the person deciding an approval has no business need to read the request as JSON — so no renderer was built and no page placed the type: zero consumers in objectstack and objectui, and its one planned producer was closed unmerged. A declared element that nothing draws is a capability claim nothing keeps, so it is retired under ADR-0049 enforce-or-remove, refused by name, the `ai:chat_window` way. The `18.0.0-next.3` entry below records what that prerelease shipped; this entry is its retirement. - -**What is refused:** an authored `record:json` component node, at `PageComponentSchema.type`. That covers `definePage()`, `PageSchema`, and every door that parses pages: `os validate`, `os build`, `os lint` and the metadata save door. The issue is located at the node's own path, with `code: 'custom'` and `params.retiredComponentType`, and its message is the retirement prescription. `PageComponentType`'s own error map refuses the name with the same text when the enum is parsed alone. `ComponentPropsMap['record:json']` stays as a row, so every reader that dispatches on it keeps recognising the name: the component-props gate, `check-yaml-examples` and the type vocabulary's known set. The row now refuses every props bag, `{}` included, with the same prescription. One prescription string, `RETIRED_PAGE_COMPONENT_TYPES` in `@objectstack/spec/ui`, answers at all three doors. The authoring rule `component-type-unknown` reports the node by name, and stops suggesting `record:json` for a misspelling in the `record:` namespace. - -**What is removed from the exports:** `RecordJsonProps` (`@objectstack/spec/ui`), the props schema the element no longer has. Its JSON Schema (`ui/RecordJsonProps`) is no longer published. Its print classification row (`PRINT_REFUSED_PAGE_COMPONENT_TYPES`) leaves with it: a retired type is refused at the parse before any print rule reads it. - -**What stays accepted:** every other member of `PageComponentType` and `ComponentPropsMap`, byte-identically, including the four record blocks' own `requiredPermissions` gate, which `record:json` never changed. The open string arm also stays open: custom and plugin-registered types keep parsing. The only string refused is the retired name itself. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| a `{ type: 'record:json' }` component node in a page region, slot or container | nothing: delete the node. No replacement panel exists, by ruling | -| `properties: { field, label, requiredPermissions }` on that node | nothing: none of them was ever read | - -The one-line fix: delete the `record:json` component node. No ADR-0087 conversion is registered, because the only edit is deleting an authored page node, and a mechanical conversion does not delete page nodes: which region closes up is a layout decision. The D3 entry `ui-record-json-retired` carries that delegation, so `os migrate meta --from 17` lists it as a manual change for every stack that still names the type. - -## Who is affected, measured - -- **objectstack** at `eda6f14b7b`: zero authored `record:json` nodes in `examples/**`, `packages/apps/**`, `apps/**`, `skills/**` and `content/docs/**` code samples. The only hits were the spec's own type list, row and print row, its tests, the lint accept pin and the generated reference docs. A tree-scoped absence pin now holds that zero. -- **objectui** `main`: zero hits (the card's census); the `.objectui-sha` pin is not moved. -- **Deployed metadata** was not measured; the type was published in one prerelease only. - -The retirement kit: - -- the retired-type map entry and the enum value removed (`packages/spec/src/ui/page.zod.ts`), its print classification row removed, and the row turned into a whole-bag refusal, with `RecordJsonProps` removed (`packages/spec/src/ui/component.zod.ts`) -- the D3 semantic entry `ui-record-json-retired`, its step-18 rationale fragment, and the `RETIRED_DEFS_BY_MAJOR` entry `ui/RecordJsonProps` -- pin tests: `component-record-json-retirement.test.ts` pins `code`, `path`, `params` and the first sentence at each door (the map, the row with empty and populated bags, `PageComponentSchema`, `PageSchema` at the element path, the enum error map), with `record:approvals` as the live control, plus a tree-scoped absence walk; `component-type-vocabulary.test.ts` pins the type known, out of the typo candidates, and `record:` still reserved; the lint accept pin `validate-record-json-23745.test.ts` is flipped into a refusal pin at both lint rules and the shared pipeline -- generated baselines and docs follow the schema: `api-surface/`, `export-origins/`, `declaration-map/`, `authorable-surface/`, `json-schema.manifest/`, the strictness-ledger counts, `spec-changes.json`, the upgrade guide and the reference docs diff --git a/.changeset/pre/24165-mcp-token-organization-id.md b/.changeset/pre/24165-mcp-token-organization-id.md deleted file mode 100644 index d7e895c05a6..00000000000 --- a/.changeset/pre/24165-mcp-token-organization-id.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec/contracts): `IAuthService.verifyMcpAccessToken`'s verified result gains an optional `organizationId`, the organization the user's OAuth consent bound - -Clause-②: yes (widening) - -- **What changed.** The verified result of `verifyMcpAccessToken` (`@objectstack/spec/contracts`) is now `{ userId: string; scopes: string[]; clientId?: string; organizationId?: string }`. `organizationId` is the organization the user's OAuth consent bound (the user's current organization at the moment of consent), read from the access token's `organization_id` claim. -- **Absent** when no consent bound one: a token minted before consents bound an organization. Absent means no organization, so only the user's global grants apply. -- **A claim, not an authorization.** A caller vets it on every request by the rule that vets a session's active organization (`vetOrganizationClaim`, `@objectstack/core`). Once no current membership backs it, the principal resolves with no organization (global grants only: tenant reads come back empty and tenant writes are refused), never in another organization. The token acts in that one organization only: never the union of the user's organizations, and never the organization the user's browser session has selected now. -- **Type only, and additive.** The member is optional, so an implementation that returns no `organizationId` still satisfies the contract, and nothing this package does at runtime changes. This package declares the member only: writing the claim at consent is the auth plugin's, and reading it into the OAuth session is the runtime's. diff --git a/.changeset/pre/24166-oauth-consent-organization.md b/.changeset/pre/24166-oauth-consent-organization.md deleted file mode 100644 index 2fecf7fd87b..00000000000 --- a/.changeset/pre/24166-oauth-consent-organization.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/plugin-auth': minor -'@objectstack/platform-objects': patch ---- - -feat(plugin-auth): an OAuth consent binds the consenting user's active organization, the access token carries it as `organization_id`, and `verifyMcpAccessToken` returns it as `organizationId` - -Clause-②: yes (widening) - -- **What changed.** When a user consents to an OAuth client, the embedded authorization server records the consenting session's active organization as the consent's `reference_id`. The value rides the authorization code onto the refresh family it starts (`sys_oauth_refresh_token.reference_id`), and every access token minted from either carries it as the `organization_id` claim. It is written through the installed `@better-auth/oauth-provider`'s own seams (`postLogin.consentReferenceId` and `customAccessTokenClaims`); no new configuration key or environment variable. -- **The session's organization, never the request's.** The value is read from the consenting session only. Nothing in the authorize request, the consent body or the client's registration can choose it. -- **No organization, no claim.** A user with no active organization at consent gets a consent with no reference, and tokens with no `organization_id`. There is no guessed or default organization; such a token's principal resolves with global grants only. -- **A refresh keeps the consent's organization.** A refresh token mints access tokens in the organization its consent bound, even after the user switches the browser session to another organization. Authorizing again while another organization is active asks for a second consent, for that organization. -- **Existing consents re-confirm once.** A consent stored before this change has no reference, so the next authorization made while the user has an active organization shows the consent page again, once. Until the user re-confirms, a refresh family started before this change keeps minting tokens with no `organization_id`, so they carry global grants only. -- **`AuthManager.verifyMcpAccessToken`.** Its result type gains `organizationId?: string`, matching `IAuthService.verifyMcpAccessToken` in `@objectstack/spec/contracts`, and it returns the token's `organization_id` claim there. A token whose `organization_id` is present but is not a non-empty string is refused, as every other malformed token is: this server never mints one. The organization is a record, not an authorization. The caller vets it on every request by the rule that vets a session's active organization (`vetOrganizationClaim`, `@objectstack/core`). -- **`@objectstack/platform-objects`.** The `reference_id` field description on `sys_oauth_consent`, `sys_oauth_refresh_token` and `sys_oauth_access_token` now names the bound organization, instead of "Caller-supplied correlation identifier". Prose only; no field, type or schema change. diff --git a/.changeset/pre/24179-step18-rationale-tracker-ids.md b/.changeset/pre/24179-step18-rationale-tracker-ids.md deleted file mode 100644 index 30866217212..00000000000 --- a/.changeset/pre/24179-step18-rationale-tracker-ids.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Clause-②: no - -fix(spec): the protocol 17 → 18 upgrade rationale states the element data-binding ruling in words instead of a tracker id - -- **What was wrong.** Two sentences of step 18's rationale, the one `os migrate meta` prints for the hop and the upgrade guide renders, cited their decision as a tracker number and a ruling letter. The sentence on the element layer's flat data-binding keys and the one on `object-grid`'s `defaultFilters` each carried that parenthetical, which tells an upgrading author nothing. -- **What changes.** Each parenthetical now says what was decided, with the ruling's date: the element layer loses its second data door while the `object-*` blocks keep their native keys, and `defaultFilters` retires together with the element layer's flat binding keys, in the shape of the `defaultSort` retirement. The rest of both sentences, every other fragment, and every conversion and migration entry are unchanged. diff --git a/.changeset/pre/24182-extender-withdrawal-restores-owner.md b/.changeset/pre/24182-extender-withdrawal-restores-owner.md deleted file mode 100644 index d31c4f454ef..00000000000 --- a/.changeset/pre/24182-extender-withdrawal-restores-owner.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/objectql': patch -'@objectstack/cloud-connection': patch ---- - -Clause-②: no - -fix(objectql): withdrawing a package that extends another package's object puts the owner's definition back at `GET /api/v1/meta/object/NAME` at once, not at the next restart - -- **What was wrong.** When a package whose content is an `objectExtensions` field on another package's object is registered on a started kernel, the ObjectQL bridge rewrites the OWNER's copy of that object in the `metadata` service with the merged body, extension included. `DELETE /api/v1/marketplace/install-local/:manifestId` of the extender withdrew its contribution from the registry, so the object listing and the data plane dropped the field, but nothing rewrote the owner's copy. The by-name read asks that copy first, so `GET /api/v1/meta/object/NAME` kept serving the withdrawn field until the runtime restarted. -- **What changes.** The ObjectQL bridge remembers which owners' copies each package's extensions rewrote, and the `manifest` service gains `restoreExtendedObjectCopies(packageId)`: once the registry no longer holds the package, it re-registers each such copy with the registry's current definition. The install-local DELETE calls it right after the registry withdrawal. The by-name read then serves the owner's body without the extension's fields, as the listing and the data plane already did. -- **Not touched.** Only a copy stamped with the registry owner's id is rewritten, the same rule the bridge overwrites by, so an authored copy stays. The DELETE answer is unchanged. A copy that cannot be restored is logged once at `error`, naming the objects and the remedy: restart the runtime. diff --git a/.changeset/pre/24183-export-json-fields.md b/.changeset/pre/24183-export-json-fields.md deleted file mode 100644 index 7c4b9b0b6a3..00000000000 --- a/.changeset/pre/24183-export-json-fields.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest)!: `format=json` honours an explicit `?fields=`, so each exported row carries exactly the named keys, in list order - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -`GET /api/v1/data/:object/export` declares `fields=a,b,c` for every format. The CSV and XLSX arms wrote exactly the listed columns, but the JSON arm wrote each row as the read returned it, so `?format=json&fields=…` was ignored without any sign. objectui's list-view and grid export send `format=json` with the view's visible columns, so a JSON export of a list view carried every readable field instead of the view's columns. - -- **Served less now:** with `?fields=`, each JSON row carries exactly the named keys, in the order the list names them, with the same readable formatting as before. Measured on a dev boot of `examples/app-showcase`: `?format=json&fields=title` on `showcase_task` answered 25 keys per row (`id`, the audit and ownership columns, `organization_id`, `owning_business_unit_id` and every author field) and now answers `title` alone. A named key the read did not return (a field that field-level security removes from the row, or the `__search` companion the read strips) is absent from the JSON row. It is not written as `null`. The CSV and XLSX arms write an empty cell for it, as before. -- **Unchanged:** `format=json` without `?fields=` still writes each row as the read returns it. The CSV and XLSX arms, their default columns and their header rules are untouched. - -What changes for you: a caller that sends `format=json` with `fields` and reads a key it did not name, such as `id`, adds that key to `fields`. diff --git a/.changeset/pre/24186-mcp-verifier-keyring.md b/.changeset/pre/24186-mcp-verifier-keyring.md deleted file mode 100644 index 200b107982b..00000000000 --- a/.changeset/pre/24186-mcp-verifier-keyring.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth): the MCP access-token verifier follows the signing keyring instead of holding its own copy of the key set for five minutes - -Clause-②: no - -- **What changed.** `AuthManager.verifyMcpAccessToken` no longer keeps its own copy of the signing key set for five minutes. It reads the key set on every call through the same keyring reader the token signer and `/jwks` use, so the set it checks a signature against is held only under both halves of the cross-request caching rule: a `sys_jwks` write that passes this process's data engine retires it in the same operation, and a 30-second bound covers a write the process cannot see, such as one made on another node. -- **A token signed by a key the set does not carry gets one fresh read.** Before such a token is refused, the keyring is read once more from the table, and the token is checked again. That read is shared by every token that arrives while it is in flight, and a new one starts no sooner than 30 seconds after the previous one, so tokens that name keys which do not exist cost at most one keyring read per 30 seconds. A token whose key is still absent after that read is refused, as before. -- **Unchanged.** Issuer, audience, expiry, the `client_credentials` refusal and the `organization_id` claim are checked exactly as before. Only the public key set `/jwks` publishes is used for verification. No export, configuration key or environment variable is added. diff --git a/.changeset/pre/24187-docblock-mechanisms.md b/.changeset/pre/24187-docblock-mechanisms.md deleted file mode 100644 index bdad0148c14..00000000000 --- a/.changeset/pre/24187-docblock-mechanisms.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Clause-②: no - -The `ActionParam` `options` docblock in `src/ui/action.zod.ts` no longer quotes objectui's inline -param branch as `options: param.options`. At the console pin (`.objectui-sha` `5e15928b4631`) that -branch of `resolveActionParam` reads `options: authoredOptions`, where `authoredOptions` is -`resolveOptionLabels(param.options, ctx.locale)`: the authored array itself when every `label` is -already a string, and otherwise only the entries with an inline per-locale `label` re-spread with -that label resolved. The docblock's point is unchanged: an inline param's options, `visibleWhen` -included, reach the widget as authored. No key, schema, export, accepted value or message text -changes. diff --git a/.changeset/pre/24190-line-items-hydrated-check.md b/.changeset/pre/24190-line-items-hydrated-check.md deleted file mode 100644 index 25067a8a53e..00000000000 --- a/.changeset/pre/24190-line-items-hydrated-check.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec)!: `defineStack` judges a `record:line_items` column that declares no `type` as the type it renders, as it already does on the master-detail carriers (#24190) - -Clause-②: yes (narrowing) - - - -**BREAKING** — an accept-set narrowing of what `defineStack` and `objectstack validate` publish, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The console's `record:line_items` panel fills a column that declares no `type` from the child object's field, with the same rule as the master-detail grid. `defineStack`'s cross-reference check judged that resolved type on the three master-detail carriers and not on this block, so the same column got two answers. - -**`@objectstack/spec`** - -- **`defineStack`'s cross-reference check** now reaches a `record:line_items` block in a page region's components, in a container's component children, in a slot and, since #24349, in a `page:tabs` or `page:accordion` item's `children`. It judges a column that declares no `type` as the type it renders, and reports the column schema's own refusal (`STACK_CROSS_REFERENCE_INVALID`, 422). Today that means `scale` on an identity-only column over a `currency` field of the child object. The child object is the one the panel lists: the object `dataSource` binding's `object` when the node carries one, else `childObject`. A child object the stack does not declare, a column naming no field of it, and a column the column schema refuses on its own (left to the component-props gate) are not judged. -- The step-18 migration entry `ui-record-line-items-props-closed` now says so, and no longer says that this block draws a column exactly as declared. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `{ type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'amount', scale: 2 }] } }` where `amount` is a `currency` field of `invoice_line` | the same block with `columns: [{ name: 'amount' }]` | - -The one-line fix: delete `scale` from a `record:line_items` column that renders as a currency column, whether it declares `type: 'currency'` or takes it from a `currency` child field. Nothing replaces `scale` there: the currency's ISO 4217 minor unit decides the displayed decimals. - -## Who is affected, measured - -On `origin/main` `ec749fbb44`: two authored `record:line_items` blocks in the examples (the showcase project detail page, five typed columns, its block under a `page:tabs` item; the showcase invoice document page, four `{ name, label }` columns, two of them over `currency` fields) and one in the published skills (four `{ name }` columns). None carries `scale`, so no example, skill or fixture outside this change's own pins changes verdict. Deployed metadata was not measured. diff --git a/.changeset/pre/24191-serve-mcp-stdio-early-initialize.md b/.changeset/pre/24191-serve-mcp-stdio-early-initialize.md deleted file mode 100644 index a9bc1a7b4c0..00000000000 --- a/.changeset/pre/24191-serve-mcp-stdio-early-initialize.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -Clause-②: no - -fix(cli): `os serve` with the stdio MCP transport and no config argument answers the `initialize` an MCP client writes when it spawns the server, and boots the configured app - -- **What was wrong.** An MCP client writes `initialize` to the server's stdin as soon as it spawns it. The CLI's argument parser fills an omitted positional argument from a pending stdin line, and `os serve`'s config path is an optional positional. So, started without a config path, `os serve` took the client's `initialize` as its config path. In a project with a compiled `dist/objectstack.json`, it printed `No objectstack.config.ts found — booting from artifact`, booted without the config's own plugins, and never answered `initialize`. `os start` already named the path when it started `os serve`; this is the same defect for `os serve` started directly. -- **What changed.** `os serve` no longer reads its config path from stdin. Without the argument it uses `objectstack.config.ts` in the working directory, as its help says, and the client's first message stays in the pipe for the stdio transport. Reading the config path from stdin was never documented, and no script, test or doc in this repository relied on it; to serve another config, pass its path as the argument. -- **Unchanged:** the flags and environment variables of `os serve`, and which config and artifact a boot uses when the path is given or when nothing waits on stdin. diff --git a/.changeset/pre/24192-start-compile-stdout-purity.md b/.changeset/pre/24192-start-compile-stdout-purity.md deleted file mode 100644 index 4c87160b6dc..00000000000 --- a/.changeset/pre/24192-start-compile-stdout-purity.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -Clause-②: no - -fix(cli): on a first run, `os start` writes its compile step's progress to stderr, so stdout carries only the stdio MCP transport's JSON-RPC frames - -- **What was wrong.** With `OS_MCP_STDIO_ENABLED=true`, stdout is the stdio MCP transport's protocol channel, and an MCP client parses every line it reads there as JSON-RPC. `os start` already writes its own banner and progress to stderr, but in a project with no `dist/objectstack.json` it compiles the config first, and that compile step wrote its progress (`◆ Compile`, `✓ Build complete`, `Artifact: …`, 24 lines for a one-object app) to stdout, ahead of the answer to the client's `initialize`. -- **What changed.** The automatic compile step's output now goes to stderr, next to the rest of `os start`'s own output, with or without the stdio transport. Nothing is dropped: the same lines still print, on stderr. -- **Unchanged:** the flags and environment variables of `os start` and `os compile`, when `os start` compiles, the artifact it writes, and `os compile`'s own output when you run it directly. diff --git a/.changeset/pre/24195-page-template-entry-reason.md b/.changeset/pre/24195-page-template-entry-reason.md deleted file mode 100644 index 808e351411c..00000000000 --- a/.changeset/pre/24195-page-template-entry-reason.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The protocol 18 migration `page-template-closed-region-unplaced-refused` now says what the console shows for a page row stored before the refusal. Its reason used to say such a row "renders as before"; on the console that ships with 18 it does not. - -Clause-②: no - -A stored page row with an unregistered template or an unplaced region is still served unchanged, and saving it again is still refused until it is renamed. Until then the console draws a refusal in its place. An unregistered template draws no layout, only a refusal that names the template and the registered templates. On a `default`, `header-sidebar-main` or `three-column` page, an unplaced region is not drawn and is not moved into another slot. Where it used to be stacked, under `main`, a refusal names it and the slots that template places. A page that names no template takes its page type's layout, which still stacks such a region under `main`. - -The entry's acceptance criteria are unchanged: rename the template or region, and each renamed region draws in its slot. diff --git a/.changeset/pre/24196-auth-session-api-declared-fields.md b/.changeset/pre/24196-auth-session-api-declared-fields.md deleted file mode 100644 index bf4a7b1462a..00000000000 --- a/.changeset/pre/24196-auth-session-api-declared-fields.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/core": patch -"@objectstack/service-print": patch ---- - -feat(spec): `AuthSessionApi.getSession`'s result declares the fields its in-process readers read - -Clause-②: yes (widening) - -- **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains five optional members**, each declared for a named reader and typed as that reader takes it: `user.email` and `session.id` / `session.token` (read by `resolveAuthzContext`'s session arm in `@objectstack/core`), `user.authGate` as `unknown` (the ADR-0069 posture, which `normalizeAuthGate` validates against the closed posture vocabulary), and `session.impersonatedBy` as `string | null` (read by the print door's `impersonatorOf` in `@objectstack/service-print`). Before this, those reads went through `any` or a cast, so a field better-auth dropped or renamed compiled and read `undefined`. For `impersonatedBy`, that would have silently turned off the impersonation refusal. Every member is optional, so every implementer and caller typechecks unchanged. -- **`@objectstack/core`**: `resolveAuthzContext`'s session arm reads the session through the declared type instead of `any`. `ResolveAuthzInput.getSession`'s own type is unchanged. Runtime behaviour is unchanged. -- **`@objectstack/service-print`**: `impersonatorOf` reads `session.impersonatedBy` through the declared type instead of a cast. Runtime behaviour is unchanged. diff --git a/.changeset/pre/24197-formula-traversal-subject.md b/.changeset/pre/24197-formula-traversal-subject.md deleted file mode 100644 index 16ce86edde1..00000000000 --- a/.changeset/pre/24197-formula-traversal-subject.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/formula': minor ---- - -feat(formula)!: `validateExpression` judges relationship traversal in the `value` role when the caller opts in, with formula wording - -Clause-②: yes (narrowing) - - - -- `validateExpression('value', source, { fieldTypes, traversalHydration: true, … })` now refuses a second hop through a reference (`traversal-multi-hop`) and a reference read both through and as a plain value (`traversal-bare-and-traversed`). The messages say the formula reads null, and the repair for an empty reference is the optional read `record.LOOKUP.?FIELD.orValue('…')`. Before, the `value` role ignored the flag. -- `findTraversalConflicts` takes an optional fourth argument, `subject` (`'predicate'`, the default, or `'formula'`), which selects that wording. The `TraversalSubject` type is exported. - -Without the flag, every role behaves as before. diff --git a/.changeset/pre/24197-lint-formula-one-hop.md b/.changeset/pre/24197-lint-formula-one-hop.md deleted file mode 100644 index 31070573e94..00000000000 --- a/.changeset/pre/24197-lint-formula-one-hop.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -feat(lint)!: a formula field's `expression` is judged on the one-hop line — one hop through a reference passes, a second hop is refused - -Clause-②: no (narrowing) - - - -The field-formula pass now passes `traversalHydration` for a formula `expression`, because the engine's formula read path loads one hop through a reference field. - -- `record.book.title + ' · ' + record.reader.name` passes, as before. It now also computes at run time. -- `record.book.shelf.code` (two hops) is refused. The refusal names the field and says a formula resolves one hop. -- `record.reader == null ? '—' : record.reader.name` (the reference read both through and as a plain value) is refused. The refusal names `record.reader.?name.orValue('—')`, the read that gives an empty reference a value. - -What to do. Read a field stored on the related object instead of a second hop, and replace a null test on a reference with the optional read. diff --git a/.changeset/pre/24197-objectql-formula-one-hop.md b/.changeset/pre/24197-objectql-formula-one-hop.md deleted file mode 100644 index 7fa958b1118..00000000000 --- a/.changeset/pre/24197-objectql-formula-one-hop.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -feat(objectql)!: a formula field reads one hop through a reference field — `record.book.title` on a loan now reads the book's title instead of `null` - -Clause-②: no (narrowing) - - - -The read path (`find`, `findOne`, and the record a create or update hands back) now preloads the related fields a formula names ONE hop through a lookup or master-detail field, before it computes the formula. A junction object's composed title, `record.book.title + ' · ' + record.reader.name`, shows both names. - -- **How it reads.** One batched read per reference field per page of rows, never one per row, naming only `id` and the related fields the formulas name. The loader is the one validation rules already use for `record.LOOKUP.FIELD`. The row a read returns still holds the foreign key; only the formula sees the related record. -- **As whom.** The hop is read under the reader's own permissions (row-level and field-level security, the organization wall). A related record the reader may not see reads as not found, and a related field it may not see reads as empty, so a formula never shows the reader a value they could not read directly. -- **When there is no related record.** A reference to a record that is not found, or that the reader may not read, leaves the formula null and reports why, naming the related object and field. An empty reference is evaluated: write `record.reader.?name.orValue('—')` to give the formula a value while the reference is empty. -- **One hop only.** A second hop (`record.book.shelf.code`) and a reference read both through and as a plain value are not evaluated: the field reads null and the reported reason uses the same wording the lint uses. A formula on the related object that reads a hop of its own reads null on a hop read, so two objects whose titles read each other terminate. -- **Sorting and search.** A formula value is stored nowhere, so a formula title still cannot be sorted by (`400 INVALID_SORT`) or searched, as before. - -What to do. Nothing for a formula that reads its own fields. A formula that reads through a reference now shows a value. One that reads two hops should read a field stored on the related object instead. diff --git a/.changeset/pre/24197-objectql-serve-formula-field.md b/.changeset/pre/24197-objectql-serve-formula-field.md deleted file mode 100644 index 7ac59e48f79..00000000000 --- a/.changeset/pre/24197-objectql-serve-formula-field.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -feat(objectql): evaluate a formula field over a record in hand the way a read serves it, one hop through a reference included - -A formula title over lookups (`record.book.title + ' · ' + record.reader.name`) is computed on every read since the formula read path reads one hop. A record no read returned, such as the state a hook fires on, had no way to get the same value: the synchronous `evaluateFormulaField` reads no hop, so it answered `null`. - -- **`ScopedContext.serveFormulaField(objectName, record, field)`** evaluates one declared formula field over `record` with the read path's own evaluation, under that context: the one hop it reads through a reference (one batched read per reference field, through the reader's own permissions, on the context's transaction when it carries one), the `can()` permission map, and the fault report. A related record the reader may not read, a missing one and a second hop leave the field `null` with the read's reason, as a read does. `ObjectQL.serveFormulaField(objectName, record, field, context)` is the engine method behind it. -- **`resolveServedRecordTitle(schema, record, channel)`** is the async sibling of `resolveRecordTitle`: this record's title, with a formula title served on `channel` (a `ScopedContext`). `resolveRecordTitle` is unchanged and still reads no hop. -- **`servedRecordTitle(schema, row)`** reads the title a read already served off the row it returned, instead of re-deriving it. - -What to do. Nothing. Code that names a record it holds in hand and wants the title a read shows calls `resolveServedRecordTitle` with the caller's `ScopedContext`. diff --git a/.changeset/pre/24197-runtime-hook-title-one-hop.md b/.changeset/pre/24197-runtime-hook-title-one-hop.md deleted file mode 100644 index bddeebf7d6c..00000000000 --- a/.changeset/pre/24197-runtime-hook-title-one-hop.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): `ctx.title()` in a hook body answers a title formula that reads through a lookup, as a read of the record shows it - -A junction object's composed title, `record.book.title + ' · ' + record.reader.name`, is shown by every read of the record, but a hook body's `ctx.title()` answered `null` for it, and `ctx.title('')` answered `null` for a related record whose own title reads through a lookup. - -- **`ctx.title()`** now reads the one hop the title formula reads, through the body's own `ctx.api` channel: one read per lookup the title reads through, with the caller's permissions, on the transaction the body has open. It follows the lookup's value in this write, so a `beforeInsert` names the payload's book and a `beforeUpdate` that changes `book` names the new book. A title over the record's own columns still costs no read. -- **`ctx.title('')`** returns the title its `findOne` served, the related record's own one-hop title included, instead of re-computing it without the hop. -- A related record the caller may not read leaves the title `null`, as the read of the same record shows no title to that caller. It is never a partial title and never the hidden name. A title that would need a second hop is `null`, as it is on a read. - -What to do. Nothing. A hook that composed a junction object's title by hand can call `ctx.title()` instead. diff --git a/.changeset/pre/24197-spec-blueprint-one-hop-title.md b/.changeset/pre/24197-spec-blueprint-one-hop-title.md deleted file mode 100644 index b7ccfdb8290..00000000000 --- a/.changeset/pre/24197-spec-blueprint-one-hop-title.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -docs(spec): the AI blueprint schema's composed-title example reads the lookup's name one hop through it - -The `expression` and `nameField` descriptions on `BlueprintFieldSchema` / `BlueprintObjectSchema` and their strict twins gave `"record.order_no + ' · ' + record.customer"` and `"{order_no} · {customer}"` as a composed title. `customer` is a lookup, so those compose the stored id into the title. They now give `"record.order_no + ' · ' + record.customer.name"` and say that a formula reads a lookup's related record one hop through it, that a second hop is refused, and that a formula title is shown everywhere but cannot be sorted by or searched. No schema shape changes. diff --git a/.changeset/pre/24198-blueprint-option-kind-stage-field.md b/.changeset/pre/24198-blueprint-option-kind-stage-field.md deleted file mode 100644 index 65448bd474a..00000000000 --- a/.changeset/pre/24198-blueprint-option-kind-stage-field.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec/ai): a solution blueprint's select options declare their lifecycle `kind`, and a blueprint object names its `stageField` - -Clause-②: yes - -The object schema already requires a lifecycle `kind` (`open` / `won` / `lost`) on every option of the field an object's `stageField` names. The AI blueprint had no slot for either key, so a model could not say which field was an object's stage or which option ended its lifecycle, and that rule never applied to an AI-built object. Both blueprint shapes now carry both keys. - -- **Option `kind`**, on `BlueprintFieldSchema.options[]` and on the strict structured-output mirror's (`SolutionBlueprintStrictSchema`) field options. It is `SelectOption.kind`'s own enum, taken by reference, so the blueprint accepts exactly the kinds an object field's option accepts. Lenient: optional. Strict: required and nullable (null = no kind stated). -- **Object `stageField`**, on `BlueprintObjectSchema` and the strict mirror's objects: a snake_case name of one of the object's own fields, mapping onto `ObjectSchema.stageField`. Lenient: optional. Strict: required and nullable (null = no stage field). The object schema's `false` value ("no linear lifecycle") has no blueprint spelling. -- Both describes state what the object schema refuses: the field `stageField` names must be a single-value `select` or `radio` whose inline options each declare `kind`. A multiselect / checkboxes / tags field that carries options is refused as a stage field, and `kind` on such a field's options is refused by the field schema. The blueprint does not check these rules itself; the object schema does, when the applier writes the object. -- Additive only: every blueprint that parsed before still parses with the lenient schema. A producer of the STRICT shape must now send `stageField` on each object and `kind` on each option (null when it has none), as with every other key in that mirror. diff --git a/.changeset/pre/24210-metadata-protocol-by-name-disabled-non-object.md b/.changeset/pre/24210-metadata-protocol-by-name-disabled-non-object.md deleted file mode 100644 index fd08c23966e..00000000000 --- a/.changeset/pre/24210-metadata-protocol-by-name-disabled-non-object.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol)!: the by-name `/meta` read answers a disabled package's item of every type but `object` and `package` as absent, as the list already omits it - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -Disabling a package already took its apps, views, pages, dashboards, flows and every other item out of `GET /meta/:type`. The by-name read `GET /meta/:type/:name` still served them. It now answers such an item the way it answers a name with nothing behind it. - -- **Absent now:** `getMetaItem({ type, name })` for an item whose `_packageId` names a disabled package, for every type except `object` and `package`. The active read and `previewDrafts` serve no `item`, which both transports answer `404 RESOURCE_NOT_FOUND`, byte for byte the answer for a missing name. The strict draft read (`state: 'draft'`) throws the missing draft's `404 NO_DRAFT`. The list read and the by-name read ask one predicate, so they hide the same items. -- **Kept:** the protection envelope (`lock`, `editable`, `deletable`, `version` and the rest) still reports the write doors' verdicts about the address, because a disable does not change how the lock gate judges a save there. Only `item` and a flow's `registration` are withheld. -- **Unchanged:** objects, which keep their `404 OBJECT_PACKAGE_DISABLED`; packages, which are never hidden, so a disabled package can still be read and re-enabled; items of enabled packages; and `getMetaItemLayered` (`?layers=true`, `/layers`) and `/published`, which still serve a disabled package's non-object item. - -What changes for you: a client that read a disabled package's app, view, page or other item by name now gets `404 RESOURCE_NOT_FOUND`, as for a missing name. Re-enable the package to read it again. diff --git a/.changeset/pre/24210-metadata-protocol-drafts-disabled-package.md b/.changeset/pre/24210-metadata-protocol-drafts-disabled-package.md deleted file mode 100644 index 7c4bf49d015..00000000000 --- a/.changeset/pre/24210-metadata-protocol-drafts-disabled-package.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol)!: `listDrafts` omits a disabled package's drafts, as the list read omits its items - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -`GET /meta/_drafts` (both transports) listed a disabled package's pending drafts, while the by-name draft read answers them `404 NO_DRAFT` and the list read omits the package's items. - -- **Omitted now:** every draft whose `packageId` names a disabled package, with or without `?packageId=`. The predicate is the one the list and by-name reads ask. Drafts of the `package` type are never hidden. -- **Unchanged:** the package batch doors (`POST /packages/:id/publish-drafts`, `/discard-drafts`) still publish and discard a disabled package's drafts; they read the store directly. Other packages' drafts are listed as before. - -What changes for you: re-enable the package to see its pending drafts in the list again. diff --git a/.changeset/pre/24210-plugin-security-echo-binding-disabled-package.md b/.changeset/pre/24210-plugin-security-echo-binding-disabled-package.md deleted file mode 100644 index 74232079e74..00000000000 --- a/.changeset/pre/24210-plugin-security-echo-binding-disabled-package.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security): the security catalog echo keeps naming a disabled package's permission set or position as that package's - -The echo reads which package owns a stored overlay from the protocol's by-name read. That read now withholds a disabled package's item and keeps its protection envelope, so the echo reads the envelope's `packageId` instead of `item._packageId`. Before, a disabled package's set was echoed unowned, lost its ADR-0090 D5 audience-binding suggestion, and stayed unowned after the package was re-enabled until the next save, publish or boot. Enforcement is unchanged. diff --git a/.changeset/pre/24210-rest-event-diff-doors-disabled-package.md b/.changeset/pre/24210-rest-event-diff-doors-disabled-package.md deleted file mode 100644 index 0638bccc4de..00000000000 --- a/.changeset/pre/24210-rest-event-diff-doors-disabled-package.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest)!: `/history`, `/audit` and `/diff` answer a disabled package's item with the plain read's absence, never past the per-caller gate - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The by-name read now answers a disabled package's item as absent. The event and diff doors judge the item the plain read would judge, and they read "no item" as "nothing behind the name": events served whole, and a view's or dashboard's diff served whole, to a caller the item's own gate refuses. - -- **Absent now, `404 RESOURCE_NOT_FOUND`:** `GET /meta/:type/:name/history`, `/audit` and `/diff` for an `app`, `view`, `dashboard`, `doc` or `book` the plain read withholds while its layers still resolve, which is a disabled package's item. No events and no diff are read. -- **Unchanged:** an item that is really gone (every layer null, such as a deleted item) keeps its answer: its events, and its diff as before. Items of enabled packages keep the per-caller gate as before. - -What changes for you: re-enable the package to read its items' history, audit trail and diff again. diff --git a/.changeset/pre/24210-rest-layered-doors-disabled-package.md b/.changeset/pre/24210-rest-layered-doors-disabled-package.md deleted file mode 100644 index dc5e6c5b5e8..00000000000 --- a/.changeset/pre/24210-rest-layered-doors-disabled-package.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest)!: `/meta/:type/:name/layers` and the deprecated `?layers=true` answer a disabled package's non-object item as a missing name, on both transports - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -- **Absent now, byte for byte the missing name's answer (`404 RESOURCE_NOT_FOUND`):** the layered view of an item of every type but `object` and `package` whose owning package is disabled, on `GET /meta/:type/:name/layers` and on `GET /meta/:type/:name?layers=true`, to every caller, an author who may write the item included. One step in the shared layered chain (`createMetaLayeredAnswer`) answers it, so the runtime dispatcher's two spellings answer the same. The owner is the one the by-name read's protection envelope names (`packageId`), read under the request's `?package=` scope, asked of the engine registry's `isPackageDisabled`. Objects already answered `404 OBJECT_PACKAGE_DISABLED` here. -- **Unchanged:** items of enabled packages, a name with nothing behind it, and the protocol's `getMetaItemLayered`, which keeps serving every layer to the readers that resolve with it (the security catalog echo, the packaged-permission-set lock gate). -- **Added, optional:** `MetaItemReadGateSources.isItemOfDisabledPackage` and `MetaLayeredRequest.packageId`. A host that supplies neither answers as before. - -What changes for you: re-enable the package to read its items' layered views again. diff --git a/.changeset/pre/24210-rest-published-state-event-body-disabled-package.md b/.changeset/pre/24210-rest-published-state-event-body-disabled-package.md deleted file mode 100644 index 30c4a88cc28..00000000000 --- a/.changeset/pre/24210-rest-published-state-event-body-disabled-package.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest)!: `/published` and `/meta/object/:name/state/:field` answer a disabled package's item as the by-name read does, and the event and diff doors' absence is the plain read's body - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -- **`GET /meta/:type/:name/published`, every type but `object` and `package`:** a disabled package's item is answered as a name with nothing behind it (`404 NOT_FOUND`, byte for byte), on both exits: the stored published row and the code/package snapshot. The owner is the one the by-name read's protection envelope names (`packageId`). Objects already answered `404 OBJECT_PACKAGE_DISABLED` here. -- **`GET /meta/object/:name/state/:field`:** a disabled package's object is refused `404 OBJECT_PACKAGE_DISABLED`, what `GET /meta/object/:name` answers, as the runtime dispatcher's twin already does. The schema is not read. -- **`/history`, `/audit` and `/diff`:** the `404 RESOURCE_NOT_FOUND` a disabled package's gated item gets is now the plain read's nested body, `{ "error": { "code": "RESOURCE_NOT_FOUND", "message": "Metadata item not found or access denied." } }`, instead of the flat `{ "error": "…", "code": "RESOURCE_NOT_FOUND" }`. Status and code are unchanged; read `body.error.code`. -- **Unchanged:** items of enabled packages, a deleted item, and a name with nothing behind it. - -What changes for you: re-enable the package to read its items' published versions and its objects' state machines again. diff --git a/.changeset/pre/24210-runtime-layered-doors-disabled-package.md b/.changeset/pre/24210-runtime-layered-doors-disabled-package.md deleted file mode 100644 index 0ba74a27507..00000000000 --- a/.changeset/pre/24210-runtime-layered-doors-disabled-package.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime)!: the dispatcher's `/meta/:type/:name/layers` and `?layers=true` answer a disabled package's non-object item as a missing name - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -- **Absent now, `404 RESOURCE_NOT_FOUND` "Not found", byte for byte the missing name's answer:** `GET /meta/:type/:name/layers` and `GET /meta/:type/:name?layers=true` (with its `Deprecation` header) for an item of every type but `object` and `package` whose owning package is disabled, to every caller. The owner is the one the by-name read's protection envelope names (`packageId`) under the request's `?package=` scope, as `RestServer` asks it. Objects already answered `404 OBJECT_PACKAGE_DISABLED` here. -- **Unchanged:** items of enabled packages, and a name with nothing behind it. - -What changes for you: re-enable the package to read its items' layered views again. diff --git a/.changeset/pre/24210-runtime-meta-disabled-package-doors.md b/.changeset/pre/24210-runtime-meta-disabled-package-doors.md deleted file mode 100644 index 9003affa9bc..00000000000 --- a/.changeset/pre/24210-runtime-meta-disabled-package-doors.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime)!: the dispatcher's `/meta` doors that read past the protocol answer a disabled package's object or item as the protocol's by-name read does - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The registry keeps a disabled package's objects on purpose, and the metadata service knows nothing of a disable. Four dispatcher doors read one of them for a disabled package's object or item and served it. - -- **Refused now, `404 OBJECT_PACKAGE_DISABLED`:** `GET /meta/object/:name/state/:field`, and the legacy one-segment `GET /meta/:name` exit, for an object whose owning package is disabled. Both read `registry.getObject`; they now answer the protocol's refusal, the answer `GET /meta/object/:name` gives. -- **Refused now, `404 OBJECT_PACKAGE_DISABLED`:** `GET /meta/object/:name/published`. When the layered read refused the object, this door fell through to the published snapshot and served it. The refusal is now the answer, as on `RestServer`'s `/published`. -- **Absent now, `404 RESOURCE_NOT_FOUND`:** `GET /meta/:type/:name` for a non-object item of a disabled package. The protocol answers it as absent, and the door no longer serves the metadata service's copy in its place. The body is byte for byte the answer for a missing name. -- **Unchanged:** objects and items of enabled packages, and every other fall-through. - -What changes for you: re-enable the package to read the object or item again. diff --git a/.changeset/pre/24210-runtime-published-disabled-package.md b/.changeset/pre/24210-runtime-published-disabled-package.md deleted file mode 100644 index e2d0f5720cc..00000000000 --- a/.changeset/pre/24210-runtime-published-disabled-package.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime)!: the dispatcher's `/meta/:type/:name/published` answers a disabled package's non-object item as a missing name - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -- **Absent now, `404 RESOURCE_NOT_FOUND` "Not found", byte for byte the missing name's answer:** `GET /meta/:type/:name/published` for an item of every type but `object` and `package` whose owning package is disabled, on the stored published row and the code/package snapshot alike. The owner is the one the by-name read's protection envelope names (`packageId`), as `RestServer`'s `/published` asks it. Objects already answered `404 OBJECT_PACKAGE_DISABLED` here. -- **Unchanged:** items of enabled packages, and a name with nothing behind it. - -What changes for you: re-enable the package to read its items' published versions again. diff --git a/.changeset/pre/24222-get-session-readers-declared.md b/.changeset/pre/24222-get-session-readers-declared.md deleted file mode 100644 index 684d7847db7..00000000000 --- a/.changeset/pre/24222-get-session-readers-declared.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/plugin-auth": patch -"@objectstack/rest": patch -"@objectstack/runtime": patch -"@objectstack/cloud-connection": patch -"@objectstack/plugin-webhooks": patch -"@objectstack/service-storage": patch ---- - -feat(spec): the remaining `getSession` readers read `AuthSessionApi`'s declared result, and none reads a top-level `activeOrganizationId` - -Clause-②: yes (widening) - -- **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains three optional `user` members**, each declared for a named reader: `isPlatformAdmin` (`boolean`, the ADR-0068 D2 rung plugin-auth's `customSession` derives) and `role` (`string | null`, better-auth's `admin` plugin scalar), both read by `judgePlatformAdmin` in `@objectstack/plugin-auth`; and `name` (`string`), read by the runtime dispatcher's AI-route `req.user` resolver as the display name. Every member is optional, so every implementer and caller typechecks unchanged. -- **The readers read through the declared type instead of `any` or a cast** (types only, runtime behaviour unchanged): `judgePlatformAdmin` and the four platform-admin mounts in `@objectstack/plugin-auth` (better-auth's own session type now has to fit the declaration at those reads), the REST server's two session reads (`@objectstack/rest`), the dispatcher's posture read, `resolveSessionData` and both `HttpDispatcher` reads (`@objectstack/runtime`), the cloud-connection and install-local session reads (`@objectstack/cloud-connection`), the webhook redeliver door (`@objectstack/plugin-webhooks`), and the storage session bridge (`@objectstack/service-storage`). -- **Fix — the top-level `activeOrganizationId` fallback is removed** from the storage upload stamp (`@objectstack/service-storage`) and from install-local's `resolveActiveOrgId` (`@objectstack/cloud-connection`). The active organization is read from `session.activeOrganizationId` alone, where the contract declares it. No in-process producer answers a top-level `activeOrganizationId` (better-auth's read is `{ user, session }`), so for the shipped auth plugin nothing changes. An auth service that answered only the flattened shape now gets no organization: the upload is not stamped, and install-local reports no active organization. To fix it, answer the active organization on `session.activeOrganizationId`, as `AuthSessionApi` declares it. diff --git a/.changeset/pre/24228-actions-every-verb-anonymous-floor.md b/.changeset/pre/24228-actions-every-verb-anonymous-floor.md deleted file mode 100644 index dd2302352ec..00000000000 --- a/.changeset/pre/24228-actions-every-verb-anonymous-floor.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): on the standalone server, every verb on `/api/v1/actions/...` reaches the `actions` domain, so an anonymous caller is answered `401`; and an `Allow` header no longer lists verbs a route mounts only to refuse - -Clause-②: no - -On the standalone server (`os dev`, `os serve`: `HonoServerPlugin` with the dispatcher plugin), a request that matched no route under its verb was answered by the HTTP adapter: `405 METHOD_NOT_ALLOWED` with an `Allow` header built from the verbs registered on the path. Two answers were wrong. - -- **`/api/v1/actions/...` (all three path shapes, unscoped and `/environments/:environmentId`-scoped).** Only `POST` is mounted, so an anonymous `GET`, `HEAD`, `PUT`, `PATCH`, `DELETE` or `TRACE` was answered `405` + `Allow: POST` instead of the published `401 UNAUTHENTICATED`. Every such request now reaches the `actions` domain, as it does on a `createHonoApp` host: an anonymous caller gets `401 UNAUTHENTICATED` with no `Allow`, and a signed-in caller gets the domain's `405` + `Allow: POST`. An anonymous `OPTIONS` (only reachable with CORS off) gets the domain's `405` + `Allow: POST` on both hosts, because the shared anonymous decision lets a CORS preflight through. -- **`Allow` on `/api/v1/keys`, the three `/api/v1/notifications` faces and `/api/v1/mcp/skill`.** These paths mount verbs that exist only so the domain can refuse them. A verb that cannot be mounted (`OPTIONS` with CORS off, `TRACE`, an extension method), and `PUT`/`PATCH` on `/mcp/skill`, got the adapter's `405` with those refusal-only verbs listed as supported (`/keys`: `Allow: DELETE, GET, HEAD, PATCH, POST, PUT`). They now reach the domain too: `/keys` answers `401` anonymous and `405` + `Allow: POST` signed in, `/notifications` answers `401` anonymous and `404 ROUTE_NOT_FOUND` signed in, and `/mcp/skill` answers `405` + `Allow: GET`. - -Every request that was served is served as before, and no route is added: the dispatcher's existing fallback seam hands these requests to the domain. A transport that implements `setFallbackHandler` but not `resolveMountedRoute` keeps the adapter's answer, and the dispatcher says so once at boot, at `warn`. diff --git a/.changeset/pre/24230-marketplace-installed-list-retired.md b/.changeset/pre/24230-marketplace-installed-list-retired.md deleted file mode 100644 index 58ce82b8946..00000000000 --- a/.changeset/pre/24230-marketplace-installed-list-retired.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: `marketplace:installed-list` is retired — refused by name at the schema door; the separate Installed Apps view it drew was retired, and Setup → Packages filtered to Source = marketplace replaces it (#24230, ADR-0049) - -Clause-②: no (narrowing) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings (the `user:profile`, `element:filter`, `element:form`, `ai:chat_window` and `record:json` retirements shipped the same way). - -`marketplace:installed-list` was the plugin console widget that drew the separate Installed Apps view. It was published in 17.3.0 with an empty `ComponentPropsMap` row and was never a `PageComponentType` member: it reached the page schema through the type union's open string arm. The separate view was then retired, because Setup → Packages carries the same packages and their actions behind a Source = marketplace filter. The widget's one page, `marketplace_installed`, had already left `@objectstack/cloud-connection`, whose "Installed Apps" navigation entry opens the filtered Packages view instead, and the console deleted the widget's one renderer. After that, an authored node validated clean and then drew "Unknown component type" in front of a user. A declared element nothing draws is a capability claim nothing keeps, so it is retired under ADR-0049 enforce-or-remove and refused by name, the `ai:chat_window` way. - -**What is refused:** an authored `marketplace:installed-list` component node, at `PageComponentSchema.type`. That covers `definePage()`, `PageSchema`, and every door that parses pages: `os validate`, `os build`, `os lint` and the metadata save door. The issue is located at the node's own path, with `code: 'custom'` and `params.retiredComponentType`, and its message is the retirement prescription. `ComponentPropsMap['marketplace:installed-list']` stays as a row, because for a type outside the enum the row is the only thing that keeps the name known to the component-props gate, `check-yaml-examples` and the type vocabulary. The row now refuses every props bag, `{}` included, with the same prescription. One prescription string, `RETIRED_PAGE_COMPONENT_TYPES` in `@objectstack/spec/ui`, answers at every door. The authoring rule `component-type-unknown` reports the node by name, and the typo suggester no longer offers it. - -**What else moves:** its print classification row (`PRINT_REFUSED_PAGE_COMPONENT_TYPES`) leaves with it, because a retired type is refused at the parse before any print rule reads it. The D3 entry `ui-cloud-connection-widgets-unknown-keys-refused`, which closed this type's props bag to `{}` in the same major, now speaks for `cloud-connection:panel` alone, so the upgrade guide no longer tells an upgrader to keep a node that is refused. No export is removed: the row was built by the `emptyProps` factory, which exports no def, so no JSON Schema leaves the published set. - -**What stays accepted:** every other `ComponentPropsMap` row, byte-identically, including the sibling console widgets `cloud-connection:panel` and `mcp:connect-agent`. The open string arm also stays open: custom and plugin-registered types keep parsing, the `marketplace:` namespace included. The only string refused is the retired name itself. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| a `{ type: 'marketplace:installed-list' }` component node in a page region, slot or container | nothing: delete the node. The Installed Apps list is Setup → Packages filtered to Source = marketplace | -| a page whose only job was to show that list | a navigation item `{ type: 'component', componentRef: 'developer:packages', params: { source: 'marketplace' } }`, which is what `@objectstack/cloud-connection`'s own "Installed Apps" entry does | - -The one-line fix: delete the `marketplace:installed-list` component node, and link to the filtered Packages view if users need to reach it. No ADR-0087 conversion is registered, because the only edit is deleting an authored page node, and a mechanical conversion does not delete page nodes: which region closes up is a layout decision. The D3 entry `ui-marketplace-installed-list-retired` carries that delegation, so `os migrate meta --from 17` lists it as a manual change for every stack that still names the type. - -## Who is affected, measured - -- **objectstack** at `cbfa5e4335`: zero authored `marketplace:installed-list` nodes in shipped metadata. `@objectstack/cloud-connection` serves no page for it, and its pin test asserts that. The other hits were the spec's own row, print row and docblocks, its tests, two lint pins, one synthetic test fixture in `packages/cli` (now a live block) and the release-owned changelogs. A tree-scoped absence pin now holds that zero. -- **objectui** at the console pin `.objectui-sha` `5e15928b46`: the type is registered by string only, with no typed read of `ComponentPropsMap['marketplace:installed-list']`. The row's key is kept, so the console build at the pin is unaffected. objectui `main` has deleted the renderer. -- **Deployed metadata** was not measured. - -The retirement kit: - -- the retired-type map entry and the print classification row removed (`packages/spec/src/ui/page.zod.ts`), the row turned into a whole-bag refusal (`packages/spec/src/ui/component.zod.ts`), and the vocabulary docblock updated (`component-type-vocabulary.ts`) -- the D3 semantic entry `ui-marketplace-installed-list-retired` and its step-18 rationale fragment; the D3 entries `ui-cloud-connection-widgets-unknown-keys-refused` (narrowed to `cloud-connection:panel`) and `ui-mcp-connect-agent-unknown-keys-refused` (one clause) amended -- pin tests: `component-marketplace-installed-list-retirement.test.ts` pins `code`, `path`, `params` and the first sentence at each door (the map, the row with empty and keyed bags, `PageComponentSchema`, `PageSchema` at the element path), the absence from the enum and the print rows, the D3 registration and the narrowed sibling entry, with `cloud-connection:panel` as the live control, plus a tree-scoped absence walk; `component-type-vocabulary.test.ts` pins the type known only through its row, out of the typo candidates, and `marketplace:` still unreserved; the lint accept pins in `validate-component-types.test.ts` and `validate-component-props.test.ts` are flipped into refusal pins -- generated docs follow the schema: the reference page for `ui/page`, `spec-changes.json` and the upgrade guide diff --git a/.changeset/pre/24235-audit-write-locale-seam.md b/.changeset/pre/24235-audit-write-locale-seam.md deleted file mode 100644 index 88745963718..00000000000 --- a/.changeset/pre/24235-audit-write-locale-seam.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/plugin-audit': patch ---- - -fix(plugin-audit): an activity summary written right after a workspace language change is stored in the new language, not the old one - -Clause-②: no (no export, option key, signature or schema changes; the held write locale is retired sooner, and the option docs say when) - -- **What was wrong.** The audit writer held the write locale for each tenant/user scope for 30 seconds, and nothing retired it when the `localization.locale` setting changed. A `sys_activity.summary` line is stored once, at write time, so every audited write in that window (a user's create, a seed publish) kept the old language for good. -- **What changes.** A write to `sys_setting` or `sys_platform_setting` through the engine now retires every held write locale. That covers a change made through the settings API, which persists through the engine, and a direct row write such as a seeder's, which emits no settings event. These are the same writes that retire `resolveLocalizationContext`'s own cache. A lookup still in flight when such a write lands is not held. The 30-second bound stays for what the writer cannot see: the storage behind a host `getLocale` resolver, and a settings write on another node. -- **Unchanged:** the host `getLocale` resolver is still asked first, and a write with no settings change between it and the last one is still answered from the held locale, without a settings read. diff --git a/.changeset/pre/24239-public-form-grant-skill.md b/.changeset/pre/24239-public-form-grant-skill.md deleted file mode 100644 index 7e013eb9660..00000000000 --- a/.changeset/pre/24239-public-form-grant-skill.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/skills': patch ---- - -docs(skills): `objectstack-api` states that a public form's submit is authorized by the form's own `publicFormGrant`, and that the `guest_portal` name on the submit context admits nothing and needs no permission set (#24239) - -Clause-②: no - -`skills/objectstack-api/SKILL.md` described the anonymous form endpoints as running under a synthetic `{ permissions: ['guest_portal'], anonymous: true }` execution context and named nothing else, so an author reading it inferred that a `guest_portal` permission set authorizes the submit and declared one, or concluded that the form falls open without one. Neither is true: the submit is authorized by the form's own declaration (ADR-0056 Option A) — the route hands the engine a `publicFormGrant` scoped to the form's target object, which admits the create and the read-back of the row it made and nothing else, decided before any permission set resolves. The `guest_portal` name on the context admits nothing; where a deployment registers a set under it, that set only feeds the grant's result masker. The passage now says so, matching `content/docs/ui/public-data-collection.mdx` and the `registerFormEndpoints` docstring in `@objectstack/rest`. Text only; nothing an author writes changes, and the form routes' behaviour is unchanged. diff --git a/.changeset/pre/24243-plugin-auth-hook-refusal-codes.md b/.changeset/pre/24243-plugin-auth-hook-refusal-codes.md deleted file mode 100644 index 7d1f2174bb7..00000000000 --- a/.changeset/pre/24243-plugin-auth-hook-refusal-codes.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): the organization, invitation-placement and phone-OTP hook refusals carry their ADR-0112 codes - -Clause-②: no - -Eight refusals thrown from plugin-auth's better-auth hooks answered with `message` alone, so a client could only show or match English prose. Each now carries a `code` it can branch on, and keeps its HTTP status. - -- `POST /auth/organization/create`: the reserved `__global__` id or slug answers 400 `VALIDATION_ERROR`. Where the deployment's effective tenancy posture enforces no organization wall (`/auth/config` publishes `features.multiOrgEnabled: false`), creating an organization answers 403 `ORGANIZATION_CREATION_DISABLED`. -- `POST /auth/organization/invite-member` with placement intent (`businessUnitId` and `positions`): with no delegated-administration runtime to authorize it, 403 `INVITE_PLACEMENT_UNSUPPORTED`; refused by the delegated-administration gate, 403 `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`. -- `POST /auth/organization/update` with a new slug: while the organization has an active environment (any environment that is neither archived nor failed), 403 `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS`. Its message no longer says environments "still reference" the organization or tells you to rename their hostnames: the guard reads no hostname, and archiving those environments is the remedy. When the guard's own read cannot answer, 503 `SERVICE_UNAVAILABLE`. -- Phone OTP sends (`/auth/phone-number/send-otp`, `/auth/phone-number/request-password-reset`): the per-number limit and the deployment's daily SMS quota both answer 429 `RATE_LIMIT_EXCEEDED`. It is one code for both walls, so a caller still cannot tell which budget it hit. The body is better-auth's flat `code` and `message`, with no `Retry-After` header. -- **One behaviour change:** a placement check that fails before the gate reaches a verdict (the issuer's grants could not be read from the store) now answers 503 `SERVICE_UNAVAILABLE`, not a 403 scope refusal carrying the store's message. No invitation is created either way. diff --git a/.changeset/pre/24245-head-dispatched-as-get.md b/.changeset/pre/24245-head-dispatched-as-get.md deleted file mode 100644 index e0eaea0e4ef..00000000000 --- a/.changeset/pre/24245-head-dispatched-as-get.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/hono': patch -'@objectstack/runtime': patch ---- - -fix(hono): on a `createHonoApp` host a `HEAD` is answered as its `GET` (no body), as the standalone server answers it; `HEAD /api/v1/mcp/skill` no longer answers `405` with `Allow: GET` - -Clause-②: no - -`createHonoApp`'s `${prefix}/*` catch-all handed the dispatcher the request's raw verb. Hono answers a `HEAD` from the `GET` route, but the verb still read `HEAD`, and the dispatcher's domains decide by comparing it with `GET`, so a `HEAD` was not answered as its `GET` on this host. Measured: `HEAD /api/v1/mcp/skill` answered `405 METHOD_NOT_ALLOWED` with `Allow: GET`; `/health`, `/ready` and the scoped root `/environments/:id` answered `404 ROUTE_NOT_FOUND`; signed in, `/analytics/meta` answered `404` and `/ai/agents` `501`; anonymous, the public `/share-links/:token/resolve` answered `401` where `GET` answers `501`. With an auth service, `HEAD /auth/config` answered `404` (it was forwarded to better-auth instead of answered as the `GET`). - -- **`@objectstack/hono`.** The catch-all now dispatches a `HEAD` as `GET`, and the `/auth/config` branch answers a `HEAD` as its `GET`. Every dispatcher route answers `HEAD` with its `GET` answer's status and headers, and Hono drops the body. A path that serves no `GET` keeps its `GET` answer for `HEAD` (its `405` and `Allow`, or `401` to an anonymous caller). The standalone server (`HonoServerPlugin` with the dispatcher plugin) already answered `HEAD` this way. Only the verb changes: a domain that forwards the request (the `/mcp` transport, the `/approvals/act` page) is still handed the client's `HEAD` request. -- **`@objectstack/runtime`.** The `/approvals/act` domain decides whether a request is a `HEAD` from the forwarded request's own method, not from the dispatcher's verb. So a `HEAD` still reaches the approvals member as a `GET` and is answered with no body. diff --git a/.changeset/pre/24251-mcp-anonymous-first.md b/.changeset/pre/24251-mcp-anonymous-first.md deleted file mode 100644 index 3d0a75c4105..00000000000 --- a/.changeset/pre/24251-mcp-anonymous-first.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): an anonymous caller of `/api/v1/mcp` is answered `401 UNAUTHENTICATED` whatever `OS_MCP_SERVER_ENABLED` or the `mcp` service says - -Clause-②: no - -ADR-0138 D2 takes the anonymous decision once per domain, as the domain's first statement. The `/mcp` domain checked the `OS_MCP_SERVER_ENABLED` switch and the `mcp` service before it, so an anonymous caller was answered `404 RESOURCE_NOT_FOUND` with the switch off and `501 NOT_IMPLEMENTED` with no MCP service installed, and learned whether the deployment runs MCP before it was refused. Measured on both hosts, the standalone server (`HonoServerPlugin` with the dispatcher plugin) and a `createHonoApp` host. - -- An anonymous request on `/api/v1/mcp` (any verb) is now refused `401 UNAUTHENTICATED` first, in every composition. Only the order of the refusals moves: no request that was refused before is accepted. -- A signed-in caller, or a system principal, keeps the switch's `404` and the service's `501`, in that order. -- The `401` advertises RFC 9728 resource metadata (`WWW-Authenticate: Bearer … resource_metadata="…"`) only where the resource is served: the switch on, an `mcp` service that can take the request, and an auth service that answers a metadata URL. With the switch off or no service the `401` is the plain API-key one, so it never points a client at a protected resource the deployment does not serve. diff --git a/.changeset/pre/24252-endpoint-fallback-body-after-match.md b/.changeset/pre/24252-endpoint-fallback-body-after-match.md deleted file mode 100644 index ebf459df61c..00000000000 --- a/.changeset/pre/24252-endpoint-fallback-body-after-match.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): a request to a declarative `apis:` endpoint path gets its `404` or `401` whatever its JSON body, not the transport's `400` - -Clause-②: no - -Only the order of refusals moves: the `404` and the `401` now answer ahead of the body's `400`. No request that was refused before is accepted, and no export changes. - -On the standalone server (`os dev`, `os serve`: `HonoServerPlugin` with the dispatcher plugin), the fallback that serves metadata-declared `apis:` endpoints (`/api/v1/apps/NAMESPACE/...` under the default prefix) read the request body before it matched an endpoint or applied `authRequired`. The transport refuses a body it cannot parse when the body is read, so that refusal answered first: - -- a `POST /api/v1/apps/ns/x` that no declaration owns, with `{"params": {"days": 30}` (the closing brace missing), got `400 INVALID_REQUEST`, while the same request with a well-formed body got `404 ENDPOINT_NOT_FOUND`, signed in or not; -- an anonymous `POST` to an `authRequired: true` endpoint with that body got `400 INVALID_REQUEST`, while a well-formed body got `401 UNAUTHENTICATED`. - -The fallback now hands the endpoint step the body unread, and the step reads it once the endpoint is matched and its policies (`rateLimit`, `authRequired`) have admitted the caller, just before `inputMapping`. A path no declaration owns answers `404`, and an anonymous caller of an `authRequired: true` endpoint answers `401`, whatever the body. A caller the endpoint admits still gets the transport's `400 INVALID_REQUEST` for an unparseable body, and a readable body reaches the target as before. diff --git a/.changeset/pre/24253-client-publish-drafts-backfill.md b/.changeset/pre/24253-client-publish-drafts-backfill.md deleted file mode 100644 index 6f83e52e171..00000000000 --- a/.changeset/pre/24253-client-publish-drafts-backfill.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/client": minor ---- - -feat(client): `packages.publishDrafts(id, { backfill })` sends the opt-in backfill of new fields' defaults - -Clause-②: yes (widening) - -- `opts.backfill?: Array<{ object, field }>` is sent in the publish body; the response's `backfillApplied` reports the rows written per field. A field the publish does not add, or one without a literal `defaultValue`, refuses the whole request with a `400` before anything is published. diff --git a/.changeset/pre/24253-metadata-protocol-publish-backfill.md b/.changeset/pre/24253-metadata-protocol-publish-backfill.md deleted file mode 100644 index 08e4a3bfd05..00000000000 --- a/.changeset/pre/24253-metadata-protocol-publish-backfill.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor ---- - -feat(metadata-protocol): `publishPackageDrafts` takes an opt-in `backfill` that writes the literal `defaultValue` of fields the publish adds into the object's existing rows, as the caller - -Clause-②: yes (widening) - -- **New request keys.** `backfill?: Array<{ object, field }>` names fields this publish adds; `context` is the caller's execution context the writes run as. Absent or `[]`, no row is touched. -- **Judged before anything is published.** Every entry must name an object drafted in the package, a field its draft declares, a field the published object does not have yet, and a literal `defaultValue` (a runtime token such as `NOW()` or `current_user`, or an expression, is resolved per new record and is refused). A malformed entry, a duplicate, or a backfill without a caller context is refused too. All problems come back in one `400 VALIDATION_ERROR`, one `issues[]` entry per problem with its `backfill.N` path, and nothing is promoted or written. -- **Written after the publish, as the caller.** Once the drafts are live and the new column exists, rows whose value is absent are read and written 200 at a time through `updateManyData` with the caller's context, so permissions, RLS/FLS, validation and record hooks run as on that caller's own edit. A value already stored is never overwritten. -- **New response key `backfillApplied`:** `{ success, fields: [{ object, field, updated, error?, code? }] }`, one element per requested field. `updated` counts the rows this publish wrote. A field stops at its first failing row and reports the rows written so far with the error; the publish is not undone. On the SQL driver the new column's DEFAULT already fills existing rows, so `updated` is 0 there. diff --git a/.changeset/pre/24253-runtime-publish-drafts-backfill.md b/.changeset/pre/24253-runtime-publish-drafts-backfill.md deleted file mode 100644 index 30c93a29137..00000000000 --- a/.changeset/pre/24253-runtime-publish-drafts-backfill.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -"@objectstack/runtime": minor ---- - -feat(runtime): `POST /packages/:id/publish-drafts` accepts a `backfill` body key and passes it to the protocol with the caller's execution context - -Clause-②: yes (widening) - -- The body's `backfill` (`[{ object, field }]`) reaches `publishPackageDrafts` as sent, together with the request's own execution context, so the backfill writes run as the signed-in caller, never as the system. A body without the key sends neither. -- The protocol's request refusal answers `400 VALIDATION_ERROR` with its `issues[]`, before anything is published, flipped or announced. The `backfillApplied` receipt rides the `200` body. diff --git a/.changeset/pre/24253-spec-publish-drafts-backfill-applied.md b/.changeset/pre/24253-spec-publish-drafts-backfill-applied.md deleted file mode 100644 index f9b8bd37fa3..00000000000 --- a/.changeset/pre/24253-spec-publish-drafts-backfill-applied.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `PublishPackageDraftsResponseSchema` declares `backfillApplied`, the per-field receipt of the publish door's opt-in backfill - -Clause-②: yes (widening) - -- `backfillApplied?: { success, fields: [{ object, field, updated, error?, code? }] }`, present only when the request named a `backfill` and the batch published. Declared so the door's response parse carries it instead of stripping it. diff --git a/.changeset/pre/24254-plugin-auth-hook-codes.md b/.changeset/pre/24254-plugin-auth-hook-codes.md deleted file mode 100644 index 6c7ad14bc18..00000000000 --- a/.changeset/pre/24254-plugin-auth-hook-codes.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): register four organization and invitation-placement refusal codes under `@objectstack/plugin-auth` - -Clause-②: yes (widening) - -The error-code ledger (`ERROR_CODE_LEDGER`, and so `ErrorCode`) gains four `@objectstack/plugin-auth` codes. Each names a hook refusal that today answers a bare `403 FORBIDDEN` with a message and no `code`, so a client such as the console cannot tell it apart from any other refusal on the same endpoint: - -- `ORGANIZATION_CREATION_DISABLED`: creating an organization is refused for every caller, because the deployment's effective tenancy posture enforces no organization wall (`/auth/config` reports `features.multiOrgEnabled: false`). -- `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS`: an organization's slug cannot change while the organization has an environment whose status is neither archived nor failed. -- `INVITE_PLACEMENT_UNSUPPORTED`: fail-closed. The invitation carries a placement (a business unit and positions), and this deployment has no delegated-administration runtime to authorize it. -- `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`: the delegated-administration gate refused the invitation's placement, because the business unit or a position lies outside the issuer's delegated scope. - -Registration only: `@objectstack/plugin-auth` still throws these refusals without a code until #24243 stamps them. The same change names a standard code, which needs no ledger row, for the plugin's other four code-less refusals: `RATE_LIMIT_EXCEEDED` for both phone-OTP send walls, which stay indistinguishable; `SERVICE_UNAVAILABLE` for the slug guard's read fault; and `VALIDATION_ERROR` for the reserved `__global__` organization id or slug. No existing code changes, and no standard-catalog member is shadowed (none needs a synonym waiver). diff --git a/.changeset/pre/24264-approval-request-strip-flow-label.md b/.changeset/pre/24264-approval-request-strip-flow-label.md deleted file mode 100644 index f031afdb637..00000000000 --- a/.changeset/pre/24264-approval-request-strip-flow-label.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -fix(plugin-approvals): the approval request page's highlights strip names the flow by its label, not `flow:NAME` - -Clause-②: no - -- **The strip's first chip is the flow's label.** `sys_approval_request_detail` lists `process_label` (the "Flow" field) where it listed `process_name`, so a request opened by the Deal Desk flow reads `Deal Desk` there instead of `flow:deal_desk`. The request lists already showed this label. -- **The object's `highlightFields` makes the same swap** (`process_label` in place of `process_name`, `record_id` kept). The page's Details tab is a bare `record:details` node, which draws the object's `highlightFields` minus the strip. A change to the strip alone would have moved `process_name` into that tab. Changed together, the tab still shows only the target record card. Other surfaces that read the object's list show the flow's label too: a request's record preview card, Studio's object settings, and the default columns of a list, kanban, related list or lookup picker that declares none. No first-party request list uses the default columns: each request list view declares its own. -- **When no label is available.** The console reads a request through the approvals get, which serves `process_label` as the stored label, else the flow label in the request's node-config snapshot, else the humanized machine name (`flow:deal_desk` → `Deal Desk`). A request the boot backfill has not labelled still shows a Flow chip. Only when all three are empty does the field have no value: the strip then draws no Flow chip, and the record title still names `process_name`. - -A patch: one entry in one platform object's field list and the same entry in its page. No export, type, accepted value or stored column changes. diff --git a/.changeset/pre/24265-app-page-record-binding.md b/.changeset/pre/24265-app-page-record-binding.md deleted file mode 100644 index 647c548a6fb..00000000000 --- a/.changeset/pre/24265-app-page-record-binding.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -Clause-②: yes (widening) - -feat(spec): an app page binds its row as `record`, so a component `visibleWhen` can follow a bound count or a plan field - -- **What it adds.** `PageSchema.record` (`PageRecordBindingSchema`): a one-row query `{ object, view?, filter?, sort? }` on a `type: 'app'` page. The page's row is the FIRST row of that query, read under the caller's identity, and every component predicate on the page reads it as `record`, the root ADR-0089 D1 already names for runtime surfaces. No new expression root is added. -- **What a predicate reads.** A count or a plan is a field on the bound row: a `summary` (roll-up) field (`record.environment_count > 1`) or the object's own field (`record.plan != 'free'`). `features.*` is not a plan: that root is the deployment's public auth flags (`/api/v1/auth/config`), the same for every organisation and every viewer. The `visibleWhen` docblock and describe now say so, and they say that a block's own element `dataSource` binds no `record` for its siblings or for its own `visibleWhen`. -- **What it refuses.** `record` on any page that is not `type: 'app'` is refused at parse (`checkPageRecordBindingPlacement`, exported from `@objectstack/spec/ui` like the other page checks): a record page's row is the record it opens, and a `home` / `list` / `utility` page has no row. Inside the binding, `limit` is refused with a prescription (a page binds one row; say which with `sort` or `filter`), and so are `id` / `recordId` (match the row with a `filter` rule on `id`). -- **Planned in the renderer.** The console does not mount the binding yet: until it does, `record` stays unbound on an app page, as before, and a predicate reading it faults, so the component renders. The liveness ledger grades the key `planned`. -- **Unchanged:** every existing page, component and predicate; the predicate root set (`record`, `current_user` with its aliases, `page.NAME`); the lint's declared roots. diff --git a/.changeset/pre/24266-record-blocks-empty-state.md b/.changeset/pre/24266-record-blocks-empty-state.md deleted file mode 100644 index 7e20f831cc7..00000000000 --- a/.changeset/pre/24266-record-blocks-empty-state.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): the bound record blocks `record:details`, `record:highlights` and `record:quick_actions` accept an authored `emptyState`, drawn only when the block's `dataSource` binding answers no row - -Clause-②: yes (widening) - -- **The key.** `RecordDetailsProps`, `RecordHighlightsProps` and `RecordQuickActionsProps` each gain an optional `emptyState`: the list view's own `EmptyStateSchema` by reference (`{ title, message, icon, link }`), the same shape `object-grid.emptyState` takes. Until now all three rows refused the key, so a page whose binding can legitimately answer no row (for example an environment block on an organisation with no production environment) could only show the block's own placeholder: "record not found", nothing, or an empty action bar. -- **When it is drawn.** Only when the block carries a node-level `dataSource` binding and that read answers no row. A block with no binding reads the record page's record and never draws it, a loading read draws the loading state, and a failed read draws its error. On `record:quick_actions`, a bound record whose bar resolves no action keeps the existing empty placeholder. -- **Planned, not yet rendered.** No renderer reads the key yet; each describe opens with `[PLANNED — not yet rendered]`, and the renderer half is objectstack-ai/objectui#12932, which ships once a spec release carrying the key is installable. Until then an authored `emptyState` validates and is not drawn. -- **No other row moves.** Every other `ComponentPropsMap` row still refuses `emptyState` (`record:related_list`, whose `parentDataSource` has its own declared answer for no row, included), so `os validate` keeps reporting it there as `component-props-unknown-key`. Nothing narrows. diff --git a/.changeset/pre/24268-declarative-update-undeclared-params-refused.md b/.changeset/pre/24268-declarative-update-undeclared-params-refused.md deleted file mode 100644 index 1ea65f93043..00000000000 --- a/.changeset/pre/24268-declarative-update-undeclared-params-refused.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/runtime': minor ---- - -fix(runtime)!: an `operation: 'update'` action that declares no `params` refuses every undeclared wire param at both action doors, instead of writing it to the row - -Clause-②: no (narrowing) - - - -**BREAKING** (the REST `/actions` door and the MCP `run_action` door now refuse, with a 400 `VALIDATION_ERROR`, a wire param that a declarative update action without `params` does not declare; measured consumers: objectstack-ai/hotcrm's `complete_task` called with such params; any console vendored before objectui `fb501bc894`, which posts the action's `patch` keys as `params`; and page action rows (`action:button` / `action:icon` with `operation: 'update'`) whose static object `params` the console forwards to a param-less declarative update action, none of which exist in this repo, with the row surface tracked in #24394), shipped as `minor` under the launch-window convention for breaking changes. - -A declarative update (`operation: 'update'` + `patch`) has no handler: the route merges the request's `params` over `patch` and writes the result to the current record. An action that declared no `params` skipped the ADR-0104 D2 param check altogether, so any key the caller sent was written too. An action declaring `patch: { status: 'completed' }` could rewrite any other field the caller may write, which is not what its author declared. - -Now an empty or absent `params` list on a declarative update is an empty accept set. Every key that is neither a declared param nor a dispatcher built-in (`recordId`, `objectName`, `_selectedIds`) is refused before anything is read or written, through the same D2 path a declared-params action already takes: the same `Invalid action params: Unknown action param "KEY"` message, the same 400, and the same `OS_ALLOW_LAX_ACTION_PARAMS=1` escape hatch. The action still writes its `patch` when the request carries no `params` or an empty bag, and a declared param still writes and still wins over `patch`. - -Unchanged: an action with a handler (`target`, `body`, or a flow) that declares no `params` keeps its pass-through. - -Also at the MCP `run_action` door: every ADR-0104 D2 param refusal, and the declarative update's own refusals (no `recordId`, or nothing to write), now carry `code: 'VALIDATION_ERROR'` and `status: 400`, so the tool error renders the coded envelope the REST door already answers with, rather than the message alone. - -What to do: declare the param on the action if the request's value should be written, or stop sending it, because the route applies `patch` from the declaration and never needs it on the wire. diff --git a/.changeset/pre/24271-org-rename-door.md b/.changeset/pre/24271-org-rename-door.md deleted file mode 100644 index 6ec2706359a..00000000000 --- a/.changeset/pre/24271-org-rename-door.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/spec': patch ---- - -`sys_organization` now declares a `rename_organization` action ("Rename Organization") at `record_header` and `list_item`, so an organization record page can rename the organization. It collects `name` (required) and `logo`, both pre-filled from the record, and no `slug`. It calls better-auth's `/api/v1/auth/organization/update` exactly as `update_organization` does (the record's id as `organizationId`, the fields under `data`), and it is gated on `multiOrgEnabled` like the other organization actions. Because the request carries no slug, the organization keeps its slug. It is declared before `change_slug`, so on the record header it takes the third inline slot and `change_slug` moves into the `⋯` overflow menu. - -`update_organization` (name, slug and logo, list row only) and `change_slug` are unchanged. - -`@objectstack/spec` books the new action in `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs`, beside `update_organization`. - -Clause-②: yes diff --git a/.changeset/pre/24273-transfer-ownership-honest.md b/.changeset/pre/24273-transfer-ownership-honest.md deleted file mode 100644 index 918ea1a8f06..00000000000 --- a/.changeset/pre/24273-transfer-ownership-honest.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -fix(platform-objects): `sys_member`'s `transfer_ownership` row action says the selected member becomes an owner and the caller keeps their role - -The action sends better-auth's `POST /organization/update-member-role` with `role: 'owner'`. That endpoint writes the selected member's role and nothing else, so the caller stays an owner and the organization ends with two. The action's confirm used to promise "You will be demoted to admin and lose owner-only privileges"; nobody was demoted. - -The copy now says what happens, in all four locales: - -- label: FROM "Transfer Ownership" TO "Make Owner"; -- confirm: the selected member becomes an owner with every owner privilege, the caller stays an owner, and to step down the caller uses Change Role on their own membership; -- success message: FROM "Ownership transferred" TO "Member is now an owner". - -To hand ownership over, run Make Owner on the new owner's row, then Change Role on your own row to `admin`. better-auth refuses that second step only while you are the organization's last owner (`YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER`). - -The action's `name`, `target`, request body, parameters, `requiresMembershipReach` and `variant` are unchanged, so a page or test that names `transfer_ownership` keeps working. A test that expected the caller to be `admin` after the action expected a demotion the endpoint never performed: it now reads `owner`, and reads `admin` only after the caller's own Change Role. - -Clause-②: no (provisional: copy only; if the measurement forces another shape, the dev reports before building it; owes the seat's review of record at `TIER_DEFAULT` before the queue) diff --git a/.changeset/pre/24281-approval-request-strip-step-label.md b/.changeset/pre/24281-approval-request-strip-step-label.md deleted file mode 100644 index fe30442d370..00000000000 --- a/.changeset/pre/24281-approval-request-strip-step-label.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/plugin-approvals': patch ---- - -fix(plugin-approvals): the approval request page's highlights strip names the current step by its label, not the node id - -Clause-②: no - -- **The approvals get serves `current_step_label`.** `ApprovalRequestRow` declares it as the step's label under the `sys_approval_request` column's own name. Every door that serves a request now serves it with exactly `step_label`'s value: the get, the list, and the request that a decision, recall, send-back, resubmit, reassign, remind, info request or comment echoes back. `step_label` stays, with the same value, for the inbox and the emailed action-link pages. `current_step` stays the Approval node's machine id. -- **The strip's step chip is the step's label.** `sys_approval_request_detail` lists `current_step_label` (the "Approval Step" field) where it listed `current_step`, so a request waiting on the `approve_step` node reads `Manager Review` there instead of `approve_step`. The request lists already showed this label. -- **The object's `highlightFields` makes the same swap.** The page's Details tab is a bare `record:details` node, which draws the object's `highlightFields` minus the strip. A change to the strip alone would have moved `current_step` into that tab. -- **When no label is stored.** The get serves the stored label, else the node label in the request's node-config snapshot, else the humanized node id (`approve_step` → `Approve Step`). A request the boot backfill has not labelled still shows a step chip. A request with no step serves neither field, and the strip draws no step chip. - -A patch: one served key that the contract already declares, and one entry in one platform object's field list and in its page. No export, type, accepted value or stored column changes. diff --git a/.changeset/pre/24282-ceremony-null-org-guard.md b/.changeset/pre/24282-ceremony-null-org-guard.md deleted file mode 100644 index 392eab489f4..00000000000 --- a/.changeset/pre/24282-ceremony-null-org-guard.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/runtime': patch -'@objectstack/plugin-auth': minor ---- - -Clause-②: yes (widening) - -fix(cli,runtime): the 17 → 18 organization-ownership upgrade of a single-posture database that holds no organization leaves no row without one, the boot the ceremony prescribes writes nothing before it is refused, and a ceremony that did not verify is never served (ADR-0131 D3, D10) - -A 17.x single-posture install whose seed ran before any organization existed holds rows of organization-owned objects with `organization_id` NULL and an empty `sys_organization`. Upgrading it took this path: the ceremony's attribution checkpointed itself done at zero rows; the boot its rewrite step asks for created the Default Organization and let the inline seed stamp some rows before it refused; the second `--apply` did not run attribution again, dropped the columns and recorded discrepancies with `verified_at` NULL; and `os start` then served that database. - -- **The ceremony creates the Default Organization (`@objectstack/cli`).** Under the `single` posture, on a database that holds no organization and rows no anchor names, `os migrate organization-ownership` plans those rows as the Default Organization's and says so in a plan warning (`defaultOrganizationCreatedByApply: true` in the plan file). `--apply` creates it before its first write, through plugin-auth's boot invariant (the row a single-posture boot creates), and gives it every such row. A run that cannot create it — a boot without the auth family that declares `sys_organization` — blocks at attribution, naming why, instead of attributing nothing and certifying. Nothing is created under a walled posture, or when the database holds organizations. -- **The ceremony names the Default Organization as the boot does (`@objectstack/cli`).** Under `single` it is the organization with `slug = 'default'`, else the sole organization whatever its slug — the boot invariant's and the tenancy service's definition (ADR-0093 D3). Before, the plan required `slug = 'default'`, so a database whose only organization had another slug kept its unanchored rows NULL through the ceremony while v18 derives every owner from that organization. Several organizations, none of them `default`, still name none, and their rows are reported. In the plan file (the command's own output, not a published schema) `defaultOrganization.slug` is now that organization's own slug, no longer always `'default'`. -- **A checkpointed attribution runs again (`@objectstack/cli`)** whenever the plan derives an owner for rows still NULL: a row the operator made attributable since, or one an earlier run found no Default Organization for. It writes only where `organization_id` is NULL, so no owner already written is derived again. A database left by an earlier release's ceremony in that state is completed by re-running `--apply`. -- **The boot guard judges before any plugin writes (`@objectstack/runtime`, `@objectstack/plugin-auth`).** `OrganizationOwnershipBootGuardPlugin` judges in its own `start()`, after the engine's schema sync (the column the rewrite step waits for is still added) and before every plugin that writes rows at start: `AppPlugin` (the inline seed), `AuthPlugin` and the identity-objects plugin (the Default Organization) name it (`com.objectstack.organization-ownership-boot-guard`) in `optionalDependencies`. A refused boot leaves every row as it found it. -- **A ceremony that did not verify is refused (`@objectstack/runtime`).** With no table left in the v17 shape, a boot whose `adr-0131-organization-ownership` marker exists and is not verified — the post-check found rows the ceremony has not carried — is refused with `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`, naming the re-run. A database with no marker (a fresh one) boots as before. -- **The refusal now arrives as a `cause` (`@objectstack/runtime`).** A failed `start()` is reported by the kernel as its own error ("Plugin … failed to start"), with the plugin's error as its `cause`. A host that composes the guard and inspects the boot error asks `isOrganizationOwnershipBootRefusal` of each `cause` down the chain: asked of the kernel's own error it now answers `false`. `assertOrganizationOwnershipMigrated(engine)` still throws the refusal itself. `os serve` and `os dev` read it from the chain. -- **One new export (`@objectstack/plugin-auth`, `minor`).** `ensureDefaultOrganizationExists(ql, options?)` — the Default Organization's boot invariant, find-or-create under `single` — for the ceremony, so the organization it creates is the row a single-posture boot creates, from one producer. A host composing a kernel still never calls it: `AuthPlugin` and the identity-objects plugin keep the invariant on every boot. No other package entry changes. - -Unchanged: the ceremony's steps, fates and refusals otherwise; a walled posture's unattributable rows (reported, never assigned); the boot of a fresh or verified database; and every plugin's order on a kernel that does not compose the guard. Repairing a database whose organization-scoped seed load already inserted twins of NULL-organization rows is an operator's data repair. diff --git a/.changeset/pre/24283-console-pin-0707b4ae3712.md b/.changeset/pre/24283-console-pin-0707b4ae3712.md deleted file mode 100644 index 4a546fa6b5c..00000000000 --- a/.changeset/pre/24283-console-pin-0707b4ae3712.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `0707b4ae3712`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 84 releasing of 85 changesets added across 67 non-merge commits; omitted: 1 release-nothing changeset, 1 commit carrying no changeset (they ship no package code). - -- **minor** — **The AI build cards draw the plan and result details the build agent now sends** (objectui#12724). (objectui `44cb469db`) -- **minor** — **BREAKING** — Line-item grids read a boolean column's options and a file cell's text in the session language (objectui#12924). Under a zh-CN session, a master-detail or `record:line_items` grid… (objectui `32b492b67`) -- **minor** — A `${…}` hole inside an inline locale map is evaluated (objectui#12936). In a node's `properties` bag (or its legacy `props` alias, or a block member evaluated with `useConfigBagE… (objectui `a7ae2521a`) -- **minor** — The approval decision panel (`record:approval_decision`) shows where a request came from and where its flow stands (objectui#12491). A request no person submitted, its `submitter_… (objectui `7bbd718be`) -- **minor** — The Approval Center's request drawer no longer has a "Raw data (JSON)" panel, for any viewer (objectui#12490). (objectui `9b82cedad`) -- **minor** — feat(react): a page element's API data source is rendered — `dataSource: { provider: 'api', read: { url }, path? }` binds the selected answer as `data` (objectui#12197) (objectui `c4dd7078e`) -- **minor** — feat(app-shell): five generic product-page blocks — `hero`, `stat`, `usage-bar`, `checklist`, `tile-grid` (objectui#12197) (objectui `c4dd7078e`) -- **minor** — The separate Installed Apps view is retired (objectui#12733, ruling B on objectstack#23892). Setup → Packages filtered to Marketplace is the one list of what is installed (objectu… (objectui `35244b637`) -- **minor** — A host can ask the console to draw no app switcher when the viewer is served one app (objectui#12913, the objectui half of objectstack-ai/cloud#2919). (objectui `d15ad87b1`) -- **minor** — `record:path`: a stage derived from a picklist now takes its terminal from the option's declared lifecycle `kind` (objectstack#23809) — `won` is the forward terminus, `lost` sits… (objectui `8392f74af`) -- **minor** — A record whose record page declares `print` shows its header actions again (objectui#12906). (objectui `d637be321`) -- **minor** — A list's user-filter chip no longer shows the raw field key while the object definition is loading (objectui#12890). (objectui `1b8864556`) -- **minor** — A page laid out by a slotted template draws only the regions that template places, and says so on the page for any other (objectui#12476, maintainer ruling A on objectstack-ai/obj… (objectui `044f23193`) -- **minor** — **Breaking (types):** a form select option (`SelectOption` and its zod twin `SelectOptionSchema`) carrying `kind` or `default` is now refused (objectui#12736). (objectui `c999d4c01`) -- **minor** — In Studio's Interfaces pillar, a click on a card of a kanban view's board selects the board's card, and a 看板卡片 (Kanban card) section in the view's properties edits it (objectui#12… (objectui `f5009db72`) -- **minor** — `element:text` now draws the inline Markdown its spec declares (objectui#12778). `ElementTextPropsSchema.content` is described as "Text or Markdown content", and the renderer prin… (objectui `d2b21a122`) -- **minor** — Resolve `@objectstack/*` `18.0.0-next.3` (objectui#12873). (objectui `26323c4fc`) -- **minor** — In Studio's Interfaces pillar, a click on a column header in an `object` leaf's canvas selects that column in the view's properties (objectui#12725, target 2: clicking a canvas el… (objectui `e058d7697`) -- **minor** — New `isDeadlineSettled(field, row, { scope, fields })` (objectui#11815). It answers whether a declared deadline is settled for one row: `true` only when the field declares `dueLik… (objectui `f34e877b6`) -- **minor** — The record page and related lists honour a deadline's `settledWhen` (objectui#11815). The details section, the highlights strip and each related-list row judge a declared deadline… (objectui `f34e877b6`) -- **minor** — A date reads overdue only when its field declares it a deadline, and never once the deadline is settled for the record (objectui#11815). (objectui `f34e877b6`) -- **minor** — A done row's past deadline no longer reads overdue in the grid (objectui#11815). Every column shape — object columns, name columns, inline-row fields and the object's default colu… (objectui `f34e877b6`) -- **minor** — A kanban card's declared deadline reads neutral once the card's record settles it through the field's `settledWhen`, and "Overdue Nd" in red otherwise (objectui#11815). (objectui `f34e877b6`) -- **minor** — Gallery cards and the compact list honour a deadline's `settledWhen` (objectui#11815): a done record's past due date reads neutral, an open one's reads "Overdue Nd" in red. Galler… (objectui `f34e877b6`) -- **minor** — A tree row's declared deadline reads neutral once the row's record settles it through the field's `settledWhen`, and "Overdue Nd" in red otherwise (objectui#11815). (objectui `f34e877b6`) -- **minor** — `DetailViewField.dueLike` and its `DetailViewFieldSchema` row are removed, with no alias (objectui#11815). A deadline is declared on the object's field metadata — `DateFieldMetada… (objectui `f34e877b6`) -- **minor** — feat(react): `MetadataContextValue.readObjectScope`, the object definitions one app reads (objectui#12811) (objectui `5ff64efc7`) -- **minor** — Studio's Data and Automations pillars show their properties in the one right slot, as the Interfaces pillar does (objectui#12725, item 3: properties follow the selection in every… (objectui `8d6e43574`) -- **minor** — Studio has one right slot that shows either the open item's properties or the AI copilot, never both (objectui#12725, items 1, 2, 6 and the canvas half of item 3). (objectui `34354a4b6`) -- **minor** — `DateFieldMetadata` and `DateTimeFieldMetadata` take the deadline keys from `@objectstack/spec`'s `FieldSchema` (objectui#12297). (objectui `8106db1f9`) -- **minor** — The console splash no longer waits for requests the first screen does not need (objectui#12809). (objectui `84b8ffa4b`) -- **patch** — A metric tile's value now scales before it would clip. A long value, such as a seven-digit money figure with cents (`1,290,000.00`), steps down from 24 px to 14 px as the tile nar… (objectui `0707b4ae3`) -- **patch** — `@object-ui/plugin-detail` now declares `@objectstack/spec` `^18.0.0-next.3`, up from `^18.0.0-next.1` (objectui#12946). (objectui `03f1d6528`) -- **patch** — Raise `@object-ui/app-shell`'s `@objectstack/spec` floor from `^18.0.0-next.2` to `^18.0.0-next.3` (objectui#12943). (objectui `641dad4b3`) -- **patch** — An `operation: 'update'` action no longer posts its declared `patch` as request `params` (objectui#12941). The platform action route applies the patch itself, from the action defi… (objectui `fb501bc89`) -- **patch** — Setup → Packages reads each package's Source from the server (objectui#12790, ruling B on objectstack#23892). (objectui `bc96bc34b`) -- **patch** — `record:details` draws a boolean the viewer cannot edit as the locale's word for its value (`common.yes` / `common.no`) instead of a disabled checkbox that read as a setting refus… (objectui `79cb8c03e`) -- **patch** — All ten language packs gain the panel titles the data-bound blocks now read for an unresolvable `dataSource.view` (objectui#12920): `element.KIND.sourceUnresolved` for `calendar`,… (objectui `1e6d83bc4`) -- **patch** — Data-bound blocks title their "data source could not be resolved" panel in the page's language (objectui#12920, the family of objectui#12910). The panel shows when a block's `data… (objectui `1e6d83bc4`) -- **patch** — A read-only `GridField` table shows a select cell's option label and a number or currency cell in the session's locale (objectui#12915). (objectui `8523e81db`) -- **patch** — The kanban swimlane that holds cards with no value in the swimlane field is headed by the pack's `kanban.uncategorized` instead of the English constant (objectui#12899). Under a n… (objectui `e1548b794`) -- **patch** — Studio's "Add region" writes only a region name the page's template places, and a page that names no template refuses an unplaced region on screen (objectui#12902). (objectui `bc281f4a1`) -- **patch** — The `element:*` blocks registered from `renderers/basic` draw their defaults in the page's language (objectui#12910, the close-out of objectui#12889). Each default reads `@object-… (objectui `2cfdc99df`) -- **patch** — All ten language packs gain the keys the `element:*` blocks in `@object-ui/components` now read for their defaults (objectui#12910): `element.number.sourceUnresolved`, `element.im… (objectui `2cfdc99df`) -- **patch** — A Studio item's title no longer disappears beside the header's actions on a narrow window (objectui#12897). (objectui `7bee002d2`) -- **patch** — `record:line_items` fills an untyped column from the child object's field (objectui#12854, the objectui half of objectstack-ai/objectstack#24140). (objectui `54e79f989`) -- **patch** — `ChatbotEnhanced`'s `onDesignBuiltApp` doc comment now describes what the finished build card draws since objectui#12724: "Open app" (打开应用) is the primary action and "Design in St… (objectui `c83e3fe53`) -- **patch** — Retire what the post-build transition left behind after objectui#12724 (objectui#12842). (objectui `c83e3fe53`) -- **patch** — The Console top bar no longer flashes a green "Connected" chip for two seconds after load (objectui#12891). (objectui `2727527b1`) -- **patch** — Page element blocks draw their loading, empty and error defaults in the page's language (objectui#12889). (objectui `97538115b`) -- **patch** — The console's boot session check now keeps the renewed session cookie (objectui#12892). Before the app renders, the console checks the stored sign-in token with one `get-session`… (objectui `285ddb22c`) -- **patch** — The kanban board's card lists and drag overlay are now named in the viewer's language. A lane's card list, a swimlane cell's card list and the drag overlay used English templates… (objectui `4ff5b6b3c`) -- **patch** — Studio's new-package dialog no longer offers a scope. Its Advanced section used to list `cloud`, `system` and `project`, and the server refuses the first two when a package is cre… (objectui `8e7597836`) -- **patch** — A formula field declared `returnType: 'currency'` renders as money, in a list cell, a form and a record detail (objectui#12111). (objectui `898bd2c06`) -- **patch** — The marketplace README renderer draws its inline Markdown with `@object-ui/components`' `renderInlineMarkdown`, the one `element:text` uses (objectui#12778), so a README link now… (objectui `d2b21a122`) -- **patch** — A focused kanban card opens on Enter and on Space (objectui#12883). Every card was already a keyboard tab stop, but its only activation was the pointer click, so a keyboard user c… (objectui `1d872f2f7`) -- **patch** — Metadata designer create flows now show the spec create seed's structural defaults before the first save. A new dataset's Measures section reads 1 (the seed's `count` measure) as… (objectui `2ec9e2a1a`) -- **patch** — The public form `/f/:slug` shows each section's description under its heading, as served (objectui#12333). (objectui `2b0ab9480`) -- **patch** — The OAuth consent page names the three agent scopes in words. An MCP client that asks for `data:read`, `data:write` or `actions:execute` used to show those as raw strings in the "… (objectui `b27901612`) -- **patch** — Kanban cards no longer tell screen-reader users to press the space bar. Each card's accessible description is now a sentence from the `kanban.dnd.instructions` bundle key, in the… (objectui `8a25feec5`) -- **patch** — A related list on a custom app page draws and runs its authored actions when its parent record is bound through `parentDataSource` (objectui#12870). (objectui `15b10655e`) -- **patch** — The approval decision panel's record summary honours a declared deadline's `settledWhen` (objectui#11815), judged against the snapshot it shows: a done record's past due date read… (objectui `bf376c5b3`) -- **patch** — The approvals inbox's record summary honours a declared deadline's `settledWhen` (objectui#11815), judged against the submitted snapshot it shows: a done record's past due date re… (objectui `bf376c5b3`) -- **patch** — A list view that shows a declared deadline but not the field its `settledWhen` reads now fetches that field, so a done record's past due date reads neutral instead of overdue (obj… (objectui `bf376c5b3`) -- **patch** — The data table's header cells name their column: each carries `data-column-key` with the column's `accessorKey` (objectui#12725). Studio's canvas reads it to tell which column a h… (objectui `e058d7697`) -- **patch** — A settings row's chips read in the session's language (objectui#12821). The env-lock chip said "Locked by env" in every language, and its title, shown when the server names no rea… (objectui `64acb7e59`) -- **patch** — The command palette's recently viewed rows now show what each item is: the record's object label on the right (in the viewer's language) and the object's icon on the left, in plac… (objectui `0e989914e`) -- **patch** — A refused activity read now shows one neutral sentence, "You don't have permission to view activity.", on Home's activity card and in the header bell's Activity tab. Home used to… (objectui `bb6c38cb6`) -- **patch** — A long api name no longer cuts a Studio item's title to a few characters (objectui#12791). (objectui `0c511f7d9`) -- **patch** — perf(app-shell): a console open reads the current app's objects, not the whole object registry (objectui#12811) (objectui `5ff64efc7`) -- **patch** — The flow designer names a schedule flow "Schedule", not "Schedule (cron)" (objectui#12845). (objectui `955dbaf8d`) -- **patch** — The workspace switcher, avatar menu, organisations page, create-organisation dialog and home screens fall back to the same "Organization" / "Environment" wording as the language p… (objectui `473e5a0a8`) -- **patch** — The sign-in page's tenant hint falls back to "You are signing in to this environment", matching the language packs (objectui#12851). (objectui `473e5a0a8`) -- **patch** — The console no longer calls two different things "workspace" (objectui#12851). (objectui `473e5a0a8`) -- **patch** — Dataset designer: picking a new dataset's base object no longer drops the template's `count` measure, so a dataset created by opening it, picking an object and saving keeps its ro… (objectui `fb1700372`) -- **patch** — A declared action `icon` is now drawn beside the label on the row's inline primary button, the row "⋮" menu item and the record-header quick action (`record:quick_actions`). The n… (objectui `1aa622a7a`) -- **patch** — Home's "Activity feed" card now shows the record activity panel's permission sentence ("You don't have permission to view activity…") when the server refuses its `sys_activity` re… (objectui `024d44694`) -- **patch** — In the collapsed (icon-mode) console sidebar, the hidden Area, Recent and Starred group labels are no longer announced by screen readers (objectui#12795). The `SidebarGroupLabel`… (objectui `e43a16207`) -- **patch** — In the collapsed (icon-mode) sidebar, a hidden group label is no longer a keyboard focus stop, and screen readers no longer announce it (objectui#12795). Before, Tab from the row… (objectui `e43a16207`) -- **patch** — A metric widget with no `layout` on a dashboard that declares `columns` now spans one grid row instead of two, the same height the dashboard designer gives a new widget. The tile… (objectui `db4417e21`) -- **patch** — Approvals Inbox: on a narrow screen the inline Approve / Reject column stays pinned to the table's right edge, with a shadow on its left edge while the table scrolls under it, ins… (objectui `4b3d6edae`) -- **patch** — Studio Interfaces names a package that has no app yet by its display name, the one the top bar prints, instead of its id (objectui#12718). The rail heading read `com.qa.recheck ·… (objectui `e1351a8e3`) -- **patch** — Metadata designer create flows no longer save the spec create seed's placeholder identity. The seed's `label` and its placeholder object binding are taken from the author or not a… (objectui `b921f9090`) -- **patch** — In the collapsed (icon-mode) console sidebar, the rail now scrolls vertically when its rows overflow, as the expanded sidebar does. Before, the rows below the fold could not be re… (objectui `0812a190f`) - -⚠️ 2 of these carry a breaking change: 2 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears 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(adr-0056): AdminScopeSchema target shape lists only the live keys (#12903) (objectui `5ec2175fd`) - -Clause-②: no - - - -objectui range: `5e15928b4631...0707b4ae3712` diff --git a/.changeset/pre/24284-search-all-internal-skip.md b/.changeset/pre/24284-search-all-internal-skip.md deleted file mode 100644 index ed9563af5e9..00000000000 --- a/.changeset/pre/24284-search-all-internal-skip.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-protocol): global search judges an object's searchable set without its `internal: true` fields, the set the engine scans - -Global search (`searchAll`, `GET /api/v1/search`) passes over an object with nothing to scan before it queries. It judged that from the object's fields including `internal: true` ones, which the engine never searches. An object whose only text field is internal therefore passed the skip and reached the engine, which now refuses a search over an empty set, so the whole sweep would have failed. The skip now leaves internal fields out, so such an object is passed over and the sweep answers `200` with the other objects' hits. - -What to do. Nothing. diff --git a/.changeset/pre/24284-search-empty-set-refused.md b/.changeset/pre/24284-search-empty-set-refused.md deleted file mode 100644 index 19d91b164b5..00000000000 --- a/.changeset/pre/24284-search-empty-set-refused.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -feat(objectql)!: a `search` over an object with no searchable field is refused with `400 INVALID_FIELD` instead of answering every row - -Clause-②: no (narrowing) - - - -A search term is matched against the object's resolved search fields: its `searchableFields` declaration (entries that name a stored field), or else the default set (the name/title field and the text, email, phone, url, autonumber, textarea, markdown and select fields). On an object where that set is empty, such as a junction object made of two lookups and a formula title, the term used to add no filter, so `?search=zzzz-nothing` and `?search=Dune` returned the same rows, and a user read every row as a match. - -- **What changes.** `find`, `findOne`, `count` and `aggregate` answer a non-blank `search` over an empty resolved set with `400 INVALID_FIELD` (`param: 'search'`). That covers every door that reaches the engine: the REST list and query routes (and the list's `total`), the grouped read, the dispatcher, the SDK and a hook's `ctx.api`. The message names the object, says why the set is empty, and gives the two remedies. -- **Unchanged.** A blank or whitespace term is not a search and still answers every row. An object with a searchable field filters as before. An explicit `searchFields` naming a field outside the set keeps its own `400 INVALID_FIELD` at the data door. Global search (`GET /api/v1/search`) still passes over an object with nothing to scan and answers `200`. - -What to do. If a read now answers `400 INVALID_FIELD` with "has no searchable field", declare `searchableFields` on that object naming a stored field, or mirror the composed title onto a stored text field and search that. A client that offers a search box on such an object should hide it, or show the refusal. diff --git a/.changeset/pre/24287-hono-stream-string-verbatim.md b/.changeset/pre/24287-hono-stream-string-verbatim.md deleted file mode 100644 index 7248e2f22a8..00000000000 --- a/.changeset/pre/24287-hono-stream-string-verbatim.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/hono": patch ---- - -fix(hono): on a `createHonoApp` host, `GET /api/v1/mcp/skill` answers the raw markdown with one `Content-Type: text/markdown; charset=utf-8` and `Cache-Control: no-store`, as the standalone server does - -Clause-②: no - -- **What a caller got.** The skill is served as a single string event on the dispatcher's `result` stream channel. The adapter's stream writer framed every event outside a Vercel data stream as `data: `, so the body was an SSE frame of a JSON-encoded string (`data: "---\nname: …"`), not markdown. It also set its own `Content-Type` and `Cache-Control` beside the domain's, so the answer carried `text/markdown; charset=utf-8, text/markdown; charset=utf-8` and `no-cache, no-store` (on a socket: two `Content-Type` lines and two `Cache-Control` lines). -- **What it answers now.** A string event is written as given, in every stream mode, and a header the producer sets replaces the adapter's default of the same name, whatever its case. Object events are still one `data:` frame each (one JSON line in a Vercel data stream), and a stream that sets no headers still gets `text/event-stream`, `no-cache` and `keep-alive`. -- **Unchanged:** the AI chat streams, which reach the same writer as pre-encoded Vercel data stream frames or as object parts, and every other route. diff --git a/.changeset/pre/24289-inprocess-hook-ctx-title.md b/.changeset/pre/24289-inprocess-hook-ctx-title.md deleted file mode 100644 index 251c77f5396..00000000000 --- a/.changeset/pre/24289-inprocess-hook-ctx-title.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): a hook's inline `handler` function is handed the same `ctx.title(field?)` a hook `body` gets, so a source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over an `objectstack.config.ts`) answers it as `objectstack dev` does (#24289) - -Clause-②: no - -A source boot runs a hook's inline `handler` in-process, and the accessor existed only in the sandbox a `body` runs in. So a hook calling `await ctx.title()` or `await ctx.title('account_id')` (a lookup column) answered on `objectstack dev` and an artifact boot, and under `bootStack(config)` refused every write with `TypeError: ctx.title is not a function`. `bindAppArtifactHandlers` now hands each inline handler function a view of the engine's hook context that answers `title` through the same accessor the sandbox installs: the same two forms, the same served formula title and related-record read on the context's own `ctx.api`, and `null`, never the record's id, when there is no title. Every other read and every write goes to the engine's context unchanged, and that context gains no key. A `handler` that names a function (the deprecated string form) is not covered by this change. diff --git a/.changeset/pre/24289-named-hook-handler-ctx-title.md b/.changeset/pre/24289-named-hook-handler-ctx-title.md deleted file mode 100644 index 7023b12348d..00000000000 --- a/.changeset/pre/24289-named-hook-handler-ctx-title.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/objectql': minor -'@objectstack/runtime': patch ---- - -fix(objectql,runtime): a hook whose `handler` names a function gets `ctx.title(field?)` in-process, as an inline handler and a `body` do - -Clause-②: yes (widening) - -- **What was wrong.** A hook whose `handler` is a string naming a function ran in-process without `ctx.title`: `defineStack({ functions })` plus a hook naming an entry on a source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over a config), and a string `handler` resolved from the artifact's `runtimeModule` (what `objectstack build` ships for a callable it could not lower to a `body`). The engine's binder resolved the name after the runtime had wrapped the inline handlers, so the function was called with the bare engine context and `await ctx.title()` threw `TypeError: ctx.title is not a function`. -- **What changed.** `bindHooksToEngine` (`@objectstack/objectql`) takes a `wrapHandler(handler, hook)` option, applied at bind to every handler a hook runs in-process, after a string `handler` resolves: an inline function, a name from the bind's `functions`, or a name the same package registered on the engine. A `body`'s handler (built by `bodyRunner`) never takes it. `ObjectQL.bindHooks` and the `IObjectQLEngine.bindHooks` contract (`@objectstack/spec`) carry the option. `bindAppArtifactHandlers` (`@objectstack/runtime`) passes the existing `withInProcessHookTitle` through it, in place of wrapping inline handlers before the bind, so the inline and the named handler take one wrap. -- **Unchanged.** A bind that passes no `wrapHandler` binds every handler exactly as resolved, so a host or harness that calls `bindHooks` itself, and a hook registered with `ql.registerHook(...)`, are not handed `ctx.title`. `HookContext` still declares no `title`. diff --git a/.changeset/pre/24291-cbp-docblock-tense.md b/.changeset/pre/24291-cbp-docblock-tense.md deleted file mode 100644 index 3b3d818d68f..00000000000 --- a/.changeset/pre/24291-cbp-docblock-tense.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Clause-②: no - -The `forceCbpMasterDetailRequired` docblock in `src/data/object.zod.ts` no longer says that today only -the security gate (`assertControlledByParentWrite`) closes the null-master-FK shape of a -`controlled_by_parent` object. That was true before the builder's force landed, and the sentence is now -in the past tense. It also names what has refused the declaration at parse since protocol 18: -`refuseControlledByParentWithoutOneMaster` refuses a `controlled_by_parent` object none of whose -`master_detail` fields declares `required: true`. No key, schema, export, accepted value or message text -changes. diff --git a/.changeset/pre/24293-filter-token-message.md b/.changeset/pre/24293-filter-token-message.md deleted file mode 100644 index 13d34bfba2b..00000000000 --- a/.changeset/pre/24293-filter-token-message.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/lint": patch ---- - -fix(lint): `filter-token-unknown` states what the runtime does with an unresolvable filter placeholder: the data engine refuses the query with `FILTER_TOKEN_UNKNOWN` / 400 - -Clause-②: no - -The finding's message said the value "is sent to the data engine as a literal string, matches no record, and the surface renders empty". The runtime does not do that. The data engine refuses a query that carries an unresolvable placeholder, `FILTER_TOKEN_UNKNOWN` with HTTP 400, rather than compare it as a literal string. The message now reads: - -```text -Filter value "{current_user}" is not a resolvable placeholder. The data engine refuses a query that carries it (`FILTER_TOKEN_UNKNOWN`, 400), so the surface never shows the data the filter meant. -``` - -Unchanged: the rule id, its severity (`error`), the finding's `path`, its `hint` (the `fix:` line), and which values the rule reports. The message's first sentence is unchanged as well. `os explain filter-token-unknown` already stated the refusal and is unchanged. diff --git a/.changeset/pre/24298-draft-key-provenance.md b/.changeset/pre/24298-draft-key-provenance.md deleted file mode 100644 index fa6692cebd3..00000000000 --- a/.changeset/pre/24298-draft-key-provenance.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): the draft read declares `draftChanges`, a pending draft's per-key provenance: which authoring surface last changed each key path of the draft, under which actor, and when - -Clause-②: yes (widening) - -- **The key.** `GetMetaItemResponseSchema` gains an optional `draftChanges` beside `item`: one entry per changed key path of the pending draft body, each `{ path, by, actor, at }`. `path` is an array of the keys from the body root to the deepest key the write changed (`["fields", "renewal_count"]`, `["fields", "renewal_count", "label"]`); an array value is one leaf, so a change to a view's columns is `["columns"]` and never carries an index (which elements differ is read by comparing the array with the published body's; the entry says who last changed the array); `[]` is the whole body of an item created as a draft. `by` is a closed set: `copilot` (an AI assistant's tool writing on a person's behalf), `studio` (a person editing in Studio) or `api` (any other caller of the save door). `actor` is the write's recorded identity or `null`, and `at` its ISO-8601 instant. -- **Where it is served.** Only on the strict draft read (`GET /meta/:type/:name?state=draft`), the read Studio's editor loads an item from, so the marks always describe the body that read returned. It is absent on the active read, the cached branch and the `?preview=draft` read. `ListDraftsResponseSchema` (`GET /meta/_drafts`) is unchanged. -- **Absent means "not recorded".** No server writes the key yet; until one does, and for a draft row saved before it did, the key is absent rather than `[]`, so a reader never takes "not recorded" for "nothing changed". -- **Nothing breaks.** Every response that parsed before still parses, and no runtime behaviour changes in this release of the spec. diff --git a/.changeset/pre/24299-api-key-read-outage.md b/.changeset/pre/24299-api-key-read-outage.md deleted file mode 100644 index 026a4e8732f..00000000000 --- a/.changeset/pre/24299-api-key-read-outage.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/core': minor ---- - -fix(core)!: an API key whose `sys_api_key` read fails is answered as the store outage (`503 SERVICE_UNAVAILABLE`), never as "no key", so the request no longer falls on to a cookie sent beside the key - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -`resolveApiKeyAdmission` (and `resolveApiKeyPrincipal` and `resolveAuthzContext`, which call it) read the key's row in a bare `catch` that answered "this request carries no key", and logged nothing. Two wrong answers followed on every door that admits API keys: - -- **A key-only caller got `401 UNAUTHENTICATED`** during the outage, where a session caller got `503 SERVICE_UNAVAILABLE` for the same fault. -- **A cookie sent beside the key answered for the request.** "No key" sent the request on to the session arm, so a request that led with a key was served as the cookie's user. Measured on the REST data door and the runtime dispatcher (served as the cookie's user) and on the settings door (`200`). - -The read now raises the branded `AuthzStoreUnavailableError` (object `sys_api_key`, the driver's error kept as `cause`), the answer the same function already gave when its lifecycle-policy or owner-posture source failed. Every door relays it as `503 SERVICE_UNAVAILABLE`, and the session is never read in the key's place. - -- **Unchanged.** A `sys_api_key` table that was never provisioned is still "no key": no key can exist there. This is the boundary `resolveAuthzContext` already draws for its permission reads (the shared `isMissingTableError`). No key header, no engine, no matching row and a row with no `user_id` also still answer "no key". - -What to do. Nothing, unless a client treated `401` during a database outage as "the key is bad": it now sees `503` and should retry, not rotate the key. diff --git a/.changeset/pre/24300-api-binding-rendered.md b/.changeset/pre/24300-api-binding-rendered.md deleted file mode 100644 index 985bc21aefc..00000000000 --- a/.changeset/pre/24300-api-binding-rendered.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -A page element's `api` data source is described as rendered, and `visibleWhen` says where `data` is mounted - -Clause-②: no (describe and TSDoc text only: `ElementApiDataSourceSchema` and `PageComponentSchema` accept exactly what they accepted before, and every refusal keeps its issue `code` and `path`) - -- `ElementApiDataSourceSchema`'s describe dropped its `[PLANNED — not yet rendered]` marker. It now says what the console renders: the console reads the binding on every page element and binds the value `path` selects as `data`, on that element and on every element nested inside it. The element's `visibleWhen` is decided once the read answers. While the read is in flight, or when the binding is refused or the read fails, the element draws a loading or error panel instead. -- `PageComponentSchema.visibleWhen` (its describe and its TSDoc) no longer says that no renderer reads the binding. The renderer mounts no `data` root on an element that no `api` data source binds, where a `data.*` read still faults and the gate never gates. On an element that one binds, `data` is the bound value. -- The `page:tabs` item `visibleWhen` describe's parenthetical now reads that the component-node evaluator mounts no `data` root "save on an element an `api` data source binds", where `data` is that binding's value. That surface's own evaluator still binds `data` to the record row. -- The binding's refusal and unknown-key messages no longer name a page element. For example, "`read.url` of a page element's `api` data source must be …" now reads "`read.url` of an `api` data source must be …", and the unknown-key surface is "this `api` data source". The `object` guidance names the object binding only where the `dataSource` takes one. Each message keeps its issue `code` and `path`. diff --git a/.changeset/pre/24301-seed-null-organization-holder-refused.md b/.changeset/pre/24301-seed-null-organization-holder-refused.md deleted file mode 100644 index a0af5ffd35d..00000000000 --- a/.changeset/pre/24301-seed-null-organization-holder-refused.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol)!: an organization-scoped seed load refuses a row whose natural key a row with no organization already holds, by name in the seed result, instead of inserting a twin of it - -Clause-②: no (narrowing) - - - -**BREAKING** (a seed load writes fewer rows), shipped as `minor` under the launch-window convention for breaking changes. - -An organization-scoped seed load (`SeedLoaderService` with `config.organizationId`: the per-organization replay, the marketplace walled install and the sample-data reseed) looked up existing rows by natural key among that organization's rows only. A row holding the same natural key with `organization_id` NULL, as a database whose rows predate organization stamping holds them, was invisible to that lookup, so the upsert inserted a twin beside it that carried the organization. Nothing was reported: 0 errors, nothing logged. On a HotCRM 4.0.1 → 5.0.0 upgrade this doubled every seeded event, task, attendee and line item. - -- **FROM:** in `upsert` or `ignore` mode, with `config.organizationId` set, a seed record whose natural key no row of that organization holds, but a row with no organization does, was inserted as a new row for the organization. -- **TO:** that record is refused. The load reports it in `errors` with the existing `ReferenceResolutionError` shape: `field: 'organization_id'`, `targetObject` the seeded object itself, `targetField` the natural key, `attemptedValue` its value, and a message that names the row holding the key and the remedy. It counts as errored, the load answers `success: false`, and the refusal is logged at `error`, once per dataset. The row with no organization is left exactly as it is: the loader never adopts it. Every other record still loads. -- **Unchanged:** a key the organization's own row holds is matched as before, even when an unowned row also holds it. Another organization's rows stay invisible to the lookup. A fully stamped database loads with 0 inserts. `update` mode (which never inserts) and the `insert` / `replace` modes are unchanged, and so is a load with no `config.organizationId` (the single-posture boot), which still matches the unowned row. - -What to do. Attribute the rows that have no organization with `os migrate organization-ownership` (plan, then `--apply`), then run the seed again: each row is then the organization's own, and the upsert matches it. diff --git a/.changeset/pre/24309-approval-current-step-label.md b/.changeset/pre/24309-approval-current-step-label.md deleted file mode 100644 index 2f43e681423..00000000000 --- a/.changeset/pre/24309-approval-current-step-label.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): `ApprovalRequestRow` declares `current_step_label`, the `sys_approval_request` column's own name for the step's label - -Clause-②: yes (widening) - -- **The member.** `ApprovalRequestRow` (`@objectstack/spec/contracts`) gains an optional `current_step_label?: string` beside `step_label`. It names the declared `sys_approval_request.current_step_label` column: the Approval node's authored label as it stood when the request reached the step. A page bound to `sys_approval_request` names the object's declared fields, and `step_label` is not one, so a served row that carries this member lets such a page list the step by its label instead of by `current_step`, the node's machine id. -- **Nothing moves.** `step_label` stays, at the same shape and with the same value, so no reader changes. The member is optional, so every existing implementer and every row built without it still conforms. It is a member of a TypeScript interface: no Zod schema and no runtime accept set changes. -- **Its producer.** `@objectstack/plugin-approvals`' request read mapping is the producer; serving the member there is that package's own change. diff --git a/.changeset/pre/24314-extension-no-injected-columns.md b/.changeset/pre/24314-extension-no-injected-columns.md deleted file mode 100644 index 9ecb71cbef9..00000000000 --- a/.changeset/pre/24314-extension-no-injected-columns.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -fix(objectql)!: an `objectExtensions` entry no longer adds platform system columns to the object it extends, so an extension of `sys_organization` or `sys_user` no longer hides the record from its own organisation - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The registry ran the injected-system-columns plan on every contributor, an extension body included. An extension body carries none of the keys the plan reads (`managedBy`, `systemFields`, `tenancy`, `ownership`), so it was planned as an ordinary object, and the merge added `organization_id`, `created_by` and `updated_by` (and on a non-`sys_` target, `owner_id` and `owning_business_unit_id`) to a target whose own declaration withholds them. On a multi-tenant deployment an extension of `sys_organization` or `sys_user` (`managedBy: 'better-auth'`) therefore created an `organization_id` column that is NULL on every row, and the tenant wall then answered a member's list of their organisation and of the users with `[]`, and their own records `404 RECORD_NOT_FOUND` by id. The injected definition also replaced a column the target declares itself. - -Now only a base layer (the owner, or a tenant overlay) is planned. The extended object's system columns are its base layer's, in whichever order the two packages register. - -- **Served less now:** an extended object whose declaration withholds a system column no longer reports or stores it. This covers `managedBy: 'better-auth'`, `systemFields: false`, `tenancy.enabled: false` and an `ownership` other than `user`. For example, `sys_sso_provider` (which plugin-auth extends with `oidc_client_secret`) no longer carries `created_by` or `updated_by`, and its `organization_id` is again the `text` column the object declares instead of the platform's read-only lookup. -- **Unchanged:** the extension's own fields, labels, validations and indexes merge exactly as before. An audit field an extension declares (`created_at`, `created_by`, `updated_at`, `updated_by`) is still forced read-only wherever the target carries the audit family. An extended ordinary object carries the same system columns, which come from its owner. No existing column is dropped from a database: a column an earlier release created stays in the table, and the registry no longer declares it. - -What changes for you: nothing to rewrite. If code read `organization_id`, `created_by` or `updated_by` on an extended `sys_*` identity object, it read a NULL column that the object never declared. Read the owner's own columns instead. diff --git a/.changeset/pre/24316-formula-hop-family-refusal.md b/.changeset/pre/24316-formula-hop-family-refusal.md deleted file mode 100644 index 50fbed43e80..00000000000 --- a/.changeset/pre/24316-formula-hop-family-refusal.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/objectql': minor -'@objectstack/lint': minor ---- - -fix(objectql)!: the one-hop read through a reference never reads a stored-metadata-body table, and the lint refuses that read where it is written - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The tables of the stored-metadata-body family (`STORED_METADATA_BODY_OBJECTS`) are projected or refused at every door that serves them. The one-hop read a formula field and a validation rule share (`resolveOneHopRelated`) was an ordinary engine read, which the family's projection does not reach. - -- **`@objectstack/objectql`.** A hop whose target is a family table is not read, whichever authority the hop is read under. A formula field reading through it is `null` with its reason, on every read, write response and `serveFormulaField` call (the seam a hook body's `ctx.title()` reads through). A validation rule reading through it has no verdict, so the write it judges is refused. The answer is the one an unreadable related record gets, so it says nothing about the row. A hop into any other object is unchanged. -- **`@objectstack/lint`.** A formula field's `expression`, or a validation rule's hydrated `condition`, that reads through a reference into a family table is an error, with the prescription. `os validate`, `os build` and the object save door run the same pass, so the author learns it at save rather than from a `null`. - -What to do. Remove the read through the reference. Stored metadata is read through the metadata API (`GET /api/v1/meta/:type/:name`), where its body is projected. Reading the reference field itself (the stored id) is unchanged. diff --git a/.changeset/pre/24326-main-region-width-refused.md b/.changeset/pre/24326-main-region-width-refused.md deleted file mode 100644 index d9aeacd66c9..00000000000 --- a/.changeset/pre/24326-main-region-width-refused.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/mcp': patch -'@objectstack/cloud-connection': patch ---- - -feat(spec)!: a page region named `main` takes only `width: 'full'` — `'small'`, `'medium'` and `'large'` there are refused at publish (#24326) - -Clause-②: yes (narrowing) - - - -**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention. - -`PageRegionSchema.width` is one enum shared by every region, so a `main` region accepted `'small'`, `'medium'` and `'large'`. The console never read them: every layout draws `main` at the width the layout leaves it (the flexible column beside `sidebar` and `aside`; one grid cell on `dashboard`), and the only reader of `main.width` is the page itself, for `'full'`, which lifts the page's max-width cap. A three-column page that asked for a `large` main got the space the two rails left, and every door reported success. - -- `PageSchema` now refuses `width: 'small' | 'medium' | 'large'` on a region named `main` (matched case-insensitively, as the console matches it), at `regions.N.width`, on every page kind and template. The message names the region and `'full'` as the one width it takes. The doors are `definePage`, `defineStack` (so `os validate` and `os build`, `STACK_SCHEMA_INVALID` 422) and the metadata save door (422 `INVALID_METADATA`). -- `'full'` on `main` is unchanged, with its page-wide meaning. Every other region accepts the same widths as before. -- The check is exported as `checkPageMainRegionWidth` from `@objectstack/spec/ui`, so a mirror built from `PageSchema.shape` can attach the same rule. -- Data at rest keeps loading: the retired ADR-0087 conversion `page-main-region-width-removed` deletes those widths on read: a stored page row, a compiled artifact inside the window its declared protocol range opens, and a package body registered with its declared range. The page renders exactly as before. -- `@objectstack/mcp` (the Connect an Agent page) and `@objectstack/cloud-connection` (the Cloud Connection page) drop `width: 'large'` from their `main` region. Both render the same. - -### Migration: FROM → TO - -| Wrote | Write instead | -| :--- | :--- | -| `width: 'small' \| 'medium' \| 'large'` on a region named `main` | drop it; `'full'` stays | - -Deleting the key changes nothing on screen. Write `width: 'full'` only when you want a full-bleed page, which drops the page's max-width cap. diff --git a/.changeset/pre/24327-runtime-this-system-copy.md b/.changeset/pre/24327-runtime-this-system-copy.md deleted file mode 100644 index d0bcafc6fa0..00000000000 --- a/.changeset/pre/24327-runtime-this-system-copy.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -Clause-②: no - -Ten user-facing strings the runtime's HTTP doors answer no longer call the system the caller is in -"this environment". The permission-set clone door's success notice (`PERMISSION_SET_CLONE_NOTICE`, the -`notice` beside the copy) now says the copy is "a new permission set authored in this system", the -wording the console's clone hint uses. The other nine drop the noun: `/mcp` and `/mcp/skill` on a -deployment that opts out answer `404` "MCP server is not enabled"; a share-link request with no -share-link service answers `501` "Sharing is not configured"; and the webhook-redelivery, approval -action-page and inbound automation-hook doors answer `501` "… no webhooks service is registered." (or -approvals, or trigger-api) when the service is missing, and "… the registered webhooks service does not -implement handleRedeliver." (or `handleActionPage`, or `handleInboundHook`) when it lacks its member. -Only the human-readable text moves: every HTTP status, error code and response shape is what it was. diff --git a/.changeset/pre/24328-history-source-describe.md b/.changeset/pre/24328-history-source-describe.md deleted file mode 100644 index 9771456115f..00000000000 --- a/.changeset/pre/24328-history-source-describe.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Clause-②: no - -The `source` describe on `HistoryMetaItemResponseSchema`'s events, the body of -`GET /api/v1/meta/:type/:name/history`, no longer lists `"fs"`, `"studio"`, `"rest"`, `"ai"` and -`"git-import"` as its labels: none of the write paths in `@objectstack/metadata-protocol` whose history -rows this door reads stamps any of them. It now says what the field is: an open code-path label the -server stamps, not an enum an author or client picks from, for example `protocol.saveMetaItem`, -`protocol.publishMetaItem` or `migrate-stored`, with `sys-metadata-repo` as the repository's own default -and the answer for a row that stored none. The field stays `z.string()`, so no key, type, export or -accepted value changes, and the door answers exactly what it answered before. diff --git a/.changeset/pre/24332-nav-item-api-data-source.md b/.changeset/pre/24332-nav-item-api-data-source.md deleted file mode 100644 index 28f55c72630..00000000000 --- a/.changeset/pre/24332-nav-item-api-data-source.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec): a navigation entry may declare one API data source, which its `visible` reads as `data` (planned, not yet rendered) - -Clause-②: yes (widening) - -- **What changed.** Every navigation entry that spreads the shared nav base (`object`, `dashboard`, `page`, `url`, `report`, `action`, `component`, `doc`, `group`) accepts an optional `dataSource`. It is the page element's API binding, `ElementApiDataSourceSchema`, by reference: `{ provider: 'api', read: { url, method? }, path? }` reads one same-origin endpoint with the console's signed-in fetch, and the entry's `visible` reads the value `path` selects as `data`, for example `visible: 'data.total == 1'`. -- **The refusals are the binding's own.** A `read.url` that is not a same-origin API path, a `method` other than `GET`, a credential key (`headers`, `token`, …), the object binding `{ object, … }` and an unknown key are all refused at parse, at the entry's `dataSource` path. A `separator` refuses `dataSource` with its own prescription. -- **What does not read it.** Only `visible` reads `data`. `label` and the target keys (`recordId`, `filters`, `params`) take no `data` read, and a group's binding is not published to its `children`: each entry declares its own. -- **Planned, not yet rendered.** No console reads the key yet, so a `visible` that reads `data` has no value to read until the navigation renderer ships. The describe opens with `[PLANNED — not yet rendered]`, and the liveness row `app` → `navigation.dataSource` is `planned` with an author warning (`os lint` / `os validate` report it on a top-level entry). -- **Who notices.** Nobody, by construction: every app that parsed before parses unchanged. Nothing narrows. diff --git a/.changeset/pre/24335-file-hydration-no-denial.md b/.changeset/pre/24335-file-hydration-no-denial.md deleted file mode 100644 index c7813744eab..00000000000 --- a/.changeset/pre/24335-file-hydration-no-denial.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): a record read that hydrates a file field no longer makes a `sys_file` access attempt for a reader without `sys_file` read - -The engine expands a stored file id by reading `sys_file`, and it issued that read as the caller. A member who may read the record but not `sys_file` (the platform default) was refused it, and the security layer logged `[Security] Access denied: operation 'find' on object 'sys_file'` at WARN on every by-id and list read by that member, although the body served was right. Those lines buried the real denials in the operator's log. - -The engine now asks the security service's `canReadObject('sys_file')` first, the same object-level read gate the security middleware applies, and serves a refused caller the field-owned answer without the read. Nothing else changes: - -- The served body is byte-identical, by id and by list, for a reader with and without `sys_file` read. A file owned by the record still hydrates, and every other id still reads `{ id, metadataRefused: true }`. -- A direct `GET /api/v1/data/sys_file/:id` by the same reader still answers `403 PERMISSION_DENIED` and still logs its denial. -- With no security service, or one that offers no `canReadObject`, the engine reads as the caller, as before. - -The ask is one cached permission check per read, not per row. - -Clause-②: no (no accept set or public surface moves: the served bodies and every refusal are unchanged; owes the seat's review of record at `TIER_DEFAULT` before the queue) diff --git a/.changeset/pre/24338-object-default-row-scope.md b/.changeset/pre/24338-object-default-row-scope.md deleted file mode 100644 index d5154221dd1..00000000000 --- a/.changeset/pre/24338-object-default-row-scope.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/metadata-core': patch ---- - -feat(spec): an object can declare its default row scope, `defaultRowScope`, the row-level security policy shape without `object` - -Clause-②: yes (widening) - -`ObjectSchema` gains one optional key, `defaultRowScope`: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object`, which the declaring object implies. The shape is exported as `DefaultRowScopeSchema` from `@objectstack/spec/security` (types `DefaultRowScope` and `DefaultRowScopeParsed`). - -- **What it parses.** Every member of `RowLevelSecurityPolicySchema` except `object`, the same member schemas: `name`, `label`, `description`, `operation`, `using`, `check`, `positions` and `enabled` (default `true`). The policy's two rules hold as written: at least one of `using` / `check`, and no `check` on a `select` or `delete` policy. -- **What it refuses.** `object` is refused by name, with a prescription to delete it: the target is implied. Any other unknown key is refused too, the policy's retired `priority` and `tags` included. -- **What it does.** The security plugin's row-policy collection (`collectRLSPolicies`), the one step every read, write and explain passes through, applies the key: when the permission sets the caller holds carry no row policy for the object and the operation (a `'*'` policy counts; one switched off, outside the caller's positions, or for another operation does not), the object's default is used in their place and judged like a held policy. A policy an authored set carries for the object is used as authored, with the default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object without the key keeps its behaviour. A `using` the row-filter compiler cannot lower makes the reads it governs return no rows, with one server warning. The platform's identity objects are to declare it for their own tables in a separate change. See the `@objectstack/plugin-security` entry in this release for the reader. -- **`composeStacks` with `objectConflict: 'merge'`** now refuses two stacks that declare a different `defaultRowScope` on the same object, the way it refuses every other fixed-shape config object (`access`, `enable`, `publicSharing`, …). The refusal message's list of those objects names `defaultRowScope` too. Nothing composed before is affected, because no stack declared the key. -- **`@objectstack/metadata-core`: the field-level-security projection classifies the new key.** When an object document is served to a caller who cannot read some of its fields, `defaultRowScope` is treated like a validation rule: the whole policy is dropped from the served document when its `using`, `check` or prose names a field that caller cannot read, and served unchanged otherwise. No behaviour moves until an object declares the key, and no object declares it yet. -- Additive: every object that parsed before still parses, and its parsed output is unchanged. diff --git a/.changeset/pre/24340-default-row-scope-live.md b/.changeset/pre/24340-default-row-scope-live.md deleted file mode 100644 index a80caeedade..00000000000 --- a/.changeset/pre/24340-default-row-scope-live.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -`defaultRowScope`'s describe, TSDoc and liveness row now say what the security plugin's reader does with the key - -Clause-②: no — only describe, TSDoc and liveness-ledger text move; `ObjectSchema` and `DefaultRowScopeSchema` accept and refuse exactly what they did. - -- `ObjectSchema.defaultRowScope`'s describe no longer says the key is not applied. It states what the row-policy collection does: the default applies when no permission set the caller holds carries a row policy for the object and the operation (a `'*'` policy counts; one switched off, outside the caller's positions, or for another operation does not), it is judged like a held policy, a `using` the compiler cannot lower makes the reads it governs return no rows with one server warning, and authoring checks do not judge that predicate. The TSDoc on the key and on `DefaultRowScopeSchema` say the same. -- `liveness/object.json`: the `defaultRowScope` row and its eight members are `live`, citing the security plugin's collection, compiler and explain as their readers. Authoring the key no longer raises the `liveness-planned-property` warning. diff --git a/.changeset/pre/24340-rls-object-default-scope.md b/.changeset/pre/24340-rls-object-default-scope.md deleted file mode 100644 index 1bf1586e078..00000000000 --- a/.changeset/pre/24340-rls-object-default-scope.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -fix(plugin-security)!: the row-policy collection applies an object's declared `defaultRowScope` when no permission set the caller holds carries a row policy for the object - -Clause-②: no (narrowing) — the narrowing is real only for a principal with no platform baseline whose permission sets carry no row policy for an object that declares a default; no object in this release declares one, and no export, option, schema or stored shape moves. - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -`ObjectSchema.defaultRowScope` declares an object's default row-level security policy. Until now no server read it. The security plugin's row-policy collection (`collectRLSPolicies`), which every read, write and explain composes from, now applies it: - -- **When it applies.** When the policies the caller's permission sets carry for the object and the operation come out empty, the object's default is collected in their place, for the declaring object. Empty means what the collection already meant by it: a held policy for every object (`'*'`) counts, while a held policy that is switched off, outside the caller's positions, or for another operation does not. -- **How it is judged.** Exactly as a held policy: its `operation`, `positions` and `enabled` decide where it applies, and its `using` and `check` compile through the same compiler. So it scopes reads, the by-id and bulk write filters, and the post-image check, and `checkAuthoredRowWrite` reads it as the declaration it is. A `using` the compiler cannot lower is dropped with the same warning and fails closed to zero rows, as an authored policy's does. -- **Explain** names an applied default as the object's own declaration, with its `name`, `label` and `description`: the row-level layer's `detail` says so, and a record-grained explanation names the rule after the default, reached `via: 'object_default'`. -- **Unchanged:** a policy an authored set carries for the object is used as authored, with the default not added to it; a principal whose superuser bypass skips row policies never reaches the collection; an object that declares no default collects exactly what it collected before. A deployment that composes the platform baseline is unaffected for the identity objects, whose shipped sets carry their own policies. - -What changes for you: nothing until an object declares `defaultRowScope`. Once one does, a principal whose permission sets carry no row policy for it reads only the rows the default admits. To give such a principal a different scope, put a row policy for the object in a permission set it holds; that policy is then used instead of the default. diff --git a/.changeset/pre/24343-8b0c-activity-never-null.md b/.changeset/pre/24343-8b0c-activity-never-null.md deleted file mode 100644 index f887d9178ad..00000000000 --- a/.changeset/pre/24343-8b0c-activity-never-null.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/plugin-audit": patch -"@objectstack/cli": patch ---- - -fix: an activity row is written only when an organization is derivable, never with a NULL organization, and the v18 ownership ceremony deletes the existing NULL activity rows it cannot attribute (ADR-0131 D9) - -Clause-②: no - -- **`@objectstack/plugin-audit`.** The audit writer's activity mirror (`sys_activity`) takes its organization from the record, then from the acting session, then from the `tenancy` service's Default Organization under `single`. When none of them names one, the mirror now writes no activity row at all. Before, it wrote a row with `organization_id` NULL. Under a wall that row was readable by no organization member. The change is still recorded: the compliance ledger row (`sys_audit_log`) is written as before, with its `tenant_id` attribution. Under `single` the Default Organization is always derivable, so nothing changes there. A record that names its organization keeps its activity row, with that organization. -- **`@objectstack/cli`.** `os migrate organization-ownership` still attributes a NULL `sys_activity` row when its record carries an organization. On a walled database (any posture but `single`), the mirror-deletion step now deletes the NULL rows no anchor attributes, such as activity about a sign-up's `sys_user`. Before, those rows were reported as unattributable and held certification. They are 14-day telemetry that the mirror no longer writes, and `sys_audit_log` keeps each change. The plan counts them as the departure `underivable-activity`, and warns before the first write that `--apply` deletes them. The apply report lists the count as the table's `deleted`. Under `single` the Default Organization takes these rows, as before, and nothing is deleted. diff --git a/.changeset/pre/24343-8b0f-grant-on-first-membership.md b/.changeset/pre/24343-8b0f-grant-on-first-membership.md deleted file mode 100644 index 698b1e5747d..00000000000 --- a/.changeset/pre/24343-8b0f-grant-on-first-membership.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/plugin-auth": patch ---- - -fix(plugin-auth): the self-registration grant settles on the registrant's first membership, owned by that organization (ADR-0131 D3) - -Clause-②: no - -The declared self-registration permission set (`auth.audience.selfRegistrationPermissionSet`) is an assignment, and an assignment is an organization row (ADR-0131 D3). It is now written with the registrant's first membership and owned by that membership's organization, under every posture. It is never written without an organization. - -- **`single`: unchanged.** The membership reconciler binds the Default Organization in the same `user.create.after` hook, so a sign-up still ends with one grant, owned by the Default Organization. -- **A wall (`group`, `isolated`).** A self-registrant belongs to no organization at sign-up, so the sign-up writes no `sys_user_permission_set` row. It used to write one with no organization. The grant is held as a pending claim in `sys_verification`, which is deployment-level and survives a restart. The first `sys_member` row written for the user lands exactly one grant, owned by that organization. That row can come from creating an organization (the creator becomes its owner), accepting an invitation, being added by an administrator, or any other membership writer. A later membership writes no second grant. -- **The claim waits 365 days.** A registrant who first joins an organization after that holds only the deployment's baseline. An engine with no middleware seam cannot settle a claim, so there a registrant with no organization is reported ungranted at `error` at sign-up instead. -- Grants already written without an organization by earlier walled sign-ups are not touched here. diff --git a/.changeset/pre/24343-producers-name-organization.md b/.changeset/pre/24343-producers-name-organization.md deleted file mode 100644 index 8965d236cbc..00000000000 --- a/.changeset/pre/24343-producers-name-organization.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -"@objectstack/plugin-audit": minor -"@objectstack/service-settings": minor -"@objectstack/plugin-auth": patch -"@objectstack/verify": patch ---- - -fix: framework writes on tenant-column objects name the organization they can derive (ADR-0131 D9) - -Clause-②: yes (widening) - -A write on a tenant-column object with no organization is derived under `single` and refused under a wall (ADR-0131 D9). Before the engine stops letting such writes through, each of these producers now names the organization it can derive itself. Under `single` that is the Default Organization, which is the owner the engine would derive anyway, so no reader changes. Under a wall the row names an organization only where the producer has one. The rows it cannot attribute are written exactly as before. - -- **`@objectstack/plugin-auth`.** The self-registration grant (`sys_user_permission_set`) and the auth mails about a user (verification, password reset, account invitation, magic link, change-email notice → `sys_email`) read one derivable organization: the `tenancy` service's `defaultOrgId()`. Under `single` that is the Default Organization. Under a wall it is none, because the registrant belongs to no organization yet. The verification and reset mails used to persist with no organization even under `single`. -- **`@objectstack/plugin-audit`.** An activity row (`sys_activity`) about a record with no organization of its own (a `sys_user`, a platform setting), written with no session organization (a sign-up), takes the Default Organization under `single`. The record's own organization and then the session's still win. The ledger's `tenant_id` attribution is unchanged. New optional `AuditWriterOptions.getDefaultOrganizationId()`; `AuditPlugin` wires it to the `tenancy` service. -- **`@objectstack/service-settings`.** The `sys_secret` ciphertext and the `sys_setting_audit` history row of a `tenant` or `user` value carry that value's organization, which is the same owner as its `sys_setting` row. A `global` value's rows name none. New optional `organization_id` on `SettingsSecretStore.insert`'s row and `organizationId` on `SettingsAuditWriter.write`'s entry; the built-in writers persist them. A failed `sys_setting_audit` write is now reported at `error`, not `warn`: the settings write succeeds while its history row is lost. The first report names the consequence and the fix. `buildSettingAuditWriter`'s optional logger argument keeps every sink it accepted (none, `{}`, or one with an optional `warn`) and additionally takes a sink that carries `error`. It reports through `error` when the sink has it, else through `warn`. -- **`@objectstack/verify`.** The RLS probe and position personas' grants (`sys_user_permission_set`, `sys_user_position`) belong to the organization the persona is a member of (the Default Organization under `single`). They used to be written with an explicit `organization_id: null`. A persona that belongs to no organization, or to several, names none. diff --git a/.changeset/pre/24348-import-control-opt-out-gate.md b/.changeset/pre/24348-import-control-opt-out-gate.md deleted file mode 100644 index c5d1c9532a8..00000000000 --- a/.changeset/pre/24348-import-control-opt-out-gate.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest)!: the import doors honour `runAutomations: false` and `treatAsHistorical: true` only for the system principal, and refuse them `403 PERMISSION_DENIED` to every other caller - -Clause-②: no (narrowing) - - - -**BREAKING** (the import doors accept fewer request values: `runAutomations: false` and `treatAsHistorical: true` are refused from every caller but the system principal, administrators included), shipped as `minor` under the launch-window convention for breaking changes. Two known consumers break visibly: objectui's Import Wizard, whose two options now answer `403` for every signed-in user (#24381), and hotcrm's dev-only demo script, which sent the opt-out as the dev administrator and now exits by its own submitter check. - -Both flags take an app's declared business controls off every row an import writes. `runAutomations: false` writes with `skipAutomations`, so no metadata-bound hook and no record-change trigger runs (an approval flow and a closed-record freeze hook among them). `treatAsHistorical: true` writes with `skipStateMachine` and `preserveAudit`, so the object's `state_machine` rule is skipped and supplied audit and read-only values are kept. Until now, any caller who may import could send either. - -**What changes.** `POST /api/v1/data/:object/import` and `POST /api/v1/data/:object/import/jobs` admit either opt-out only from the system principal (`ExecutionContext.isSystem`), the predicate the doors that start an elevated self-triggered flow already ask. Every other caller, every signed-in user and administrator included, gets `403 PERMISSION_DENIED` in the ADR-0112 envelope, with `error.details: { object, refused }` naming the opt-outs it sent. The doors ask before the body is parsed: a refused request writes no row and creates no import job, on a dry run too. A hidden object keeps its `404` ahead of the refusal. - -**What stays as it was.** A request that omits both keys, or sends `runAutomations: true` / `treatAsHistorical: false`, imports exactly as before, with every control in force. `runImport` (`@objectstack/core`) is unchanged: in-process callers that run as the system principal, and the identity import in `@objectstack/plugin-auth`, keep their own settings. - -**If an import now answers 403.** Send it without `runAutomations: false` and `treatAsHistorical: true`: the rows are written with the object's automations, hooks, `state_machine` rule and audit stamps in force. A bulk load that must skip them is a migration, and runs in-process as the system principal. diff --git a/.changeset/pre/24348-import-opt-out-describes.md b/.changeset/pre/24348-import-opt-out-describes.md deleted file mode 100644 index 24d1096158f..00000000000 --- a/.changeset/pre/24348-import-opt-out-describes.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -Clause-②: no - -The `runAutomations` and `treatAsHistorical` describes on `ImportRequestSchema` (and so `CreateImportJobRequestSchema`, the same schema) now say who may send the opt-out: only the system principal, an in-process caller such as a seed or migration job. The import doors refuse `runAutomations: false` and `treatAsHistorical: true` from every other caller, every signed-in user and administrator included, with `403 PERMISSION_DENIED` before any row is read. The `runAutomations` describe no longer tells a caller that it gets a silent bulk load by sending `false`, and the schema's example body no longer sends it. The schema shape and both defaults are unchanged. diff --git a/.changeset/pre/24349-page-walk-panel-children.md b/.changeset/pre/24349-page-walk-panel-children.md deleted file mode 100644 index 93ecd6575f5..00000000000 --- a/.changeset/pre/24349-page-walk-panel-children.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec)!: `defineStack`'s page cross-reference checks judge a component inside a `page:tabs` or `page:accordion` item's `children`, as they judge one at region level (#24349) - -Clause-②: yes (narrowing) - - - -**BREAKING** — an accept-set narrowing of what `defineStack` and `objectstack validate` publish, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. `defineStack` reads a page's components through one traversal, shared by its inline-action target check and its inline grid column check. A tab or accordion item is a panel record (`{ label, value?, children }`), not a component, and the traversal stopped at it, so nothing inside a tab or an accordion panel was judged: a button whose modal target names no page built green in a tab and was refused one level up. - -**`@objectstack/spec`** - -- **`defineStack`'s page cross-reference checks** now reach a component in a `page:tabs` or `page:accordion` item's `children`, at any depth (tabs in an accordion panel, a panel in a container or under a slot). The traversal descends the panel positions the component rows declare (`pageComponentSlotPositions`), the list the other page walks already read, so it follows the declared shapes rather than naming tabs. A record under a key no row declares a panel list is not descended. -- A component in a panel gets the region-level verdict and message, located through the panel: `STACK_CROSS_REFERENCE_INVALID` (422), for example `Inline action 'open_detail' on page 'invoice_record' (regions.0.components.0.properties.items.1.children.0) references page 'no_such_modal' (via modal target) which is not defined in pages.` -- Two checks reach further: an inline action's `type: 'modal'` / `type: 'flow'` `target`, and an identity-only column (one that declares no `type`) carrying `scale` over a `currency` child field, on an `object-master-detail-form` block's `details[].columns` or a `record:line_items` block's `columns`. That includes a `record:line_items` block in a tab or accordion panel, a position the block's identity-only check did not reach before this change. -- New step-18 migration entry `ui-page-panel-children-cross-references-judged`; the entry `ui-record-line-items-props-closed` now names the panel positions in its reach. - -## FROM → TO - -| you wrote, inside a `page:tabs` or `page:accordion` item's `children` | write instead | -|:--|:--| -| `{ type: 'element:button', properties: { action: { type: 'modal', target: 'no_such_page' } } }` | a `target` naming a page the stack declares (or a declared flow, for `type: 'flow'`) | -| `{ type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'amount', scale: 2 }] } }` where `amount` is a `currency` field of `invoice_line` | the same block with `columns: [{ name: 'amount' }]` | - -The one-line fix: give the component inside the panel the same fix it would need at region level. Nothing about the panel or its items changes. - -## Who is affected, measured - -On `origin/main` `31e7d0ba2`: four authored pages nest components under a panel item (the showcase project detail page, whose `record:line_items` block declares a `type` on all five columns; the platform user and organization record pages; the approval request record page), one documentation example does (three tabs of record blocks), and the published skills carry none. None holds an inline action target or an identity-only column under a panel, so no example, skill or fixture outside this change's own pins changes verdict. Deployed metadata was not measured. diff --git a/.changeset/pre/24358-family-hydration-refusal.md b/.changeset/pre/24358-family-hydration-refusal.md deleted file mode 100644 index 0af8613c9e0..00000000000 --- a/.changeset/pre/24358-family-hydration-refusal.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/objectql': minor ---- - -fix(objectql)!: `expand` and the master-detail `parent` root never hydrate a stored-metadata-body table - -Clause-②: no (narrowing) - - - -**BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The tables of the stored-metadata-body family (`STORED_METADATA_BODY_OBJECTS`) are projected or refused at every door that serves them, but only for the object a request names. Two relationship hydrations in the engine read a family row as an ordinary row, which that projection does not reach. - -- **`expand`.** A reference field whose target is a family table is not expanded, at any depth and for any caller: the field keeps its stored id, the answer a reference the caller may not expand already gets. This covers `expand` / `$expand` on the data read, `findOne`, the nested query form and a hook body's `ctx.api` read. Expanding a reference into any other object is unchanged. -- **The master-detail `parent` root.** A detail whose master is a family table never reads its header. `parent` is unbound, so a `requiredWhen` or `readonlyWhen` that reads it fails closed exactly as it does for a header the caller cannot read, on insert, update and the `validate()` preview, and the verdict never depends on the row. A master outside the family is unchanged. - -What to do. Read stored metadata through the metadata API (`GET /api/v1/meta/:type/:name`), where its body is projected, rather than by expanding a reference to it or reading it as a master-detail header. The stored id on the reference field is unchanged. diff --git a/.changeset/pre/24360-metadata-protocol-this-system.md b/.changeset/pre/24360-metadata-protocol-this-system.md deleted file mode 100644 index 4614ddc6489..00000000000 --- a/.changeset/pre/24360-metadata-protocol-this-system.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Clause-②: no - -fix(metadata-protocol): the save door's lock refusals and its default-record-page refusal say "authored in this system" - -- **What changed.** Three refusal texts said an item saved through the metadata API is "authored in this environment". They now say "authored in this system": the `protection`-block refusal, the lock-family-key refusal, and the holder text a default-record-page conflict prints for a page no package ships (`page 'NAME', authored in this system`). Inside the running system, user-facing copy names the system the user is in as "this system". -- **Unchanged.** Every error code (`VALIDATION_ERROR`, `DEFAULT_RECORD_PAGE_CONFLICT`), status and `issues[]` entry. diff --git a/.changeset/pre/24360-objectql-this-system.md b/.changeset/pre/24360-objectql-this-system.md deleted file mode 100644 index 216bd17a6cd..00000000000 --- a/.changeset/pre/24360-objectql-this-system.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -Clause-②: no - -fix(objectql): the default-record-page and security-catalog refusals name an item made here, not shipped by a package, as "authored in this system" - -- **What changed.** The holder text a `DEFAULT_RECORD_PAGE_CONFLICT` refusal prints for a page no package ships now reads `page "NAME", authored in this system`, and the holder text a `NAMESPACE_CONFLICT` refusal prints for the environment catalog reads `the environment catalog (an item authored in this system, not shipped by a package)`. Both said "authored in this environment". Inside the running system, user-facing copy names the system the user is in as "this system". -- **Unchanged.** Every error code, status and envelope field (`existing`, `incoming`, `existingHolder: { kind: 'environment' }`). "The environment catalog" is the documented name of that holder (ADR-0048), so it stays. diff --git a/.changeset/pre/24360-platform-objects-this-system.md b/.changeset/pre/24360-platform-objects-this-system.md deleted file mode 100644 index 686631579f8..00000000000 --- a/.changeset/pre/24360-platform-objects-this-system.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -Clause-②: no - -fix(platform-objects): `sys_metadata_activation`'s description and its `package_id` field description say "authored in this system" - -- **What changed.** The activation ledger's object description and the description of its `package_id` field described an item no package ships as "authored in this environment". Both now say "authored in this system". Inside the running system, user-facing copy names the system the user is in as "this system". -- **Unchanged.** The object's fields, their types and every stored value. diff --git a/.changeset/pre/24366-storage-gate-credential.md b/.changeset/pre/24366-storage-gate-credential.md deleted file mode 100644 index 3b657242c18..00000000000 --- a/.changeset/pre/24366-storage-gate-credential.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/service-storage': minor ---- - -fix(service-storage)!: the storage family gate and the route judge the same credential - -Clause-②: no (narrowing) — a session the ADR-0069 policy holds is refused on every storage route whatever else the request carries; no published schema, export or option moves. - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate, and every route of the storage family is one. The family's gate and its routes now judge the same credential: the gate judges every credential a route of the family can serve. - -- **Refused now:** a session the policy holds gets `403` with the gate's own `code` (`PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` or `MFA_REQUIRED`) and `message` on every storage route, whatever else the request carries, before the family does anything else. While a gate is active, a session that cannot be read is the family's declared `503` (`SERVICE_UNAVAILABLE`), whatever else the request carries. -- **Unchanged:** a session the policy does not hold is served exactly as before, apart from that outage answer; an API key whose owner holds an `account`-scope posture is refused with that posture, as before; and while no gate feature is active nothing changes. - -What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before any storage route answers them, whatever else their request carries. There is nothing to configure. diff --git a/.changeset/pre/24368-services-this-system-copy.md b/.changeset/pre/24368-services-this-system-copy.md deleted file mode 100644 index 7bacbf6216d..00000000000 --- a/.changeset/pre/24368-services-this-system-copy.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -'@objectstack/plugin-auth': patch -'@objectstack/plugin-security': patch -'@objectstack/service-settings': patch ---- - -Clause-②: no - -Refusals and Setup help text that a user reads inside the running system no longer call that system -"this environment". They say "this system", or they drop the noun. - -- `@objectstack/plugin-auth`: the two self-registration refusals drop the noun ("Self-registration is - closed (audience posture invite_only).", "Self-registration is limited to approved email domains, …"). - So do the OAuth self-service `503` ("OIDC provider is not enabled") and the SSO `400` - `DOMAIN_VERIFICATION_DISABLED` ("Domain verification is not enabled (set - OS_SSO_DOMAIN_VERIFICATION)."). The last-administrator guard's refusals and its two warning lines say - "this system", including "nobody able to administer the system". The OIDC authorize-gate refusal - (`403 ENV_ACCESS_DENIED`) keeps "this environment". Only the cloud control plane installs that gate, - and there the noun names the environment a cloud user is signing in to. -- `@objectstack/plugin-security`: the diagnostic `message` of the packaged-permission-set lock now reads - "… is declared by package '…' and is locked — editing it here would …". The provenance-unknown - refusal says "this system could not determine …". Both `userMessage` texts are unchanged. -- `@objectstack/service-settings`: the Mail and SMS provider groups read "Choose how this system sends - outbound email." and "… outbound SMS.". The Authentication audience group reads "Who may become a user - of this system's apps.". The zh-CN, ja-JP and es-ES bundles change only the noun, to 本系统, - このシステム and este sistema. - -Only the human-readable text moves. Every HTTP status, error code, settings key and response shape -stays as it was. diff --git a/.changeset/pre/24371-data-read-one-overlay-read.md b/.changeset/pre/24371-data-read-one-overlay-read.md deleted file mode 100644 index b83e8e96603..00000000000 --- a/.changeset/pre/24371-data-read-one-overlay-read.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Clause-②: no - -perf(metadata-protocol): the by-name item read reads the item's stored active rows once, so a `GET /api/v1/data/:object` makes one `sys_metadata` read of the object instead of five - -- **What changed.** `getMetaItem` (the read the API-exposure gate makes on every `/data` request) used to send its three active-row selections to the store one at a time: which package the item belongs to, the served row once per spelling of the type (`object`, then `objects`), and the rows its lock is chosen from, once per spelling. Now it reads the item's active rows once, for both spellings (`type IN ('object', 'objects')`), and each selection picks its rows from that one read with the same conditions it used to send the store. The item read's `version` reuses the package binding the read already selected. -- **Measured.** On Postgres behind a 50 ms round-trip proxy, the median `GET /api/v1/data/todo_task` (example todo app, signed-in admin) went from 551 ms with 5 `sys_metadata` statements to 340 ms with 1. -- **Unchanged.** The served item, its lock and `version`, and every refusal stay the same. Nothing is cached across calls, so a metadata write is seen by the next request. An object with a stored row still has one more read behind `version` (the repository's write-address head). diff --git a/.changeset/pre/24380-import-undo-controls.md b/.changeset/pre/24380-import-undo-controls.md deleted file mode 100644 index 6bdff62ce22..00000000000 --- a/.changeset/pre/24380-import-undo-controls.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/rest': minor ---- - -fix(rest)!: an import undo runs under the object's hooks, record-change triggers and state machine for every caller but the system principal, and reports each row a control refuses instead of counting it as reverted - -Clause-②: yes (narrowing) - - - -**BREAKING** (the import undo door takes fewer controls off its writes, and claims less: `POST /api/v1/data/import/jobs/:jobId/undo` keeps `skipAutomations` and `skipStateMachine` on its reversal writes for the system principal alone, so every other caller's undo, administrators included, runs under the object's metadata-bound hooks, record-change triggers and `state_machine` rule; and an undo that leaves any row unreverted now answers `success: false` and leaves the job unstamped, where it answered `success: true` and stamped it reverted), shipped as `minor` under the launch-window convention for breaking changes. The cost is named, not hidden: undoing an ordinary import may fire record-change automation again (a notification, an approval re-opening on a restored amount), which is the app's declared behaviour for those writes. If a row now stays unreverted, read its entry in `failures`, resolve what refuses it on the app (complete the approval, reopen the record) and undo the job again, which retries only the rows that remain; a reversal that must skip the controls runs in-process as the system principal. - -The same rule now holds at every import door: only the system principal may take an app's declared controls off a write. The import doors already refused a caller-chosen opt-out from everyone else; the undo door chose its skips itself, for every caller, and no longer does. - -**What changes.** The undo's deletes (of the records the job created) and restores (of the fields it updated) are written as `ExecutionContext.isSystem` decides: the system principal keeps `skipAutomations` and `skipStateMachine`; every other caller writes with neither. A reversal that a hook, the state machine, a record lock or anything else refuses is caught for its row and never swallowed: - -- the answer lists it in a new `failures` array, one `{ id, action, code, error }` per row, where `action` is `delete` or `restore` and `code` / `error` are what the single-record write door answers the same error with (a hook's refusal reads in the hook's own sentence); `failed` is its length and `success` is `false`; -- the job's stored undo log is rewritten to exactly the unreverted rows, each refusal beside them under `failures`, and `reverted_at` is not stamped. The job stays `undoable`, and undoing it again retries only those rows, never one already reverted. `reverted_at` (`revertedAt` on the job's progress and history) now means every row was reverted. - -**What stays as it was.** An undo that no control refuses reverts every row and answers as before, with `failures: []`. A historical job (`treatAsHistorical`) keeps `preserveAudit` on its undo for every caller. The job-row write that records the outcome stays engine-owned (ADR-0103). The 404, 409 `ALREADY_REVERTED` and 422 `NOT_UNDOABLE` answers are unchanged, and the import doors and their opt-out gate are untouched. diff --git a/.changeset/pre/24380-undo-response-failures.md b/.changeset/pre/24380-undo-response-failures.md deleted file mode 100644 index b6db044769a..00000000000 --- a/.changeset/pre/24380-undo-response-failures.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -fix(spec)!: `UndoImportJobResponseSchema` requires `failures`, one entry per row an import undo did not revert, and the `skipAutomations` describe names no import-undo exception - -Clause-②: yes (narrowing) - - - -**BREAKING** (`UndoImportJobResponseSchema` accepts fewer answers: `failures` is required, so a response parsed with the schema is refused without it, and code that constructs an `UndoImportJobResponse` must set it, `[]` on a full revert; readers of `client.data.undoImportJob` are unchanged and gain the key), shipped as `minor` under the launch-window convention for breaking changes. - -`UndoImportJobResponseSchema` gains a required `failures`: one `{ id, action, code, error }` entry per row an import undo did not revert, `action` being `delete` or `restore`, and an empty array when every row was reverted. It is required rather than optional so a server that does not report its unreverted rows is off-contract instead of silently spec-valid. The `success` describe now says it is `false` when any row is listed there, and `failed` counts those entries. `revertedAt` on `ImportJobProgressSchema` and `ImportJobSummarySchema` now says it is set only once every row of the job is reverted; an undo that left rows unreverted sets nothing, and the job stays undoable for them. - -The `skipAutomations` describe on the hook context's `session` no longer names the import undo as a case that skips an app's controls for any caller. It names the system principal's import and undo, and says the import undo door is not an exception: only the system principal may take an app's declared controls off a write, and any other caller's undo runs under the object's hooks, record-change triggers and state machine. The `ExecutionContext` TSDoc for `skipAutomations` says the import runner sets it from its caller's `runAutomations: false`, which the import doors admit over HTTP from the system principal alone while an in-process caller of the runner passes its own setting, and that the import undo sets it only when the system principal undoes a job; the `skipStateMachine` TSDoc says the same of the undo. diff --git a/.changeset/pre/24384-platform-objects-org-admin-reach.md b/.changeset/pre/24384-platform-objects-org-admin-reach.md deleted file mode 100644 index 93c4d4456e9..00000000000 --- a/.changeset/pre/24384-platform-objects-org-admin-reach.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/platform-objects': minor ---- - -`sys_organization`'s organisation-admin actions are now offered only to the membership grades whose requests the door accepts. Before, they were gated on `multiOrgEnabled` alone, so a plain member saw them, and better-auth refused every click with 403: - -- `update_organization` ("Edit Organization", list row) and `rename_organization` ("Rename Organization", record header and list row) declare `requiresMembershipReach: 'update_organization'`: owner and admin. -- `delete_organization` ("Delete Organization", list row) declares `requiresMembershipReach: 'delete_organization'`: the owner alone. - -The `multiOrgEnabled` gate stays as the last term of each predicate. `invite_user` is unchanged, and `set_active_organization`, `leave_organization`, `create_organization` and `change_slug` stay as they were. - -Clause-②: yes (widening) diff --git a/.changeset/pre/24384-spec-org-reach-rows.md b/.changeset/pre/24384-spec-org-reach-rows.md deleted file mode 100644 index 00ee6374306..00000000000 --- a/.changeset/pre/24384-spec-org-reach-rows.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -`MEMBERSHIP_REACH` (`@objectstack/spec/identity`) gains two rows, so `requiresMembershipReach` accepts two more names: `update_organization` (better-auth's `/organization/update`, statement `organization: ['update']`, reached by `owner` and `admin`) and `delete_organization` (`/organization/delete`, statement `organization: ['delete']`, reached by `owner` alone). Both are read off better-auth's own default role statements, like the existing rows, and plugin-auth's reach-table test holds each equal to the installed vendor door. An action declaring `requiresMembershipReach: 'update_organization'` is lowered to `visible: "'org_owner' in current_user.positions || 'org_admin' in current_user.positions"`; `'delete_organization'` lowers to `"'org_owner' in current_user.positions"`. Existing rows and their lowering are unchanged. - -Clause-②: yes (widening) diff --git a/.changeset/pre/24387-port-zero-busy-warning.md b/.changeset/pre/24387-port-zero-busy-warning.md deleted file mode 100644 index 24e84855300..00000000000 --- a/.changeset/pre/24387-port-zero-busy-warning.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/plugin-hono-server': patch ---- - -fix(plugin-hono-server): a request for port 0 no longer warns that port 0 is in use - -Clause-②: no (no export, option or type moves: a log line stops firing for a legal request) - -- **What changed.** `HonoServerPlugin`'s `kernel:listening` hook compared the requested port with the bound one and warned `Port 0 is in use, using port N instead` whenever they differed. Port `0` asks for a kernel-assigned port, so the two can never match and nothing was in use. Every `os serve --port 0` boot printed that warning under the banner's Boot diagnostics, at the default log level and at `info`. The warning now fires only for a non-zero request that the adapter moved to another port. -- **Unchanged.** A kernel-assigned boot prints the existing `HTTP server started successfully` line, which already names the bound port, and nothing else. A non-zero request whose port is held still warns, naming the requested and the bound port. diff --git a/.changeset/pre/24394-page-action-row-patch-retired.md b/.changeset/pre/24394-page-action-row-patch-retired.md deleted file mode 100644 index eea209cf670..00000000000 --- a/.changeset/pre/24394-page-action-row-patch-retired.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -'@objectstack/spec': minor ---- - -feat(spec)!: retire a page action row's own `patch` — a row's `operation: 'update'` runs the declared action its `name` names, and the platform action route writes that action's `patch` (#24394) - -**BREAKING** — `patch` on a page action row (`action:button`, `action:icon`, and each `action:group` / `action:menu` member of `properties.actions`) is now refused. A row's `operation: 'update'` posts to the platform action route under the row's `name`. The route resolves that declared action by name and writes the action's own `patch`, merged under the request's params. The row's `patch` was never sent: the reference console read it only for its Undo snapshot. Beside `operation: 'update'` a row now needs a `name`, since without one it has no declared action to run and the console refused it before any request. A row's static `params` are written only where that action declares them, and the route refuses any other with a 400 (ADR-0104 D2). `ActionSchema.patch`, the action's own declaration, is unchanged. - -Clause-②: yes (narrowing) - -Both directions, measured on the built rows. Narrowing: every row `patch` is refused, and a row with `operation: 'update'` and no `name` (an input list or static `params` included) is refused at `name`. Widening: a row with `operation: 'update'` and a `name` but no `patch` and no `params` now parses; before, the rows refused it at `patch` as having nothing to write, though its write is the named action's `patch`. - -Measured before removal, at `71fa166e96`: no producer. No page action row in this repository's examples or apps authors `patch` (or any `operation`), and neither does any objectui page row at the pinned `0707b4ae37` or at objectui `main` `4b7ce47`. - -## FROM → TO - -| you wrote (17.7 and earlier) | write instead | -| --- | --- | -| `operation: 'update'` with a `patch` on a page action row | the values in the `patch` of the declared action the row's `name` names (`defineAction({ name, operation: 'update', patch })`), the row keeping only `operation: 'update'` and that `name` | -| `patch` on a page action row with no `operation` | nothing — delete the key; it was already refused | -| `operation: 'update'` on a page action row with no `name` | a `name` naming the declared `operation: 'update'` action | - -**The one-line fix:** move each row's `patch` values onto the declared action its `name` names, then delete the row's `patch`. - -**What an author who still writes it sees.** `tsc` fails at a typed authoring site (the rows type the key as retired), and `os validate`, `os build` and `os lint` refuse it as an `error` (`component-props-invalid`) at its own path (`properties.patch`, `properties.actions.N.patch`) with the prescription: - -> An `action:button`'s `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. 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. - -A page's own parse keeps its component `properties` as an open bag, so an existing page carrying the key is never refused at load. - -## The retirement kit - -- **A `retiredKey()` tombstone** at `patch` on `ActionButtonProps`, `ActionIconProps` and the `action:group` / `action:menu` member. `RETIRED_KEYS_BY_MAJOR[18]`: `ui/ActionButtonProps:patch`, `ui/ActionIconProps:patch` (the member's keys have no surface row of their own). The authorable-surface rows read `[RETIRED]`. -- **The rows' half of the declarative-update judge** reads the row's own surface: a row has no "`patch` without `operation`" rule of its own (its tombstone refuses every `patch`), and its "nothing to write" rule asks for the `name`. `ActionSchema`'s half of every rule, and every text it prints, is unchanged. -- **The D2 conversion `page-action-row-patch-removed`** (protocol 18, retired from the load path) deletes the key from every node and member — region-level, nested in a container's `children`, and in a slotted page's named slot — one notice per row. It is scoped by component `type`: the same key name on another component is left alone. -- **The D3 entry `ui-page-action-row-patch-retired`** records the judgement the strip cannot make: when the named action's own `patch` lacks a value the row carried, that value was never written, and adding it to the action changes what the action writes. -- **No deprecation window**, per the project's startup-stage posture. - -**Not part of it:** objectui's renderers still forward the row's `patch`, its runner still reads it for the Undo snapshot, and its component registry still lists a `patch` input on `action:button` / `action:icon` (so the tracked `sdui.manifest.json` advertises it to the html-tier page gate until the pin moves). Those reads leave on the objectui side. - -⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. - - diff --git a/.changeset/pre/24395-draft-changes-surface.md b/.changeset/pre/24395-draft-changes-surface.md deleted file mode 100644 index 8fae6d4d0b0..00000000000 --- a/.changeset/pre/24395-draft-changes-surface.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): a metadata save declares its authoring surface — `SaveMetaItemRequestSchema.surface`, in one exported closed set shared with `draftChanges[].by` - -Clause-②: yes (widening) - -- **One closed set, one name.** `MetadataAuthoringSurfaceSchema` (`copilot | studio | api`) is exported from `@objectstack/spec/api`, with its type `MetadataAuthoringSurface`. `GetMetaItemResponseSchema.draftChanges[].by` now reads it instead of an inline enum: the accepted values are unchanged. -- **The save states its surface.** `SaveMetaItemRequestSchema` gains an optional `surface` from that set. A draft save stamps it as `by` on each key path it changes; an absent `surface` stamps `api`. On the REST door and the runtime dispatcher it is the `?surface=` query parameter, which takes `studio` or `api` only: `copilot` is stated by an in-process AI tool, never over HTTP. -- **The writer exists.** The `draftChanges` describe no longer says no server writes it: it states the writer's rules (the first draft save is compared with the stored published row, an item with no stored published row is recorded as `[]`, and a `migrate-stored` rewrite records nothing). `18.0.0-next.4`'s note said no server writes `draftChanges` yet; from this release the draft save writes it. diff --git a/.changeset/pre/24395-draft-changes-writer.md b/.changeset/pre/24395-draft-changes-writer.md deleted file mode 100644 index 2eba916839e..00000000000 --- a/.changeset/pre/24395-draft-changes-writer.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/metadata-protocol": minor -"@objectstack/metadata-core": minor -"@objectstack/platform-objects": minor ---- - -feat(metadata-protocol): the draft save records `draftChanges` — which surface, actor and instant last changed each key path of a pending draft — and the strict draft read serves it - -Clause-②: yes (widening) - -- **Recorded by the draft save.** `saveMetaItem({ mode: 'draft', surface })` compares the body it writes with the body it replaces, inside the write's own transaction: the draft row, else (the draft's first save) the active row its publish would replace, else nothing, which records `[]`, the whole body. A path is the object keys from the body root to the deepest changed key; an array is one leaf, with no index. Each changed path is stamped `{ by: surface ?? 'api', actor, at }`, where `actor` is the value the save's history event carries (or `null`) and `at` is the write's instant as `toISOString()`. -- **Kept until publish or discard.** No two entries share a path: a save replaces every entry at or under each path it changes, so the longest prefix answers for a key. A save that changes no key path leaves the entries as they were. Publish and discard delete the draft row, and the entries with it, so the next draft starts clean. A `migrate-stored` rewrite and an active save record nothing. -- **Served on the strict draft read only.** `getMetaItem({ state: 'draft' })` answering a stored draft row carries `draftChanges` beside `item`. It is absent on the active read, the cached branch, the `?preview=draft` read, and a draft row whose saves recorded none (one saved before this release) — never `[]`. -- **Stored on the row.** `sys_metadata` gains a nullable `draft_changes` column (`@objectstack/metadata-core`'s `SysMetadataObject`), never inside the stored body. Schema sync adds it to an existing table on boot with no manual step (measured on SQLite and the memory driver). -- **Translated labels.** `@objectstack/platform-objects`' translation bundles carry the new column's label and help text in every shipped locale. -- **An unknown `surface` is refused.** In-process, a value outside `copilot | studio | api` is refused `400 INVALID_REQUEST` before anything is written. diff --git a/.changeset/pre/24395-http-save-surface.md b/.changeset/pre/24395-http-save-surface.md deleted file mode 100644 index 22747fcea07..00000000000 --- a/.changeset/pre/24395-http-save-surface.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -"@objectstack/rest": minor -"@objectstack/runtime": minor ---- - -feat(rest,runtime): `PUT /meta/:type/:name?surface=studio|api` declares the save's authoring surface on both HTTP save doors; `?surface=copilot` is refused - -Clause-②: yes (widening) - -- **The spelling.** `?surface=` joins the save door's closed query set on `RestServer` and on the runtime dispatcher (`META_ITEM_SAVE_PARAMS`), read once through `metaSaveRequestOptions` beside `?mode=`. A draft save stamps it on each key path it changes (`draftChanges[].by` on the strict draft read); without it the save is recorded as `api`. `?surface=studio` is the spelling for a person's save in Studio. -- **The refusals.** `?surface=copilot` is `400 VALIDATION_ERROR`: it marks a write as the AI copilot's, which only an in-process tool calling `saveMetaItem` may state. Any other value, a case variant included, is the same `400`, never read as `api`; a repeated `?surface=` is refused by the arity rule. Each refusal writes nothing. -- **Why a query parameter.** It is what the save is asked to do, as `?mode=` and `?force=` are, beside the HTTP preconditions the headers carry; and it needs no CORS allow-list entry, which a custom request header would. diff --git a/.changeset/pre/24400-bound-action-foreign-object.md b/.changeset/pre/24400-bound-action-foreign-object.md deleted file mode 100644 index 669f9a7972a..00000000000 --- a/.changeset/pre/24400-bound-action-foreign-object.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/objectql': minor ---- - -feat(spec,objectql)!: a top-level action bound by `objectName` to an object another package owns is listed on that object wherever it is served, and a bound action whose object no loaded package registers fails the boot - -Clause-②: yes (narrowing) - - - -**BREAKING** (one narrowing beside a widening), shipped as `minor` under the launch-window convention for breaking changes. - -- **Widening** (`@objectstack/spec`, `@objectstack/objectql`): an action declared in a stack's top-level `actions` with `objectName` naming an object that another package owns now reaches that object. `defineStack()` accepts the action when the stack extends the object through `objectExtensions`, or when the object is a known platform object (`sys_organization` and the rest of the platform's named list). It keeps the action in the top-level `actions`. The engine's schema registry then attaches it to the object at the one step every object read ends in. That covers `GET /api/v1/meta/object/` (stored-row, MetadataService and registry layers alike), `engine.getSchema()` and the action router's object lookup, in whichever order the two packages register. The attached entry is the action's own declaration, so its `requiredPermissions`, `requiresMembershipReach` and execution identity are unchanged. An action the object already declares under the same name keeps the object's declaration. A disabled package's bound actions are not attached. Before this, such an action was refused by `defineStack()` whenever the stack declared an object of its own. Where it got through (an object-less stack, or `strict: false`), it registered but was listed on no object, so no client rendered it. -- **FROM** (`@objectstack/objectql`): a top-level action bound by `objectName` to an object no loaded package registers was registered and listed nowhere, and the boot succeeded. **TO:** the boot fails at `kernel:ready` with `INVALID_METADATA` (422), naming each such action, its package and the object. An artifact registered through the `manifest` service after the boot is refused before any of it registers. **Fix:** register the object (in the package or in one it depends on), or correct the action's `objectName`. -- **Unchanged:** a name `defineStack()` cannot attribute (an object this stack neither defines nor extends, and not a known platform object) is still refused at build with `STACK_CROSS_REFERENCE_INVALID`. A same-stack bound action is still merged into its object by `defineStack()` and is attached once. - -The `ObjectExtensionSchema` refusal for `actions` now says exactly this: declare a top-level action with `objectName: ''` in the same stack; the schema registry attaches it to the object wherever the object is served; a target no loaded package registers fails the boot. diff --git a/.changeset/pre/24404-cloud-connection-this-system.md b/.changeset/pre/24404-cloud-connection-this-system.md deleted file mode 100644 index aa9ee9d888b..00000000000 --- a/.changeset/pre/24404-cloud-connection-this-system.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/cloud-connection': patch ---- - -Clause-②: no - -fix(cloud-connection): the sign-in refusals and the unmanaged-install copy no longer call the running system "this environment" - -- **What changed.** Inside the running system, user-facing copy names the system the user is in as "this system", or drops the noun. The seven `401 UNAUTHENTICATED` answers on `/api/v1/cloud-connection/*` now read "Sign in to connect a cloud account." (`bind/start`), "Sign in to install apps." (`install`) and "Sign in." (`bind/poll`, `unbind`, `installation`, `installed`, `org-packages`), where each used to say "Sign in to this environment …". On `/api/v1/marketplace/install-local`, the `409 RESOURCE_CONFLICT` that refuses a managed install over an unmanaged copy now names the holder as "the writable base 'BASE' — an unmanaged install of this package, or a base authored in this system — whose items are this system's own metadata", and the unmanaged install's success `note` reads "The package's metadata is now this system's own, in the base 'BASE' …". -- **Unchanged.** Every HTTP status, error code (`UNAUTHENTICATED`, `RESOURCE_CONFLICT`), `details` object and response shape. Server log lines are not touched. diff --git a/.changeset/pre/24404-mcp-this-system.md b/.changeset/pre/24404-mcp-this-system.md deleted file mode 100644 index cb7d9498dff..00000000000 --- a/.changeset/pre/24404-mcp-this-system.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -Clause-②: no - -fix(mcp): the known-partial listing warning and the Connect-an-Agent bundle description say "this system" - -- **What changed.** Inside the running system, user-facing copy names the system the caller is in as "this system". The warning an MCP client receives with a known-partial object listing (the `objectstack://objects` resource and the `list_objects` tool) now says "this system declares at least that many objects, possibly more", and the Connect-an-Agent UI bundle's description ends "for connecting MCP clients to this system". -- **Unchanged.** The `SERVICE_UNAVAILABLE` code that marks a partial listing, every payload shape, the portable `SKILL.md` (which names the deployment an outside agent connects to), and server log lines. diff --git a/.changeset/pre/24405-mcp-connect-agent-subtitle.md b/.changeset/pre/24405-mcp-connect-agent-subtitle.md deleted file mode 100644 index f2fecbe6cbd..00000000000 --- a/.changeset/pre/24405-mcp-connect-agent-subtitle.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/mcp': patch ---- - -Clause-②: no (copy only: no accept set or public surface moves; owes the seat's review of record at `TIER_DEFAULT` before the queue) - -fix(mcp): the Connect-an-Agent page subtitle says "this system" - -- **What changed.** The Connect-an-Agent page header now reads "Give any MCP-capable AI client governed access to this system — every call runs under the caller's own permissions and row-level security." Inside the running system, user-facing copy names the system the caller is in as "this system". -- **Unchanged.** The page's name, layout, components and navigation entries, and every MCP route and payload. diff --git a/.changeset/pre/24405-platform-objects-this-system.md b/.changeset/pre/24405-platform-objects-this-system.md deleted file mode 100644 index bcadf995437..00000000000 --- a/.changeset/pre/24405-platform-objects-this-system.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/platform-objects': patch ---- - -Clause-②: no (copy only: no accept set or public surface moves; owes the seat's review of record at `TIER_DEFAULT` before the queue) - -fix(platform-objects): SSO provider, OAuth application and Connect-an-Agent copy says "this system" - -- **What changed.** Inside the running system, user-facing copy names the system the user is in as "this system". The `sys_sso_provider` object description ("External SSO identity providers (OIDC / SAML) this system federates login to"), the `clientId` help text of its Register SSO Provider action ("OAuth client ID issued by the IdP for this system."), and the OAuth applications grid's empty state ("Register an application to let its clients sign users in with this system.") now say so. The `en` copy of the Connect-an-Agent page subtitle follows the page's new text. -- **Translations.** The zh-CN, ja-JP and es-ES values of the same strings are re-translated, and the recorded source hash of the Connect-an-Agent subtitle moves to the new English text in all three, so those locales keep serving their translation rather than falling back to English. -- **Unchanged.** Every object, field, action and page name, and all behaviour. diff --git a/.changeset/pre/24406-spec-this-system-copy.md b/.changeset/pre/24406-spec-this-system-copy.md deleted file mode 100644 index 43d1d77ec0e..00000000000 --- a/.changeset/pre/24406-spec-this-system-copy.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/platform-objects': patch ---- - -Clause-②: no - -fix(spec): published copy that names the running system says "this system", or drops the noun, not "this environment" - -- **What changed.** Inside the running system, user-facing copy names the system the reader is in as "this system", or drops the noun. A permission set made here, not shipped by a package, is "authored in this system". - - The permission-set `packageId` refusal on the save, restore and publish doors now reads "a set written into no package is authored in this system, which an absent `packageId` states". - - The permission set's `packageId` help text in Setup ends "Leave empty for a set authored in this system." The English Setup forms bundle of `@objectstack/platform-objects` is regenerated from it. - - The `permission.managedBy` tombstone prescription, and the `permission-set-managed-by-retired` protocol-18 upgrade-guide entry, say "a set authored in this system carries neither". - - The `AudienceConfig.posture` and `AuthConfig.audience` describes say "who may self-register", with no noun. Their TSDoc says "who may become a user of this system's apps", the sentence the Setup Authentication group already reads. - - `AiModelsResponse.models` describes "Models this system offers". The `IMetadataService.listDiagnosed` TSDoc example reads "this system contains N items". -- **Kept "environment", where the word names a deployment or configuration scope.** `SeedLoaderConfig.env` ("Only load datasets matching this environment", the `prod | dev | test` tier), the `package-uninstall-environment-wide` upgrade-guide entry (the ADR-0131 environment-wide scope, as against an organization), and the environment artifact's consent-record TSDoc. -- **Unchanged.** Every error code, status, issue path, accepted value and export. Only the human-readable text moves. diff --git a/.changeset/pre/24409-migrate-boot-no-dev-seed.md b/.changeset/pre/24409-migrate-boot-no-dev-seed.md deleted file mode 100644 index 5d3feafbcb3..00000000000 --- a/.changeset/pre/24409-migrate-boot-no-dev-seed.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -'@objectstack/plugin-auth': patch ---- - -fix(plugin-auth): a migrate-and-exit boot (`OS_MIGRATE_AND_EXIT=1`) no longer provisions the development admin, whatever `NODE_ENV` says - -Clause-②: no - -- **What changed.** `objectstack serve` reads `OS_MIGRATE_AND_EXIT` only after the kernel bootstrap, and the dev-admin seed runs on `kernel:ready`, inside that bootstrap. So a migration run with `NODE_ENV=development` seeded exactly as a dev boot does: run against an empty database, it left a known-credential login behind. `isDevAdminSeedArmed()` now answers "not armed" when `OS_MIGRATE_AND_EXIT` is `1`, the same spelling `serve` exits on, and the clause wins over an explicit `OS_SEED_ADMIN=1`. A migrate-and-exit boot of an empty database now creates no `sys_user` row and no `sys_account` row. -- **The walled owner check follows.** It reads the same function, so on a migration run the seed no longer counts as a way to verify a declared platform owner. A walled migrate-and-exit boot of an empty database completes its schema sync as before; it just seeds no login. -- **Unchanged.** The `objectstack dev` seed: still armed under `NODE_ENV=development` on a database with no login, and still off under `OS_SEED_ADMIN=0`. Any `OS_MIGRATE_AND_EXIT` value other than `1` is a boot that goes on to serve, and keeps its seed. A database that already holds a login was never seeded and is not touched. -- **If a pipeline relied on the seed.** Create the first account through the deployment's own provisioning path after the migration step, as a production deployment does. diff --git a/.changeset/pre/24410-lint-formula-hop-undeclared-field.md b/.changeset/pre/24410-lint-formula-hop-undeclared-field.md deleted file mode 100644 index d974b833ce8..00000000000 --- a/.changeset/pre/24410-lint-formula-hop-undeclared-field.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/lint': minor ---- - -fix(lint)!: a formula that reads, through a reference, a field the reference's target does not declare is refused where it is written - -Clause-②: no (narrowing) - - - -**BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -The field-formula pass judges the field a formula reads one hop through a reference against the target object's fields: the fields it declares, the system columns the platform adds to it, and `id`. `os validate`, `os build` and the object save door (`PUT /api/v1/meta/object/:name`) run the same pass, under the existing `EXPRESSION_INVALID` rule. - -- FROM: `record.book.name`, where `book` is a lookup to an object that declares no `name`, saved with a 200 and read `null` on every row. TO: refused (`422 INVALID_METADATA` at the save door). The message names the field and the target: "unknown field `name` on ``". -- `record.book.title`, where the target declares `title`, passes as before. -- A target the stack does not carry is not judged. At the save door the stack carries the live registry's objects in the written package's closure, so a target from a package it depends on is judged. - -What to do. Read a field the target declares, or declare the field on the target object. Do not declare it on the object that carries the formula. At `os build` and `os validate` this pass does not see a target field that an `objectExtensions` entry contributes, while the save door judges the merged object. diff --git a/.changeset/pre/24410-objectql-formula-hop-sibling-isolation.md b/.changeset/pre/24410-objectql-formula-hop-sibling-isolation.md deleted file mode 100644 index 5c5eea11764..00000000000 --- a/.changeset/pre/24410-objectql-formula-hop-sibling-isolation.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/objectql': patch ---- - -fix(objectql): a formula reading a field its reference's target does not declare no longer makes the other formulas reading through the same reference read null - -Clause-②: no - - - -The formula read loads, once per page, every column the object's formulas read through each reference. When one formula named a field the target does not declare, the whole reference was answered as undeclared. Every formula reading through it then read `null` and reported the other formula's field. - -- The undeclared fields are left out of that shared read and judged for each formula on its own. -- A formula that names one reads `null`, with a fault report naming only its own undeclared fields. -- A formula that reads only declared fields through the same reference gets the value it gets when read alone (`?fields=`). - -What to do. Nothing. A formula that read `null` this way now reads its value. To find the formula at fault, re-save the object: the save door refuses the misspelt read and names the field. diff --git a/.changeset/pre/24411-unstored-field-removal.md b/.changeset/pre/24411-unstored-field-removal.md deleted file mode 100644 index 89c156c27fc..00000000000 --- a/.changeset/pre/24411-unstored-field-removal.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/metadata-protocol': minor ---- - -fix(metadata-protocol): removing a field that stores nothing is not a destructive change - -Clause-②: yes (widening) (the save door newly accepts, without `force`, a removal it refused; owes the seat's `TIER_DEFAULT` review of record before the queue) - -- **What changed.** The object save's safety diff (`PUT /api/v1/meta/object/:name`, and every other door that reaches `saveMetaItem`'s destructive gate) now judges each finding by whether the field is stored, using the spec's storage predicate `isVirtualSearchField` (`SEARCH_VIRTUAL_TYPES`) on the field's own declaration — the predicate the sortability projection already reads. A `formula` field is computed on read and has no column (ADR-0079 addendum, item 9), so: - - removing a `formula` field saves `200` without `?force=true` (it answered `409 DESTRUCTIVE_CHANGE` with "existing data in this column will become inaccessible"); - - retyping a `formula` field into a stored kind saves without `force`: the new column starts empty, and no stored value is lost. -- **Not changed.** Removing a stored field, including an engine-computed but stored one (`autonumber`, `summary`), still answers `409 DESTRUCTIVE_CHANGE` with the same `field_removed` finding and message. Retyping a stored field into a `formula` still answers `409` with `field_type_change`. The `force` mechanism, the messages and the error codes are unchanged. -- **Who notices.** An author or an agent that removes a formula field no longer needs `force=true` for it, so `force=true` stays reserved for a real loss of stored data. diff --git a/.changeset/pre/24421-sharing-rule-declared-name-guard.md b/.changeset/pre/24421-sharing-rule-declared-name-guard.md deleted file mode 100644 index b744b974166..00000000000 --- a/.changeset/pre/24421-sharing-rule-declared-name-guard.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -'@objectstack/plugin-sharing': minor -'@objectstack/runtime': minor ---- - -fix(plugin-sharing,runtime)!: a declared sharing rule is read-only and clonable — no rule, new or renamed, can take its name, and it is switched off through the activation ledger, which revokes its grants at once - -Clause-②: no (narrowing) - - - -**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. - -ADR-0131 D6 charts declared sharing rules under regime C: a rule a package declares is read-only, it is customised by a clone under another name, and it is switched off through the deployment-level activation ledger (`sys_metadata_activation`), like flows, actions, positions and permission sets. Same-name shadowing is retired. - -What is refused now: - -- `POST /api/v1/sharing/rules` (and `SharingRuleService.defineRule`) creating a rule under a declared rule's name — FROM `201`, the new row replacing the declared rule in its scope, TO `409 RESOURCE_CONFLICT` naming the declared rule. Fix: create your rule under another name, and switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/` and `{ "enabled": false }`. -- A `/data` insert of a `sys_sharing_rule` row under a declared rule's name, or an update that renames a row onto one — FROM `201` / `200`, TO `409 RESOURCE_CONFLICT`. Fix: the same. - -What is added: - -- **The switch.** `POST /api/v1/security/_activation/sharing_rule/:name` writes the declared rule's activation row, behind the same authority as the other activation doors: `manage_metadata`, and the platform operator under a wall, because the row covers every organization. A declared rule switched off is served nowhere. The write revokes every grant that carries the rule's name in the same request, and switching it back on re-derives them. The name must be a declared rule (`404` otherwise; a rule your organization authored keeps its own `active` field), and with no sharing-rule service bound the door answers `503`. -- **A declaration over a stored row is reported, never made an overlay.** When a package declares a rule whose name a `sys_sharing_rule` row already holds in a scope (a package upgrade, or a copy customised before this release), the row keeps answering as its own rule. The declaration is not served beside it, and that is logged at `error` once per name and scope per process, with the remedy. - -Unchanged: rules whose name no declaration holds are created, renamed and switched as before. A stored row that already holds a declared name stays editable as itself. `DELETE /api/v1/sharing/rules/:idOrName` on a declared rule is still `403 PERMISSION_DENIED`, and is now answered the same way when the rule is switched off. diff --git a/.changeset/pre/24456-client-platform-objects-this-system.md b/.changeset/pre/24456-client-platform-objects-this-system.md deleted file mode 100644 index 74a040b4c68..00000000000 --- a/.changeset/pre/24456-client-platform-objects-this-system.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/client': patch -'@objectstack/platform-objects': patch ---- - -Clause-②: no - -fix(client,platform-objects): the `ai.models()` TSDoc and the translated permission-set `packageId` help say "this system", as the spec now does - -- **What changed.** Inside the running system, published copy names the system the reader is in as "this system", and a permission set made here, not shipped by a package, is "authored in this system". `@objectstack/spec` already says both (`AiModelsResponse.models`, the permission set's `packageId` help text); this follows it in two more places. - - The TSDoc of `client.ai.models()` now reads "Models this system offers in the chat model picker (cloud ADR-0028)". The citation is qualified as `cloud ADR-0028`: the AI service that serves this list is a cloud package, and this repository's ADR-0028 is about metadata naming. - - The zh-CN, ja-JP and es-ES values of the permission set's `packageId` help text in the Setup forms bundle now say 本系统 / このシステム / este sistema where they said 本环境 / この環境 / este entorno, matching the English text "Leave empty for a set authored in this system." -- **Unchanged.** Every method, route, type, form field, key and behaviour. No translation key is added or removed. diff --git a/.changeset/pre/24457-console-pin-4016370af3c9.md b/.changeset/pre/24457-console-pin-4016370af3c9.md deleted file mode 100644 index dc64ac77ef1..00000000000 --- a/.changeset/pre/24457-console-pin-4016370af3c9.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `4016370af3c9`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 32 releasing of 32 changesets added across 33 non-merge commits; omitted: 1 commit carrying no changeset (they ship no package code). - -- **minor** — **BREAKING** — The object-page special cases for the retired permission-set catalog object are removed (objectui#7611, after objectstack's ADR-0131 stage 8 retired `sys_position`, `sys_permissio… (objectui `78b07f468`) -- **minor** — `@object-ui/react` now exports `buildNodeEvaluationScope(predicateScope, boundRecord, pageVariables)`, the one builder of the scope a page node's `visibleWhen` and other values ar… (objectui `9e7e1a55b`) -- **minor** — A page route reads the objects its page binds, not the whole object registry (objectui#12980, part 1). (objectui `024b7b665`) -- **minor** — feat(plugin-chatbot): a drafted result's Preview button appears for any draft kind, and the draft preview bar names what is pending on the surface it shows (objectui#12968) (objectui `76f91f80d`) -- **minor** — A row action's templates read the row as `record`, the way the record header's do (objectui#12967). (objectui `025130ca0`) -- **minor** — The Studio copilot's applied card can open the change review and undo the draft; the 变更 badge follows an in-turn publish; the chat fits the copilot's slot at a 1010 px window (obj… (objectui `43efd078a`) -- **minor** — The list toolbar's Display control compares hidden fields and density against the view's own value again (objectui#12949). On a console list, after a user hid a column (or changed… (objectui `b5153f44e`) -- **minor** — Studio's 变更 badge and the item header follow the copilot's drafts as they land (objectui#12725, item 5). (objectui `eee54ed79`) -- **minor** — **The copilot's change card says when a change will be saved as a draft, and the applied card offers review and undo** (objectui#12894). (objectui `3da39ae42`) -- **patch** — data-table: the whole leading cell of a selectable row is now the selection's hit area. A click beside the small checkbox toggles the row instead of opening the record (objectui#1… (objectui `4016370af`) -- **patch** — `page:tabs` probes a related list's badge count from the record the record context names (`recordId`, the route's id on a record page) instead of the id inside the loaded row, so… (objectui `46dbf32bf`) -- **patch** — The permission-set editor's read-only badge now names the caller's capability when that is the gate that closed (objectui#13011). (objectui `09eadf4e1`) -- **patch** — The console sidebar's capability gate no longer loads the whole object registry in an app created in the console (an app with no package). It asks, by name, every object the app l… (objectui `2b1651d14`) -- **patch** — The by-name object scope no longer records a failed read as a missing object (objectui#13009). A by-name read that obtains no answer (a 5xx, a network drop, a 404 without the `RES… (objectui `d0934b919`) -- **patch** — The AI build plan card now draws a composed record title's line (for example 「名称:自动生成「{图书} · {读者} · {借出日期}」,表单里不出现」) in place of the plain 「名称:LABEL」 line, read from the plan card… (objectui `4b7ce4705`) -- **patch** — In an app that names no package (an app created in the console is stored bound to none), two chrome readers no longer load the whole object registry (`GET /api/v1/meta/object`) wh… (objectui `1624e08f6`) -- **patch** — A page route reads more of the objects its page binds in its own scope read, and an interface list page inside a package app no longer reads its source object a second time (objec… (objectui `d3fd5e4f9`) -- **patch** — `record:quick_actions`: a refused declared action array (a mixed id/object array) no longer draws its "refused at index N (see console)" box in a runtime view. That box is develop… (objectui `0d9764260`) -- **patch** — `record:quick_actions` no longer shows its authoring placeholder ("record:quick_actions — no actions configured") to a viewer whose named actions are all withheld. A block that na… (objectui `1d3da6c1e`) -- **patch** — The AI build plan card draws a stage field's lifecycle on its chip (for example 「借出 · 已还 ✓完成 · 逾期 ⚠」), read from the plan card's `lifecycle` key. A chip without the key draws as b… (objectui `5f599dbcd`) -- **patch** — Opening the console no longer starts nine reads the page does not show in the same moment as the page's own reads (objectui#12980, part 3). (objectui `6db875efc`) -- **patch** — On a free-plan organization, the environment list's locked mark, "Development environments · Team plan and up" (zh 「开发环境 · 团队版起」), is now drawn only for a viewer who can upgrade (… (objectui `4dfdebdf5`) -- **patch** — `page:tabs` no longer counts a related list the viewer cannot see (objectui#12987). A tab's derived badge sums the `record:related_list` blocks under it; a list whose own node-lev… (objectui `909c64e3a`) -- **patch** — `record:quick_actions` maps an action's spec `variant` onto the `Button` vocabulary the way the record header and the row menu do: `danger` draws the destructive style and `primar… (objectui `bcd9717e8`) -- **patch** — The Import Wizard no longer offers "Run automations & triggers" or "Import as historical data" (objectui#12985). Both import doors (`POST /api/v1/data/:object/import` and `/import… (objectui `cb8000181`) -- **patch** — A console route's view no longer holds the boot splash after the route's data is in (objectui#12980, part 2). (objectui `736b9cee6`) -- **patch** — Console: a create-workspace refusal carrying objectstack's `ORGANIZATION_CREATION_DISABLED` code now shows the localized "Creating new organizations is disabled on this instance."… (objectui `ccddde951`) -- **patch** — The help menu gains an About entry on every console bar (the tenant console and the control plane draw the same header). It opens a small dialog naming the build the console was c… (objectui `ea34d9b7e`) -- **patch** — The Studio copilot no longer draws the floating pending-changes bar over its composer (objectui#12965, part of objectui#12725). (objectui `32a171e56`) -- **patch** — The list's quick-search box and the record pickers' type-ahead are no longer offered on an object whose search resolves to no field, and a refused search shows the server's messag… (objectui `b7fe09eba`) -- **patch** — The metadata designer's copy no longer names the running system by the cloud noun "environment" (objectui#12960, the same rule objectui#12956 applied to the locale packs). Where a… (objectui `8dfc6b7a4`) -- **patch** — Console copy names the system the user is already in, not the cloud noun "environment" (objectui#12956). Inside a running deployment, the Settings subtitle and its first category,… (objectui `7f9cc37d0`) - -⚠️ 1 of these carries a breaking change: 1 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears 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)_ feat(tooling): objectui consumes @objectstack/* by commit — .objectstack-sha, bump-objectstack.sh, dev and CI built from the pin (objectui#12957) (#12963) (objectui `749ed7607`) - -Clause-②: no - - - -objectui range: `0707b4ae3712...4016370af3c9` diff --git a/.changeset/pre/5082-declared-index-unique-scope.md b/.changeset/pre/5082-declared-index-unique-scope.md deleted file mode 100644 index 5045d5892da..00000000000 --- a/.changeset/pre/5082-declared-index-unique-scope.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'@objectstack/spec': minor -'@objectstack/lint': minor -'@objectstack/platform-objects': patch -'@objectstack/metadata-core': patch -'@objectstack/metadata-protocol': patch -'@objectstack/plugin-security': patch -'@objectstack/plugin-auth': patch -'@objectstack/plugin-sharing': patch -'@objectstack/service-messaging': patch -'@objectstack/service-automation': patch -'@objectstack/service-realtime': patch ---- - -A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). - -**Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. - -**What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: - -- the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; -- lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. - -**What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. - -**What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. - -## FROM → TO - -| you wrote | write instead | -|:--|:--| -| `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | -| …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | -| `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | - -**The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. - -**Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. - -### The kit - -- **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. -- **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. -- **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. -- **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. -- **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. diff --git a/.changeset/pre/9139-cbp-master-detail-required-error.md b/.changeset/pre/9139-cbp-master-detail-required-error.md deleted file mode 100644 index a47929108e0..00000000000 --- a/.changeset/pre/9139-cbp-master-detail-required-error.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -"@objectstack/lint": minor -"@objectstack/spec": minor ---- - -feat(lint)!: `relationship/master-detail-required` refuses the three unsafe master-reference shapes at `error` on a `controlled_by_parent` object (#9139) - -Clause-②: no (narrowing) - -A `controlled_by_parent` detail derives all of its record access from the master its `master_detail` reference names (ADR-0055). Three declarable shapes of that reference leave the security gate as the only thing refusing a detail record saved without its master, because record validation never checks a field that is not `required` and skips `readonly` and `system` fields before its required check: - -1. `required` absent, or `required: false`; -2. `required: true` with `readonly: true`; -3. `required: true` with `system: true`. - -A record that lands without its master anyway is readable by nobody, and every later write to it by id is refused. Until now `relationship/master-detail-required` was a `warning` with the predicate "`required` is not `true`", on every object, so shapes 2 and 3 drew no finding at any severity. The maintainer ruling of 2026-08-16 (Direction 1) scheduled the promotion for the v18 boundary, scoped to `controlled_by_parent`. - -**BREAKING — what moves for consumers.** - -- `os lint` reports each of the three shapes at `error` when the object declares `sharingModel: 'controlled_by_parent'`, located at the defect (`…fields.FIELD.required`, `.readonly` or `.system`). It covers every `master_detail` field of such an object, the same scope the builder's `required: true` force already applies. `os lint` therefore exits non-zero on such a stack, and the metadata-generation rubric (`scoreMetadata`) weighs the finding as an error and marks the stack `valid: false`. -- `@objectstack/spec` gains the step-18 semantic migration entry `cbp-master-detail-required-lint-error`, so `os migrate meta` across protocol 18 prints the prescription below. - -**Remedy — the v18 upgrade-checklist line.** On every object with `sharingModel: 'controlled_by_parent'`, give each `master_detail` reference `required: true` and remove any `readonly: true` or `system: true` from it. `os lint` now refuses the missing-`required`, `required` + `readonly` and `required` + `system` shapes there at `error` (`relationship/master-detail-required`). An object authored through `ObjectSchema.create` already gets `required: true` when the key is omitted, so the edit there is dropping the flag. - -**Unchanged.** - -- On every object that is not `controlled_by_parent` the rule is exactly as before: a `warning` for a `master_detail` without `required: true`, the same message and fix, and no finding for the two flagged shapes. -- The rule is not in the authoring-rule registry. `os build`, `os validate` and the metadata save door do not run it, so a stack carrying one of the shapes still builds and publishes. Only `os lint`'s exit code and the generation rubric move. -- Runtime is untouched. The security gate keeps refusing an insert that omits the master FK on these shapes and keeps resolving the master for metadata already at rest, and stored metadata is neither rewritten nor refused on load. -- No export or signature moves in either package. -- Measured before crossing, at `b04a5295f`: 129 authored objects across the example apps, the platform, plugin and service objects and the CLI's golden eval corpus. 7 of them are `controlled_by_parent`, and 0 draw the new `error`. - - diff --git a/.changeset/pre/9272-grant-window-order-refused.md b/.changeset/pre/9272-grant-window-order-refused.md deleted file mode 100644 index 7614e640515..00000000000 --- a/.changeset/pre/9272-grant-window-order-refused.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -"@objectstack/plugin-security": major ---- - -`sys_user_permission_set` refuses an assignment whose validity window ends at or before it starts (ADR-0091 D1/D2) - -Clause-②: no (narrowing) - - - -**BREAKING**: an accept-set narrowing on the data door, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). - -- **What is refused.** A write that leaves an assignment with both `valid_from` and `valid_until` set and `valid_until` at or before `valid_from` is refused with `400 VALIDATION_FAILED`, a `rule_violation` at `valid_until`. The window is half-open, `[valid_from, valid_until)`, so such an assignment can never grant anything: the resolver drops it at every evaluation. Until this release it was stored without a word, so an administrator who mistyped a date got an assignment that silently granted nothing. -- **Which writes are judged.** Every insert, from every writer, system writers included, single rows and batches alike. An update is judged by the stored row overlaid with the patch, and only when it moves a bound: by id, and for each matched row of a multi-row update. The refusal is a declared validation rule on the object, `validity_window_order`, so the engine runs it on every one of those write paths. -- **What still lands, unchanged.** A window that ends after it starts, one bound alone (either one), and no window at all. An update that moves neither bound is not judged, so an assignment stored before this release with an inverted window does not block an unrelated edit; the write that repairs it is the only one that has to state a valid window. -- **To fix a refused write**, set `valid_until` later than `valid_from`, or leave one of the two empty for an open-ended window. diff --git a/.changeset/pre/9272-user-page-grant-window-columns.md b/.changeset/pre/9272-user-page-grant-window-columns.md deleted file mode 100644 index a01ceffeffe..00000000000 --- a/.changeset/pre/9272-user-page-grant-window-columns.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@objectstack/platform-objects": minor ---- - -The user page's "Permission Sets" list shows each assignment's validity window, `valid_from` and `valid_until` (ADR-0091 D1/D2) - -The resolver drops a `sys_user_permission_set` assignment outside its half-open `[valid_from, valid_until)` window at every evaluation, so a list that showed only the set presented an expired or not-yet-active assignment as a live grant. The two bounds now sit beside the set. The list's Add affordance still writes the user and the set only; the window is set by editing the assignment row. diff --git a/.changeset/pre/9591-migrate-meta-write.md b/.changeset/pre/9591-migrate-meta-write.md deleted file mode 100644 index a6bb673ec1d..00000000000 --- a/.changeset/pre/9591-migrate-meta-write.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/cli": minor ---- - -`os migrate meta --write` writes the chain's mechanical changes into the authored source files, in place, at every site it can prove - -Clause-②: yes (widening) - -- A new flag on the authored-source mode: `os migrate meta --from N --write`. Without it nothing changes: the dry run, its report and its `--json` payload are what they were, and `--out` still writes its snapshot. -- What it writes: each mechanical change the chain applied (`applied`), at a site it traces to one object or array literal in one project file — through `define*` calls and the `.create(…)` factories `@objectstack/spec` exports, module-level `const` bindings, relative imports and re-exports, and `Object.values()` over a namespace import — when the loaded value matches that literal and nothing else references the bindings on the way. Only that site's bytes change: a renamed key keeps its value and its comments, a removed key takes its own line(s), and every other byte (comments, formatting, key order) stays as it was. -- What it refuses, each change listed with the reason (`--json`: `write.manual[].kind`): `computed`, `helper`, `spread`, `shared`, `outside-project`, `mismatch`, `injected`, `unspellable`, `layout` and `unattributed`; and `entangled`, because a conversion's edits are written whole or not at all. -- What it never writes: the semantic changes (`todos`), which stay listed exactly as before, and a site a conversion declines, for which no mechanical change exists. -- After writing it re-runs the chain over the written sources. Unless the re-run applies exactly the changes it left, it restores every file it wrote and exits 1. -- `--json` gains a `write` key, only with `--write`: `status`, `files`, `written`, `manual`, `unexplained` and `verification`. -- `--write` is exclusive with `--stored`; `--stored --apply` is unchanged. diff --git a/.changeset/pre/9591-retirement-sentence-write.md b/.changeset/pre/9591-retirement-sentence-write.md deleted file mode 100644 index 549cabff448..00000000000 --- a/.changeset/pre/9591-retirement-sentence-write.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': patch -'@objectstack/lint': patch -'@objectstack/driver-turso': patch ---- - -Retirement prescriptions name `os migrate meta --write`: "… to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand." - -Clause-②: no - -Wording only: no schema, key, type, export or error code changes, and every input that parsed or was refused before gets the same verdict at the same path. Only the closing sentence of the message changes. - -- A prescription covered by an ADR-0087 conversion used to close with "Run `os migrate meta --from N` to list the mechanical edits for existing sources; apply them by hand." It now closes with "Run `os migrate meta --from N` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand." The default run still only lists. `os migrate meta --write` rewrites in place each edit it can trace to one literal in one project file and lists every other edit with the reason it was not written. The sentence never says the tool rewrites your sources automatically, because `--write` does not write every edit. Text up to and including "for existing sources;" is unchanged, so a caller matching that prefix still matches. -- The three prescriptions for conversions that cover only part of a value (dashboard `compareTo.offset`, the script flow node's `config.actionType` and the package manifest `permissions` case) carry the same `--write` clause before their second clause. -- The `record:chatter` / `record:discussion` `position` value prescriptions (`'sidebar'`, `'inline'`, `'drawer'`) used to tell the author to run a bare `os migrate meta`, which the command refuses because `--from` is missing. They now name `os migrate meta --from 17` and close with the same sentence. -- `@objectstack/lint`: the script node's retired dispatch-key diagnostic and the dataset widget's `chartConfig.xAxis.field` hint close with the new sentence. -- `@objectstack/driver-turso`: the `timeout`, `localPath` and `wasm` config tombstones close with the new sentence. diff --git a/.changeset/pre/console-088e0f705520.md b/.changeset/pre/console-088e0f705520.md deleted file mode 100644 index d9bfb230f31..00000000000 --- a/.changeset/pre/console-088e0f705520.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `088e0f705520`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 3 releasing of 3 changesets added across 3 non-merge commits. - -- **minor** — A record-form field can name one item of a metadata registry by its machine name: a `text` field that declares `widget: 'position-ref'` lists the position registry, and one that d… (objectui `088e0f705`) -- **patch** — A position approver is read through the spec's registry binding, `{ source: 'registry', type }` (objectstack#22710): its readable label and its designer picker come from the posit… (objectui `83b9fade2`) -- **patch** — A rich-text field no longer draws hint lines above its editor (objectui#12110, ruled B). The `markdown`, `html` and `richtext` editors used to show "Format: markdown" (or "Format:… (objectui `2498a0753`) - -Clause-②: no - -objectui range: `e5311165a8d6...088e0f705520` diff --git a/.changeset/pre/console-0df67f237c5a.md b/.changeset/pre/console-0df67f237c5a.md deleted file mode 100644 index 56913631adc..00000000000 --- a/.changeset/pre/console-0df67f237c5a.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `0df67f237c5a`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 28 releasing of 29 changesets added across 21 non-merge commits; omitted: 1 release-nothing changeset, 1 commit carrying no changeset (they ship no package code). - -- **minor** — The Setup catalog for positions and permission sets reads the registry. It is built from the metadata-admin list and editors in their environment scope, not from new pages (object… (objectui `0df67f237`) -- **minor** — A bulk action's field-backed param now renders its real control on the selection bar, and the confirm step names the picked record (objectui#12105). (objectui `29b994902`) -- **minor** — A create form says when the server will fill a field: "Set automatically when saved." under a field whose `defaultValue` the server resolves at insert, while the field is empty (o… (objectui `b7cd5c891`) -- **minor** — A calendar view in a list draws every record of the days it shows, not the first 100 records of the object (objectui#12081 item 2). (objectui `16c8810ac`) -- **minor** — In-place edit on the record page and a list's in-cell edit honour the caller's field write grant, and the owner field honours the transfer grant (objectui#12103). (objectui `5f75cfad1`) -- **minor** — List views read an absent `userActions.editInline` as on, the v18 protocol default (objectui#12086, the consumer half of objectstack#22605). A list view is editable in place by de… (objectui `5330afd1f`) -- **minor** — Five write affordances that read no grant at all now read the affordance-to-grant map, so a caller without the grant is no longer offered a write the server then refuses (objectui… (objectui `5af42dbbd`) -- **minor** — After a marketplace install into the environment the console is rendering, the package page waits until that environment serves the installed package's app, and only then refreshe… (objectui `3c0f8069e`) -- **minor** — `element:repeater` and `element:number` publish the node-level `dataSource` binding as their only query input (objectui#12085). `@objectstack/spec` retires the element layer's fla… (objectui `ce991bd70`) -- **minor** — A create form asks its fields the create question, so a role that may create records but not edit them can fill and submit the form (objectui#12082). Every console affordance that… (objectui `023f00d46`) -- **minor** — **BREAKING** — The row type of the `VersionHistory` timeline is declared as `VersionHistoryEntry` instead of `VersionEntry` (objectui#6349, batch 10). `@object-ui/collaboration` publishes an unr… (objectui `12ff25631`) -- **minor** — The props of this package's `ThemeProvider` are declared and exported as `ThemePreferenceProviderProps` instead of `ThemeProviderProps` (objectui#6349, batch 10). `@object-ui/reac… (objectui `12ff25631`) -- **minor** — `ConnectionState` is a new export: the lifecycle state of one client connection, `'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'` (objectui#6349, batch 10)… (objectui `12ff25631`) -- **minor** — The props of this package's minimal `AppShell` container are declared and exported as `MinimalAppShellProps` instead of `AppShellProps` (objectui#6349, batch 9). `@object-ui/layou… (objectui `1b2d0160f`) -- **minor** — The props of this package's view switcher are declared and exported as `ListViewSwitcherProps` instead of `ViewSwitcherProps` (objectui#6349, batch 8). `@object-ui/plugin-view` pu… (objectui `d758f2f20`) -- **patch** — **The console no longer offers an object to a caller who cannot read it** (objectui#12109). A navigation entry bound to an object (`type: 'object'`, by its `objectName`) is not dr… (objectui `5a65f7d57`) -- **patch** — An object timeline with a `groupByField` draws one group per value, headed by the value's display name (objectui#12104). (objectui `206505c34`) -- **patch** — An org install from the Marketplace catalog's "Your organization" strip, on a cloud-managed environment, now waits until the environment serves the installed package's app before… (objectui `a21ff9a95`) -- **patch** — The console asks for recent activity only when the signed-in user may read it, and a failed read no longer shows as "No recent activity" (objectui#12081, item 8). (objectui `029bdaf45`) -- **patch** — An app that serves the signed-in user no navigation now opens on an app-level empty state, not on the Studio home (objectui#12079). (objectui `417e24567`) -- **patch** — The record chatter stores each reaction as the member's own `sys_comment_reaction` record, and stops writing the whole `sys_comment.reactions` column (objectui#12078; ruling A, am… (objectui `de302c731`) -- **patch** — fix(console): a long flow screen or action parameter list keeps its Submit / Confirm on screen (objectui#12080) (objectui `4d0ff2331`) -- **patch** — `ConnectionState` is re-exported from `@object-ui/types` instead of being declared a second time here (objectui#6349, batch 10). `@object-ui/data-objectstack` declared the same fi… (objectui `12ff25631`) -- **patch** — `ConnectionState` is re-exported from `@object-ui/types` instead of being declared a second time here (objectui#6349, batch 10). `@object-ui/collaboration` declared the same five… (objectui `12ff25631`) -- **patch** — Doc comments only. `UserDataAdapter` now carries the contract notes `@object-ui/app-shell` kept on its own copy of the interface: implementations must be safe to call concurrently… (objectui `1b2d0160f`) -- **patch** — The props of the console's page header are declared as `ConsolePageHeaderProps` instead of `PageHeaderComponentProps` (objectui#6349, batch 8). `@object-ui/layout` publishes `Page… (objectui `d758f2f20`) -- **patch** — Doc comment only. The `PageHeaderComponentProps` comment no longer says `@object-ui/app-shell` shares the name: the console's header props are now `ConsolePageHeaderProps`, so thi… (objectui `d758f2f20`) -- **patch** — The props of the dashboard's read-only record drill drawer are declared as `DashboardRecordDetailDrawerProps` instead of `RecordDetailDrawerProps` (objectui#6349, batch 8). `@obje… (objectui `d758f2f20`) - -⚠️ 1 of these carries a breaking change: 1 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. - -- _(no changeset)_ fix(scripts): an injected tree hashes apart from an installed one, so a local Spec Main Shape Gate reproduction cannot leave verdicts a sibling worktree replays (objectui#12114) (… (objectui `a194b4e37`) - -Clause-②: yes (widening: the html-page save gate and `os validate` read the SDUI manifest this console ships, and at this pin `element:repeater` and `element:number` publish the node-level `dataSource` binding as their only query input. A page source with `` and no flat `object` was refused with `missing-required-prop` against the previous manifest and passes against this one; a flat `object` / `filter` / `sort` / `limit` still passes, now with a non-gating `unknown-prop` warning.) - - - -objectui range: `20c6d351ad74...0df67f237c5a` diff --git a/.changeset/pre/console-1f23d5b070fa.md b/.changeset/pre/console-1f23d5b070fa.md deleted file mode 100644 index d5a8a28da57..00000000000 --- a/.changeset/pre/console-1f23d5b070fa.md +++ /dev/null @@ -1,126 +0,0 @@ ---- -"@objectstack/console": major ---- - -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` diff --git a/.changeset/pre/console-1fb1ced36243.md b/.changeset/pre/console-1fb1ced36243.md deleted file mode 100644 index e2b7d7c7922..00000000000 --- a/.changeset/pre/console-1fb1ced36243.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `1fb1ced36243`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 10 releasing of 11 changesets added across 11 non-merge commits; omitted: 1 release-nothing changeset, 2 commits carrying no changeset (they ship no package code). - -- **minor** — The author of a comment on a record page can now edit and delete it from the record feed (objectui#12268). (objectui `7af0ab378`) -- **minor** — **BREAKING** — Both dashboard surfaces draw a widget's legacy `component` envelope the way it is authored: a `metric-card` node draws as before, and any other node draws the retired-format promp… (objectui `e8fc452c3`) -- **minor** — The console's assistant button no longer covers the record page's inline-edit Save or an attachment's Download and Delete, and deleting an attachment asks first (objectui#12248). (objectui `51c661e38`) -- **minor** — **BREAKING** — The exported field metadata types stop declaring the members `@objectstack/spec`'s `FieldSchema` refuses by name and no reader honours, and two type values become the spec's `Fiel… (objectui `10aba37d1`) -- **patch** — A record page's header totals now update when you edit or delete a row in one of its related lists (objectui#12244). Before, a quote's total in the header highlight strip kept its… (objectui `30c2e4e1d`) -- **patch** — fix(plugin-report): a report lays out a select dimension in the field's declared option order (objectui#12243) (objectui `e5c8ea7fe`) -- **patch** — Home's activity card names each row's object by its label in the reader's language (objectui#12238). (objectui `ee5c49171`) -- **patch** — A record activity feed shows a call, meeting or email logged on the record, instead of hiding it as a completed task (objectui#12228). On an object with no authored record page, a… (objectui `0cd322d78`) -- **patch** — A currency field's fixed currency, declared as `currencyConfig`, shows on the record quick-look panel and in related lists (objectui#12242). (objectui `10aba37d1`) -- **patch** — The record form stops reading a field's snake_case `visible_on`. A field's visibility is `visibleWhen`, the key `@objectstack/spec`'s `FieldSchema` declares (objectui#12242). (objectui `10aba37d1`) - -⚠️ 2 of these carry a breaking change: 2 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 2 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)_ ci(test): the coverage lane runs eight shards, then both test matrices shard by measured duration (objectui#12208, objectui#12256) (#12263) (objectui `1fb1ced36`) -- _(no changeset)_ ci(type-check): the turbo cache replays unchanged packages, and pull-request and queue runs type-check only what the change affects (objectui#12222) (#12251) (objectui `1ce8db8d9`) - -Clause-②: no - - - -objectui range: `7578323a544e...1fb1ced36243` diff --git a/.changeset/pre/console-20c6d351ad74.md b/.changeset/pre/console-20c6d351ad74.md deleted file mode 100644 index 9c5b2a59b58..00000000000 --- a/.changeset/pre/console-20c6d351ad74.md +++ /dev/null @@ -1,92 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `20c6d351ad74`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 33 releasing of 33 changesets added across 24 non-merge commits; omitted: 1 commit carrying no changeset (they ship no package code). - -- **minor** — The approval decision panel draws a short notice instead of nothing when it is placed anywhere other than an approval request's record page (objectui#12072). (objectui `20c6d351a`) -- **minor** — The per-group aggregation type that `useGroupedData` takes is declared and exported as `GroupAggregationConfig` instead of `AggregationConfig` (objectui#6349, batch 7). `@object-u… (objectui `c0c0a0d56`) -- **minor** — The view switcher's views are typed as `@object-ui/core`'s `ListViewVisualization`, and this package no longer re-exports that union as `ViewType` (objectui#6349, batch 7). `@obje… (objectui `c0c0a0d56`) -- **minor** — **BREAKING** — The console sidebar no longer lets a user drag the app's menu into a private order; the Pinned section is now the one place a user orders entries (objectui#12059). Arranging an ap… (objectui `8a55f0ccd`) -- **minor** — **BREAKING** — feat(types)!: `UnifiedViewConfig.chart` retires with a tombstone that names the spec's list chart block, and the three `object-chart` legacy-axis refusals stop calling those keys… (objectui `c7b30bd66`) -- **minor** — An `element:repeater` node may omit `properties.object` when its `dataSource.object` names the object. The zod arm `ElementRepeaterBlockSchema` now applies the same `object` waive… (objectui `2a48bd408`) -- **minor** — A pointer field draws the record it points at, the console reads an approval request's record page through the approvals routes, and the approval decision panel is built for the r… (objectui `a368ccb1a`) -- **minor** — **BREAKING** — feat(plugin-list)!: a chart list view binds only an ADR-0021 `dataset`; the legacy inline chart axes and their `name` / `value` floors are retired on every route (objectui#6152, r… (objectui `025341692`) -- **minor** — **BREAKING** — `createObjectStackUploadAdapter` no longer sends an upload's `path` as the storage scope (objectui#12055). (objectui `7282c6a51`) -- **minor** — **BREAKING** — The result type of `useConfirmDialog` is declared as `DesignerConfirmDialogState` instead of `ConfirmDialogState` (objectui#6349, batch 6). `@object-ui/app-shell` declares an unre… (objectui `1f1c4b526`) -- **minor** — **BREAKING** — feat(plugin-list)!: the renderers stop reading the list-view keys every door refuses: the pre-#2231 aliases, `calendar.defaultView`, and the undeclared keys a per-kind block carri… (objectui `3fd862510`) -- **minor** — **BREAKING** — `objectui validate` and `objectui check` judge a document through the strict authoring face (objectui#5250) (objectui `e4c0b5432`) -- **minor** — The runtime chat contracts have their own names, apart from `@object-ui/types`' authoring `ChatMessage` / `ChatToolInvocation` (objectui#6349, batch 5). The shape ` - -objectui range: `47b1f0bb7174...20c6d351ad74` diff --git a/.changeset/pre/console-47b1f0bb7174.md b/.changeset/pre/console-47b1f0bb7174.md deleted file mode 100644 index 5fe991ec3b4..00000000000 --- a/.changeset/pre/console-47b1f0bb7174.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -"@objectstack/console": patch ---- - -Console (objectui) refreshed to `47b1f0bb7174`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 13 releasing of 14 changesets added across 14 non-merge commits; omitted: 1 release-nothing changeset (they ship no package code). - -- **patch** — The console's sign-in page no longer reads `/api/v1/i18n`, and the application's translations and locale list load with the session's credentials once signed in (objectui#12034). (objectui `47b1f0bb7`) -- **patch** — The record approvals panel draws its decision-progress tally through one module-internal indicator, `DecisionProgressIndicator` (objectui#12033, part of objectui#2763). The tally… (objectui `ba9e82026`) -- **patch** — The Studio header's *More* trigger keeps its own name (objectui#11794). While *Access*, the pillar it holds, was open, the trigger renamed itself to "Access", which hid the word t… (objectui `8de8ba280`) -- **patch** — The embedded item editor ("Save into object", opened from a metadata item's Related drawer) now saves into the parent's draft (objectui#12027). (objectui `ea79b7777`) -- **patch** — A compact record-preview card for any `(object_name, record_id)` pair, kept inside the package for the approval surfaces to compose (objectui#12029, the first child of objectui#27… (objectui `049012bf0`) -- **patch** — Studio navigation details (objectui#11794): (objectui `8f815f4fe`) -- **patch** — Three more controls pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the list view and the chatbot): `ListView`'s "Color by field" an… (objectui `8f8f760fa`) -- **patch** — Studio saves one way on a package: the permission matrix and hooks autosave to the package draft like the other pillars, every create dialog says *Save as draft*, and the Changes… (objectui `6694abe75`) -- **patch** — Five of the Studio design surface's pickers use the shared `Select`, the control the rest of Studio picks with (objectui#11865, the design surface's part of that card): in the nav… (objectui `5382a865f`) -- **patch** — Four plugin controls pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the plugins' single selects): `SharedViewLink`'s "Expires after… (objectui `2063f7a96`) -- **patch** — A quick-filter value restored from the URL now gets its field's type once the object definition loads, when the field is declared without its type (objectui#12008). (objectui `16b9d440d`) -- **patch** — Four metadata-admin pickers use the shared `Select`, the control the rest of the console picks with (objectui#11865, the metadata-admin previews and inspectors' part of that card)… (objectui `f2bff5ce8`) -- **patch** — The Create View dialog, the AI build panel's Excel import bar and the API console's method selector pick with the shared `Select`, the control the rest of the console picks with (… (objectui `869d0bfdf`) - -objectui range: `f0268ad78485...47b1f0bb7174` diff --git a/.changeset/pre/console-4997995022f8.md b/.changeset/pre/console-4997995022f8.md deleted file mode 100644 index 941118841d6..00000000000 --- a/.changeset/pre/console-4997995022f8.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `4997995022f8`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 5 releasing of 5 changesets added across 7 non-merge commits; omitted: 2 commits carrying no changeset (they ship no package code). - -- **minor** — A Studio form field that declares the `ref-multi:permission` widget now renders a picker of the declared permission sets instead of the JSON editor fallback (objectui#12126). (objectui `499799502`) -- **minor** — The Marketplace's install answers now say what the runtime served (objectui#12098). (objectui `107139379`) -- **minor** — A paged grid's column footer reads the whole matched set, or says it reads the page (objectui#12081 item 3). (objectui `69bca0af6`) -- **patch** — A boolean field whose `defaultValue` is a CEL expression or a runtime token now gets that default on create (objectui#12121). (objectui `c9fc10b1e`) -- **patch** — More write affordances read the affordance-to-grant map, so a caller without the grant is no longer offered a write the server then refuses (objectui#12082, leg A of the write-cen… (objectui `752504520`) - -**In this console build, declared nowhere** — objectui merged 2 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: stop teaching keys the strict authoring face refuses (objectui#12131, phase 1) (#12134) (objectui `66eb5604e`) -- _(no changeset)_ fix(examples): repair the class (i) keys the strict authoring face refuses (objectui#5250 slice C) (#12128) (objectui `802f0bc41`) - -objectui range: `0df67f237c5a...4997995022f8` diff --git a/.changeset/pre/console-7578323a544e.md b/.changeset/pre/console-7578323a544e.md deleted file mode 100644 index 64848cbcd42..00000000000 --- a/.changeset/pre/console-7578323a544e.md +++ /dev/null @@ -1,87 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `7578323a544e`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 29 releasing of 31 changesets added across 25 non-merge commits; omitted: 2 release-nothing changesets, 2 commits carrying no changeset (they ship no package code). - -- **minor** — **BREAKING** — A page route's breadcrumb no longer carries a 「Pages」 crumb (objectui#12198, executing the maintainer's ruling on objectstack-ai/cloud#2768). **BREAKING** for `@object-ui/i18n`: t… (objectui `7578323a5`) -- **minor** — A flow action whose run pauses for input now closes with the action's own `successMessage` (objectui#12234). (objectui `360cf3215`) -- **minor** — **BREAKING** — A dashboard widget's legacy `component` envelope takes a `metric-card` node only, and refuses every other node by name with the fix, on both zod faces and on the TypeScript type (… (objectui `6fb281d64`) -- **minor** — `BaseFieldMetadata` declares a field's help text as `inlineHelpText`, typed by reference to `@objectstack/spec`'s `FieldSchema.inlineHelpText`, and no longer declares `help` (obje… (objectui `923504f4a`) -- **minor** — A record whose `approval_status` reads `pending` no longer opens locked when the approvals read answered and found no request (objectui#12220). (objectui `5c2982adf`) -- **minor** — **BREAKING** — A free-plan organization's environment list no longer offers a primary button that opens a paywall dialog (objectui#12190). The maintainer's rule is that no primary button opens a… (objectui `fa778e67f`) -- **minor** — A `type: 'url'` action or an `element:button` whose target is `mailto:` now opens the mail client, where it used to fail at click (objectui#12191, the objectui half of ruling A+;… (objectui `334f0cff9`) -- **minor** — Console: the app's identity moves into the top bar (objectui#12201, the maintainer's ruling of 2026-10-12). (objectui `38bb37333`) -- **minor** — The `lookup` widget reads `@objectstack/spec`'s `allowCreate`, `lookupColumns` and `lookupPageSize` alone. Their snake_case twins are retired, with no alias (objectui#12157). (objectui `466a219eb`) -- **minor** — `LookupFieldMetadata` declares the lookup picker's `allowCreate`, `lookupColumns` and `lookupPageSize` in `@objectstack/spec`'s spelling, each typed by reference to the spec's `Fi… (objectui `466a219eb`) -- **minor** — Every built-in locale pack gains one key, `form.maxFileSize` (objectui#12156). It is the size-limit hint the flat `ObjectForm` appends to the description of a `file` or `image` fi… (objectui `b2316074a`) -- **minor** — **BREAKING** — A `file` or `image` field's size limit is now `maxSize`, the one spelling the widgets read and the spec accepts (objectui#12156). (objectui `b2316074a`) -- **minor** — **BREAKING** — The console boot screen says one honest line, and shows no English before the user's locale (objectui#12187, the first of its three fixes). (objectui `54159d329`) -- **patch** — The console builds again on a case-insensitive filesystem, which is macOS's default (objectui#12184). Two modules in the console's settings pages, the workspace-timezone prompt di… (objectui `08b5d97d1`) -- **patch** — A field-backed action param's help line reads the field's `inlineHelpText`, in the order the record form reads it (objectui#12213). (objectui `923504f4a`) -- **patch** — The record form draws a field's `inlineHelpText` under its control (objectui#12213). (objectui `923504f4a`) -- **patch** — Lookup links on a record page and on a custom page now carry the console's base path, so the same link works from a left click, a new tab, a middle-click and a copied link (object… (objectui `97e7d4edd`) -- **patch** — A console page load reads `sys_user_preference` once, not once per preference key (objectui#12187). (objectui `d48071e3d`) -- **patch** — The bell's Activity tab names each row's object by its label in the reader's language (objectui#12212). (objectui `fc1bc6664`) -- **patch** — An empty gallery draws the same empty state as an empty grid, translated, instead of "No items to display" in English (objectui#12217). (objectui `f5b66bf97`) -- **patch** — The lifecycle badge (`ManagedByBadge`) draws only for a caller who can author metadata (objectui#12194). An end user sees it nowhere: not on the record page, the object list heade… (objectui `f38a42827`) -- **patch** — A console page route stops loading the Studio's designers, the interface list and one file per icon before its content (objectui#12187). (objectui `6eca5b5a7`) -- **patch** — The `position-ref` grant picker no longer offers the `everyone` and `guest` audience anchors (objectui#12200). Every authenticated or anonymous principal holds an anchor implicitl… (objectui `a491b64a6`) -- **patch** — A record's Edit form saves the owner the user changes or clears (objectui#12205). The form dropped `owner_id` from every save, so a reassignment or a clear made in the Edit dialog… (objectui `6c437b07e`) -- **patch** — Two producers hand the `lookup` widget `@objectstack/spec`'s spellings, moving with the widget's own retirement of the snake_case reads (objectui#12157): (objectui `466a219eb`) -- **patch** — The detail view's inline-edit field enrichment no longer copies `allow_create`, `lookup_columns` or `lookup_page_size` off the object schema. The lookup picker reads `allowCreate`… (objectui `466a219eb`) -- **patch** — The internal ledger behind `ObjectGrid`'s relational copy set drops its `allow_create`, `lookup_columns` and `lookup_page_size` rows, because `LookupField` no longer reads those s… (objectui `466a219eb`) -- **patch** — An inline edit of a date-only field on a record page saves the day the user picked, in every timezone (objectui#12206). (objectui `803f678b3`) -- **patch** — The flat `ObjectForm`'s file-size hint now reads the spec's `maxSize`, and it is shown in the session language (objectui#12156). (objectui `b2316074a`) - -⚠️ 5 of these carry a breaking change: 5 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 2 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)_ ci: Spec Main Shape Gate leaves the required set — advisory on PRs, hourly on main; the required-check pin declares the seven ruled names (objectui#12240) (#12250) (objectui `5039e8117`) -- _(no changeset)_ ci: two-speed CI — affected tests on PR and queue runs, full suite and coverage on main's push and hourly schedule, spec gate reuse by objectstack sha, fifteen gate workflows fold… (objectui `21fdeb34d`) - -Clause-②: no - - - -objectui range: `9646e2ed2bf3...7578323a544e` diff --git a/.changeset/pre/console-9646e2ed2bf3.md b/.changeset/pre/console-9646e2ed2bf3.md deleted file mode 100644 index bb0fb0f9141..00000000000 --- a/.changeset/pre/console-9646e2ed2bf3.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `9646e2ed2bf3`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 13 releasing of 14 changesets added across 9 non-merge commits; omitted: 1 release-nothing changeset, 1 commit carrying no changeset (they ship no package code). - -- **minor** — The FilterBuilder's row and group types are exported as `FilterBuilderRow` and `FilterBuilderRowGroup` instead of `FilterBuilderCondition` and `FilterGroup` (objectui#6349, batch… (objectui `26b0bd2d1`) -- **minor** — `FilterBuilderOperator` is no longer exported from this package (objectui#6349, batch 12). It was `@objectstack/spec`'s `ViewFilterOperator`, the protocol's canonical view-filter… (objectui `26b0bd2d1`) -- **minor** — The result type of this package's `toDomProps` is declared and exported as `FieldDomProps` instead of `DomProps` (objectui#6349, batch 11). `@object-ui/core` publishes a different… (objectui `35a2b59a5`) -- **minor** — The node type of the tree this package's parser produces is declared and exported as `ParsedSchemaNode` instead of `SchemaNode` (objectui#6349, batch 11). `@object-ui/types` publi… (objectui `35a2b59a5`) -- **patch** — The Studio fallback form for an email template no longer offers `category` or `isSystem`, and the fallback skill form no longer offers `triggerPhrases` (objectstack-ai/objectstack… (objectui `9646e2ed2`) -- **patch** — A gallery card whose title field is a lookup now shows the referenced record's name, not `[object Object]` (objectui#12188). (objectui `5dd583f5e`) -- **patch** — The list view's URL filter state reads the FilterBuilder's group by `@object-ui/components`' new name for it, `FilterBuilderRowGroup` (it was exported as `FilterGroup`; objectui#6… (objectui `26b0bd2d1`) -- **patch** — `ListViewProps.onFilterChange` and `ListViewProps.initialFilters` now name the advanced-filter group by `@object-ui/components`' new name for it, `FilterBuilderRowGroup` (it was e… (objectui `26b0bd2d1`) -- **patch** — `toFilterGroup` now declares its return type by `@object-ui/components`' new name for the FilterBuilder's group, `FilterBuilderRowGroup` (it was exported as `FilterGroup`; objectu… (objectui `26b0bd2d1`) -- **patch** — Under a tenancy wall (`isolated` or `group`), the security catalog's pages are read-only for everyone but the platform operator, and say why (objectui#12183). (objectui `d928c8394`) -- **patch** — `DataSource` inside this package is now a re-export of `@object-ui/types`' `DataSource`, the one adapter contract, instead of an older and looser second declaration of it (objectu… (objectui `35a2b59a5`) -- **patch** — On a metadata type the `OS_METADATA_WRITABLE` hatch elevates, an item a package ships now opens read-only in the metadata editor and in the permission matrix, and an item no packa… (objectui `5b20aed39`) -- **patch** — Studio's landing page and the permission matrix name the overlay that exists: an environment overlay, not an org overlay (objectui#12171). (objectui `b0c5f9ddf`) - -**In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears 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)_ ci(site): deploy the docs from a GitHub Actions workflow every 6 hours, and turn Vercel's Git-triggered builds off (objectui#12178) (#12182) (objectui `7e30d0b6e`) - -Clause-②: no - -objectui range: `088e0f705520...9646e2ed2bf3` diff --git a/.changeset/pre/console-a58626c88dc8.md b/.changeset/pre/console-a58626c88dc8.md deleted file mode 100644 index 50a07af0a60..00000000000 --- a/.changeset/pre/console-a58626c88dc8.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `a58626c88dc8`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 36 releasing of 36 changesets added across 36 non-merge commits. - -- **minor** — The timeline's unused "Overdue" bucket label is removed: `timeline.bucket.overdue` is gone from all ten language packs and from the timeline's built-in English defaults (objectui#… (objectui `c910630cf`) -- **minor** — **BREAKING** — Inside a dashboard widget's legacy `component` envelope, a `metric-card` is now judged by the slot's component arm alone, so every key that arm refuses is refused there too, with… (objectui `89cc738da`) -- **minor** — **BREAKING** — "Recently Accessed" shows each item's own label, in the current language, and a visit that does not change the list no longer writes the `ui.recent` preference (objectui#11678). (objectui `48c82c9d5`) -- **minor** — The calendar and the timeline start the week on the first day of the week of the user's locale (objectui#11675), instead of a fixed Sunday (calendar) and a fixed Monday (timeline)… (objectui `9ca3cacbe`) -- **minor** — The console's settings pages resolve icons through the shared `getLazyIcon` helper and no longer log `[lucide-react]: Name in Lucide DynamicIcon not found` (objectui#11679). (objectui `abd374bb2`) -- **minor** — `DefaultLoginPage` and `DefaultRegisterPage` offer a generic sign-up only where the server would accept one (objectui#11705). Under the default `invite_only` audience posture the… (objectui `7e2d5b0ee`) -- **minor** — Objects with a picklist-bound field save again from the OWD overview, the Setup fields and objects pages, the metadata-admin embedded-item editor, and `MetadataService.saveFields`… (objectui `da3545375`) -- **minor** — The keyboard-shortcuts dialog (`?`) lists only shortcuts that do something (objectui#11674). (objectui `de96f3d0c`) -- **minor** — **BREAKING** — A `metric-card` placed in a dashboard's widget slot refuses `label` by name and points at `title`, the key its heading is drawn from (objectui#4425). (objectui `eb4552e71`) -- **minor** — The console's login and register pages offer a generic sign-up only where the server would accept one (objectui#11691). `/api/v1/auth/config` states the sign-up rule as two keys,… (objectui `daa7caff4`) -- **minor** — An object-bound timeline no longer heads every past date "Overdue": a day before today goes under a neutral "Earlier" bucket, translated in every language (objectui#11676). (objectui `ce464d958`) -- **minor** — Studio reads shared picklists: a select field can use one, an object with a picklist-bound field saves again, and the picklist page is read-only (objectui#10202, the objectui half… (objectui `c3623eb11`) -- **minor** — The AI chat's tool-approval card and its "Open in Builder" handoff card are translated in every language (objectui#11667). (objectui `57d82cb34`) -- **minor** — The chat launchers show a marker while a proposed plan awaits the user's approval (objectui#11666, item 6 of objectui#2458). A user who closed the chat with a blueprint still wait… (objectui `848ba0e12`) -- **minor** — Five fixes on the AI build surface, from the 2026-10-05 cloud acceptance run (objectui#11658). (objectui `fc3c2cc1c`) -- **minor** — `action:group` and `action:menu` no longer read a member's `properties.params` (objectui#11638). A container member's `properties.params` no longer reaches the action runner. (objectui `73b5d7764`) -- **minor** — `LoginForm` shows its "Don't have an account? Sign up" row only when the caller passes `registerUrl` (objectui#11634). The prop used to default to `'/register'`, so a caller that… (objectui `f1a177c41`) -- **minor** — A field group's `visibleWhen` now gates its section on the record detail page, the same way it gates the section on the entry form (objectui#11630). Take an object whose `fieldGro… (objectui `76993f8ef`) -- **patch** — fix(plugin-tree): tree cells draw the same field faces as list cells, so a boolean no longer shows as `true` (objectui#11686) (objectui `a58626c88`) -- **patch** — **The record header's highlight chips share the row's free width and truncate only when it runs out (objectui#11684).** A Product with SKU "QA Widget 1" read "QA Wid…" in the reco… (objectui `7300fcafe`) -- **patch** — An authored `view:calendar` or `view:timeline` node loads its plugin and renders the calendar or the timeline, and a console boot no longer logs the registry's race warning for th… (objectui `e6dcd85cc`) -- **patch** — The approvals inbox's request drawer shows its record summary the way the record page shows the record (objectui#11677). (objectui `3409fe89e`) -- **patch** — The flow designer saves a screen field's `Min` and `Max` as numbers (objectui#11664). (objectui `5ba255538`) -- **patch** — The metric cards no longer write authored keys they do not read onto the page (objectui#4425). Rendered through `SchemaRenderer`, which is how every dashboard draws its KPI tiles… (objectui `a600924f2`) -- **patch** — Console, record page and Studio copy from the 2026-10-05 cloud acceptance run (objectui#11659). (objectui `f9f4a62d5`) -- **patch** — The flow designer's SLA escalation switch on an approval node no longer writes a refused `escalation: { enabled: false }` block, and switching it off keeps the values the author e… (objectui `9f3ed7bca`) -- **patch** — Cancelling a background import shows the rows the server already committed, with Undo (objectui#11650). `ImportWizard`'s Cancel used to show "Import cancelled · 0 imported" withou… (objectui `846f98251`) -- **patch** — Drag-to-reorder works on a grouped sidebar menu, within each level (objectui#11626). `NavigationRenderer` had a sortable path only in its group-free arm. Every stock app's menu is… (objectui `b88937b65`) -- **patch** — The `object-kanban` board totals the view's `summarizeField` in each column header (objectui#11629). (objectui `f4370f426`) -- **patch** — On an object that declares no list view, a grid toolbar change no longer sends a save the door refuses (objectui#11643). Such an object opens on the "All Records" tab the console… (objectui `43671468d`) -- **patch** — The console's `/verify-email?token=…` page verifies the address again (objectui#11633). It used to send the token as `POST /api/v1/auth/verify-email` with a JSON body. better-auth… (objectui `8057a8b14`) -- **patch** — On a runtime with no marketplace that still mounts install-local (an offline boot, `OS_CLOUD_URL=off`), a local install's Details page now offers its local menu: re-seed sample da… (objectui `0baf86f1b`) -- **patch** — A dataset-bound dashboard widget names its comparison window from `compareTo.kind` (objectui#11632). `previousPeriod` reads "vs previous period" and `previousYear` reads "vs last… (objectui `e398a54f0`) -- **patch** — Two grid toolbar changes to one view in one session now both survive a reload (objectui#11642). Before, the second change overwrote the first: changing density and then sorting by… (objectui `531b26c8f`) -- **patch** — A lookup whose `dependsOn` names a parent field drops its selection when that parent changes or is cleared (objectui#11631). (objectui `c00039842`) -- **patch** — A grid toolbar change on a served view is stored where the save door keeps it, so it survives a reload (objectui#11625). This covers density, a column-header sort and the hide-fie… (objectui `59917c4b2`) - -⚠️ 3 of these carry a breaking change: 3 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - - - -objectui range: `0abd4f9f8769...a58626c88dc8` diff --git a/.changeset/pre/console-b9814b334d8a.md b/.changeset/pre/console-b9814b334d8a.md deleted file mode 100644 index 09b37438ebb..00000000000 --- a/.changeset/pre/console-b9814b334d8a.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `b9814b334d8a`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 31 releasing of 35 changesets added across 36 non-merge commits; omitted: 4 release-nothing changesets, 4 commits carrying no changeset (they ship no package code). - -- **minor** — Studio's first draft save after opening an item no longer replaces a draft that was saved elsewhere in the meantime (objectui#11773). (objectui `b9814b334`) -- **minor** — An empty list draws its declared link, and a list page's own empty state wins over its source view's (objectui#12270). (objectui `b434f5f47`) -- **minor** — A `record:details` section heading can show how many values a field holds (objectui#12192). (objectui `c08c9332a`) -- **minor** — The console and the runner bundles are built against `@objectstack/*` 18.0.0-next.0 (objectui#12030). In both, the `@objectstack/*` ranges are `devDependencies`, but each package… (objectui `c5406c201`) -- **minor** — The ten locale packs follow `@objectstack/spec` 18's platform capability set (objectui#12030): the key `capability.label.manage_org_presentation` is replaced by `capability.label.… (objectui `c5406c201`) -- **minor** — **BREAKING** — Depend on the `@objectstack/*` 18 prerelease line (objectui#12030). (objectui `c5406c201`) -- **minor** — A report the viewer may not read shows the no-access state, not the server's refusal (objectui#12330). (objectui `2b06a91e6`) -- **minor** — A flow action launched from a list toolbar now resolves the row selection by the same rule as a script action: `@object-ui/core`'s `resolveServerActionRecordId` (objectui#12331).… (objectui `2722d33c5`) -- **minor** — Console writes that landed in silence now say so, and controls disabled on a read-only package say why to every user, not only to a mouse (objectui#12303). (objectui `b64fda3f6`) -- **minor** — A record page draws one provenance footer for its record, whatever the number of `record:details` nodes it carries (objectui#12307). (objectui `28ebfb5f8`) -- **minor** — The marketplace package page offers the install modes a package permits, managed or an unmanaged copy, and reads what the install door answers (objectui#12288, the objectui stage… (objectui `cea429b98`) -- **minor** — Calendar quick-create no longer offers a dialog that cannot succeed. On an object with a required field it cannot fill, an empty-day click opens the full create form with the clic… (objectui `ee470719e`) -- **minor** — A grid's inline-edit save no longer overwrites a concurrent write without a word (objectui#12292). The grid's default row save and "Save All" now send each row's `updated_at` as `… (objectui `3092aca7a`) -- **minor** — The OAuth consent page names the requesting application by its registered name again, and never shows the raw `client_id` in the name's place (objectui#12301). (objectui `ca45c6d55`) -- **minor** — The strict authoring face accepts `dataSource` on `record:related_list` (objectui#12302). The block's renderer has honoured the spec's per-element binding since objectstack#6953:… (objectui `093d55bb6`) -- **minor** — `record:details`, `record:highlights` and `record:quick_actions` honour the per-element `dataSource` binding the spec already declares on every page component (objectui#12294). A… (objectui `82e5f5348`) -- **minor** — `AutoNumberFieldMetadata` declares `autonumberFormat`, typed by reference to `@objectstack/spec`'s `FieldSchema.autonumberFormat`, and keeps `format` as its shorthand (objectui#12… (objectui `fd4872f9e`) -- **minor** — **BREAKING** — `buildValidationRules` stops compiling a field-level `pattern` / `pattern_message` and `validate` (objectui#12242, maintainer ruling A). `@objectstack/spec`'s `FieldSchema` refuse… (objectui `5d3f437ab`) -- **minor** — **BREAKING** — The field metadata types stop declaring the keys a host writes at runtime and the field-level regex keys, and `geolocation` leaves the `FieldMetadata` union (objectui#12242, secon… (objectui `5d3f437ab`) -- **minor** — `@object-ui/fields` exports `fieldHelpText(field)`, the one read of the help line a field draws under its control (objectui#12213). (objectui `5b45c0d50`) -- **minor** — **BREAKING** — A dashboard or report route's breadcrumb no longer carries a 「Dashboards」 or 「Reports」 crumb (objectui#12273, applying the maintainer's ruling on objectstack-ai/cloud#2768, as obj… (objectui `9f282ef53`) -- **patch** — `data-table`: an inline edit made while a row save or Save All is in flight is no longer dropped when the save settles (objectui#12322). (objectui `72594f29e`) -- **patch** — A failed first `connect()` with `autoReconnect` now retries discovery and settles (objectui#12318). The retry used to call `connect()` from inside the attempt it was retrying. `co… (objectui `866763bda`) -- **patch** — Metadata admin detail pages no longer overprint their header on a narrow window (objectui#12306). (objectui `67ca3af91`) -- **patch** — An inline edit in a grid is no longer written to another record when the rows are re-read in a new order (objectui#12310). The table kept each pending edit under its row's positio… (objectui `a8051d654`) -- **patch** — Each app tile in Home's "Your apps" strip is now a link to the app, so an app can be opened in a new tab and its address can be copied (objectui#12305). (objectui `0ffa07605`) -- **patch** — The connection state follows the requests the console already makes, so the header chip now shows a mid-session outage (objectui#12295). (objectui `40cb13b2e`) -- **patch** — The console's Setup › Apps page reports what the server did with a write (objectui#12300). Before, a Delete and a Disable could report success for a write that changed nothing. (objectui `068a5407f`) -- **patch** — Saving a master-detail edit form no longer rewrites the position of lines the user did not touch (objectui#12286). (objectui `f0532d8a8`) -- **patch** — A dashboard header action of type `url`, or of no declared type, now goes through the action runner, like every other header action (objectui#12275). (objectui `74964ad68`) -- **patch** — A flow action whose flow runs straight through, with no screen, now closes with the flow's own `successMessage` when the action declares none (objectui#12274). (objectui `bfca2a2ce`) - -⚠️ 4 of these carry a breaking change: 4 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 4 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(ci): retire the four-shard leftovers in AGENTS.md section 9 and two comment sites (#12287) (objectui `157017967`) -- _(no changeset)_ docs(fields): the auto-number page teaches the spec's format tokens, with no quarter token and no hand-written renderer (objectui#12258) (#12280) (objectui `5595aabca`) -- _(no changeset)_ ci(spec-main-shape-gate): drop the merge_group trigger now the context is out of the required set (objectui#12252) (#12262) (objectui `a1a917a08`) -- _(no changeset)_ ci(type-check): the affected speed builds the packages the repo-root setup program imports (objectui#12272) (#12277) (objectui `48fb558d3`) - -Clause-②: no - - - -objectui range: `1fb1ced36243...b9814b334d8a` diff --git a/.changeset/pre/console-daeb77fc84dd.md b/.changeset/pre/console-daeb77fc84dd.md deleted file mode 100644 index 163f9e4098d..00000000000 --- a/.changeset/pre/console-daeb77fc84dd.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -"@objectstack/console": minor ---- - -**Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. - -Console (objectui) refreshed to `daeb77fc84dd`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 4 releasing of 4 changesets added across 5 non-merge commits; omitted: 1 commit carrying no changeset (they ship no package code). - -- **minor** — Studio, Interfaces pillar: an html or react page reads like a code page. The source editor's plain-text fallback numbers its lines and wraps long ones as the code editor does, and… (objectui `830eb1f2d`) -- **patch** — A new record page seeded in Studio is saved with the `default` layout template instead of `full-width`, and the page node's `template` member is typed by reference to the spec's (… (objectui `daeb77fc8`) -- **patch** — Two Studio pickers are named by the labels shown beside them (objectui#12510). Nothing changes on screen. (objectui `3e09c1e8d`) -- **patch** — Studio's empty surfaces now carry the action that fills them (objectui#12514). (objectui `f23eb01cb`) - -**In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears 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(studio-redesign): move the 61 PR screenshots under after/ to an evidence branch and remove them from main (#12583) (objectui `7066dae53`) - -objectui range: `f96f771140f1...daeb77fc84dd` diff --git a/.changeset/pre/console-e5311165a8d6.md b/.changeset/pre/console-e5311165a8d6.md deleted file mode 100644 index b79efab1493..00000000000 --- a/.changeset/pre/console-e5311165a8d6.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `e5311165a8d6`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 21 releasing of 23 changesets added across 24 non-merge commits; omitted: 2 release-nothing changesets, 1 commit carrying no changeset (they ship no package code). - -- **minor** — **BREAKING** — Studio and the metadata admin read a metadata type's overlay flag under its new name, `allowEnvironmentOverlay`, and the read-only banners name `OS_METADATA_WRITABLE` (objectui#12… (objectui `e5311165a`) -- **minor** — The marketplace package page's **Update** button opens an update dialog, and a package README's raw HTML is no longer printed as text (objectui#12146). (objectui `82bb1d584`) -- **minor** — **BREAKING** — A form field declares `step` and `className` on both faces, so the strict authoring face stops telling authors to remove two keys that work. A built-in form branch now joins an en… (objectui `d2500d8be`) -- **minor** — "Clone to customize" on a packaged permission set calls the server's clone door (objectui#7611, objectstack ADR-0126 §7.1). (objectui `6d5879fe9`) -- **minor** — **A dashboard or a list view the server refuses you, opened by its URL, now says it was refused** (objectui#12112, the console half of objectstack#22611's ruling A). (objectui `43e150518`) -- **minor** — Setup's "Assigned users" on a permission set writes a new grant by the set's name alone (objectui#7611, after objectstack#22863, ADR-0131 stage 6b-2-pre). (objectui `faf4ce952`) -- **minor** — **BREAKING** — A form field declares `avatarField`, the record field each candidate of the search picker draws its avatar from, on both faces, so the strict authoring face stops telling authors… (objectui `c0c2f792b`) -- **minor** — A `position` decision output lists the position registry and commits position names (objectui#7611, objectstack ADR-0131 D3/D4). (objectui `8cc7158b4`) -- **minor** — The capability select lists the capability registry, and the permission-set editor's provenance badge reads the layered envelope (objectui#7611, objectstack ADR-0131 D3/D7). (objectui `53ee2f854`) -- **minor** — **BREAKING** — A form field declares the rest of the field metadata its widget reads off the `fields[]` entry, on both faces, so the strict authoring face stops telling authors to remove keys th… (objectui `0665c3be7`) -- **minor** — Screen readers hear the names of more controls in the viewer's language (objectui#12145). (objectui `e723f58ee`) -- **minor** — The suggested-bindings panel shows each suggestion's real state and can revoke an acceptance (objectui#7611, after objectstack#22854). (objectui `f962cdc09`) -- **minor** — The record feed and a related list's Add existing and fallback remove read the affordance-to-grant map, so a caller without the grant is no longer offered a write the server then… (objectui `158c75d90`) -- **minor** — A paged table's first, previous, next and last page buttons have accessible names (objectui#12124). (objectui `dc6d28e86`) -- **patch** — The Studio app's root now opens the app's first navigation entry, like every other app's root, and `StudioHomePage`, Studio's old metadata overview, is retired (objectui#12113). (objectui `ed12d48ec`) -- **patch** — A form field's own `defaultValue` is no longer drawn in its control, so the form never shows a value it does not save (objectui#12155). (objectui `f3d540814`) -- **patch** — A grouped grid's own first, previous, next and last page buttons have accessible names (objectui#12143). (objectui `2381c07f3`) -- **patch** — Console rendering fixes from the HotCRM browser pass (objectui#12110): (objectui `5330038f4`) -- **patch** — The record page's discussion shows stored replies under their comment, and every comment offers the reply input where the caller may post one (objectui#12144). (objectui `a7d492011`) -- **patch** — A record activity feed shows an object's `activityMilestones` rows of type `completed`, such as "Case resolved", instead of hiding them as completed tasks (objectui#12106). (objectui `1d1cfe0c1`) -- **patch** — A record-source refusal (`RECORD_SOURCE_REQUIRED`, raised by every arm that requires a record source) now reports the same path every time (objectui#5250). (objectui `9b7550999`) - -⚠️ 4 of these carry a breaking change: 4 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears 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)_ feat(scripts): the JSON-fence gate reads the published READMEs (objectui#12131, phase 2) (#12139) (objectui `ce9811c96`) - - - -objectui range: `4997995022f8...e5311165a8d6` diff --git a/.changeset/pre/console-f0268ad78485.md b/.changeset/pre/console-f0268ad78485.md deleted file mode 100644 index e2849df8027..00000000000 --- a/.changeset/pre/console-f0268ad78485.md +++ /dev/null @@ -1,154 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `f0268ad78485`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 173 releasing of 175 changesets added across 170 non-merge commits; omitted: 2 release-nothing changesets, 4 commits carrying no changeset (they ship no package code). - -- **minor** — The chat build panel reads cloud's post-apply verification loop, and an unknown build phase no longer reads as "Building" (objectui#11988, the receiver objectstack-ai/cloud#2172's… (objectui `6d5eb3430`) -- **minor** — **BREAKING** — feat(types)!: a list view's legacy `options` bag is the `@objectstack/spec` list overlay's bag by reference, `ListViewTimelineConfig` is the list view's own `timeline` block, and… (objectui `5d77c099b`) -- **minor** — The built-in metadata designers leave the console's first load: `@object-ui/app-shell` registers them from a chunk it loads on its own, once the package entry has run (objectui#11… (objectui `4babf4035`) -- **minor** — The Studio landing (`/studio`) gets the console's own header (objectui#11863). Its old header held one control, the product wordmark linking back to Home. It now mounts `AppHeader… (objectui `7ebff3991`) -- **minor** — **BREAKING** — feat(types)!: the list view's `kanban`, `calendar`, `gallery` and `timeline` blocks are the `@objectstack/spec` 17.7.0 `ListViewSchema` slots by reference, and their `.passthrough… (objectui `cf62edf68`) -- **minor** — `element:repeater` reads the node-level `dataSource` binding first (objectui#11880, the repeater half of the objectui step of objectstack#11509, ruled A-narrow). (objectui `aaba8655a`) -- **minor** — The Studio landing (`/studio`) lists the packages the author was last in, above its package cards (objectui#11863). A visit to a package's pillar builder (`/studio/PKG/TAB`) is re… (objectui `054fd8441`) -- **minor** — The Import Wizard's preview checks a `time` column, and the user import's email column, the way the server's import judges them, so the import button no longer counts rows the ser… (objectui `fbad07885`) -- **minor** — **BREAKING** — feat(types)!: `ObjectGridSchema.defaultFilters` and the flat `ObjectGanttSchema` / `ObjectMapSchema` `filter` follow their `@objectstack/spec` rows (objectui#6152, round 10) (objectui `b13ea3c67`) -- **minor** — A console object list's toolbar grouping is now in the URL, beside its Filter panel conditions, search term and sort, so a grouped list can be shared as a link or bookmarked (obje… (objectui `75bacf93b`) -- **minor** — **BREAKING** — A form field declares `scale` and `currencyConfig` on both faces, by reference to `@objectstack/spec`'s `FieldSchema`, so the strict authoring face accepts the decimal places and… (objectui `18d7b489c`) -- **minor** — The list's Sort picker lists a field it keeps only for the current sort as removable, never as a new choice (objectui#11943). (objectui `4f4fc03f7`) -- **minor** — **BREAKING** — feat(types)!: the `object-calendar` element's `calendar` container is the `@objectstack/spec` 17.7.0 slot by reference, and its `.passthrough()` is gone (objectui#6152, round 9) (objectui `1473757bf`) -- **minor** — **BREAKING** — feat(types)!: `object-grid` `operations` and the `object-grid` / `object-kanban` / `object-calendar` `filter` follow the `@objectstack/spec` 17.7.0 rows (objectui#6152, round 8) (objectui `d7e9e9ab6`) -- **minor** — The metadata designer registries are observable, so a designer registered after a surface rendered now appears in it (objectui#11939, step 1 of objectui#6795). (objectui `a835c3149`) -- **minor** — **BREAKING** — The `sidebar` node declares `side` on both faces, so the strict authoring face accepts the edge the sidebar is drawn against (objectui#11070). (objectui `172acc313`) -- **minor** — New SDUI widget `cloud:workspace-timezone-notice`: one line on the Cloud welcome page naming the timezone a workspace was seeded with at creation (objectui#11930, the objectui hal… (objectui `45d585363`) -- **minor** — A new workspace now takes its creator's browser timezone when it is created, so its first administrator is no longer asked for a zone the browser already knows (objectui#11908). (objectui `dfa15c860`) -- **minor** — **BREAKING** — The metric sub-caption is retired on the reader side (objectui#11389, ruling C, the objectui half after `@objectstack/spec` 17.7.0). **BREAKING** for any dashboard that drew a cap… (objectui `d92b2a15d`) -- **minor** — `action:button` delivers `undoable` where a record is in scope, and publishes it (objectui#11168, ruling B on objectui#11754). (objectui `f0496bdf1`) -- **minor** — **BREAKING** — feat(types)!: retire `object-grid`'s `resizableColumns` on both faces, and stop `ObjectGrid` reading it (objectui#6152, round 7) (objectui `d73d98770`) -- **minor** — A record action greyed out by its declared `disabled` predicate now says why (objectui#11811). It shows the reason "Not available for this record", and the same text is its access… (objectui `455c64665`) -- **minor** — The list filter builder starts on the view's first column, hides hidden fields, and offers one empty check where "empty" and "null" mean the same records (objectui#11810). (objectui `1e1f09e99`) -- **minor** — Dates and times render in the time zone the server answers for the signed-in workspace (objectui#11693). (objectui `d50f7241b`) -- **minor** — The console asks an administrator once to set the workspace timezone while it is still the platform default, pre-filled with the browser's zone (objectui#11758, the gate the objec… (objectui `3bf8894ef`) -- **minor** — Installed Apps now reads a package the runtime refused to load at startup as "Not loaded", says why, and keeps its Uninstall (objectui#11645). (objectui `7b1770590`) -- **minor** — `DashboardWidgetSchema` chains `@objectstack/spec`'s `checkDashboardWidgetChartMeasureArity` after the metric-family check (objectui#11334, objectui#11417), so `safeValidateSchema… (objectui `c0862c1cc`) -- **minor** — `element:text` drops the two pre-convergence `variant` spellings `heading` and `subheading`, which `@objectstack/spec` 17.7.0 retires (objectstack#21015, objectui#11717). The regi… (objectui `c0862c1cc`) -- **minor** — The `page` node and the spec page-kind nodes (`record` / `home` / `utility`) chain `@objectstack/spec`'s `checkPageRequiresKind` (objectui#11717), as they already chain `checkPage… (objectui `c0862c1cc`) -- **minor** — Screen readers can name and reach the controls of the view tab bar, the settings form, the sidebar menus, the table's selection column and the percent cell (objectui#11690). axe-c… (objectui `9fc68aadb`) -- **minor** — A record delete confirmation now names what it deletes and confirms with a destructive "Delete" button (objectui#11695). (objectui `17acfbb9b`) -- **minor** — The organization Settings page no longer offers a slug edit the framework will refuse (objectui#11720). From framework 17.7.0, better-auth's organization update refuses a new slug… (objectui `ded4494b6`) -- **minor** — Object list pages are titled with the object's plural label (objectui#11696). (objectui `6be0f7a46`) -- **minor** — Read-only boolean values, the boolean list face and the Home greeting's punctuation follow the language (objectui#11689). (objectui `22b503c30`) -- **minor** — An empty list's copy no longer contradicts the page it sits on (objectui#11687). (objectui `834c5594b`) -- **patch** — Studio's "New automation" dialog opens on common starting points in plain words, with the trigger form under "Advanced" (objectui#11861: the automations entry, the last of the thr… (objectui `f0268ad78`) -- **patch** — A permission set's advanced facets pick with the shared `Select`, the control the rest of Studio picks with (objectui#11865, the facets' part of that card): a Row-Level Security p… (objectui `1ad0c0824`) -- **patch** — A quick-filter selection restored from the URL now applies when the list's dropdown chips come from the object definition (objectui#12001). (objectui `e616327ab`) -- **patch** — The list toolbar's hide-fields popover, Group editor and Row color select, and its user-filter chips, no longer offer a field the user may not read (objectui#11984). They now ask… (objectui `164f02cf6`) -- **patch** — Studio on a smaller or resized window (objectui#11795). Studio stays a desktop tool; three things change below desktop width: (objectui `70f1b2755`) -- **patch** — An `object-view` grid now exports the filter it shows, and returns to page 1 when that filter changes (objectui#11880, item 5). (objectui `5bc55c0c5`) -- **patch** — The designer forms pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the designer forms' part of that card): `FieldDesigner`'s type fi… (objectui `e8f9977f5`) -- **patch** — The console's Public Forms, Flow Runs and Profile pages pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the console pages' part of t… (objectui `28557855f`) -- **patch** — The dashboard designer's widget property panel picks a widget's Type and Color Variant with the shared `Select`, and the designers' property panel (`PropertyEditor`) draws a `sele… (objectui `5ad818310`) -- **patch** — The list toolbar's field-list read check is rolled back to restore the console's first-load budget, and it re-lands with objectui#11984 after objectui#11939's reclaim. (objectui `902fbe190`) -- **patch** — Studio's Interfaces create dialog picks a new page's source kind, and a new report's dataset and measure, with the shared `Select`, the control the rest of Studio picks with (obje… (objectui `fb361efe0`) -- **patch** — Studio's package record sharing overview picks each object's internal and external sharing model with the shared `Select`. That is the control Studio's object Settings tab already… (objectui `b1f0dcc00`) -- **patch** — A console list's view-tab switch starts the next view with the quick-filter selections its own URL carries (objectui#11992). The object page read the `uf_FIELD` selections once pe… (objectui `9c3798249`) -- **patch** — Studio's object Settings tab picks the sharing model, the external sharing model, the name field, the lifecycle field and the next highlight field with the shared `Select`, the co… (objectui `d21bd083e`) -- **patch** — A rolled-back Studio publish shows its "Nothing was published" banner again (objectui#11985). (objectui `1b8388bef`) -- **patch** — Studio's *Explain access* panel picks the object and the operation with the shared `Select`, the control Studio's validation rule editor already picks with (objectui#11865, the *E… (objectui `0bbb67b9a`) -- **patch** — **BREAKING** — `element:record_picker` and `element:number` bind data through the node-level `dataSource` only (objectui#11880, part of the objectui half of objectstack#11509, ruled A-narrow). (objectui `74add0c12`) -- **patch** — The console header no longer shows a "Search ⌘K" trigger where it would open nothing (objectui#11912). (objectui `a920d8c29`) -- **patch** — A refused package publish names each refused item with the server's reason, on the package sheet and on Home's *Publish all* and the draft-preview bar (objectui#11922). (objectui `c6e1b8c6b`) -- **patch** — Studio's action "New" menu opens on common actions in plain words, with the blank action under "Advanced" (part of objectui#11861: the actions entry). (objectui `d8be368c7`) -- **patch** — Studio's flow-node inspector, in its fallback form, writes only config keys the node's contract declares (objectui#11968). (objectui `0ab2b9885`) -- **patch** — Studio's validation rule editor picks a rule's type, field, built-in format and severity with the shared `Select`, the control the condition builder beside them already uses (obje… (objectui `f19b91540`) -- **patch** — Studio keeps a dashboard widget as an unsaved edit while the author changes its measure, instead of showing a red error the moment the last measure is removed (objectui#11951). (objectui `989b19082`) -- **patch** — Studio's field inspector gives `rating`, `slider` and `progress` fields a number *Default value* control, so the default saves as a number (objectui#11966). (objectui `31741d5f4`) -- **patch** — Studio Interfaces: a leaf save that goes through now clears the red "Changes not saved" strip an earlier refused save showed (objectui#11950). (objectui `402adc8aa`) -- **patch** — Studio's field type catalog now holds every field type the spec declares: it gains `user`, `secret` and `record` (objectui#11909). (objectui `0c9fb8ea3`) -- **patch** — Studio's Interfaces pillar can now create a page in a writable package, and opens it on its source beside a live preview (objectui#11823, step 3). (objectui `7546570a4`) -- **patch** — Studio's validation-rule "New" menu opens on common rules in plain words, with the rule types under "Advanced" (part of objectui#11861: the validation-rule entry). (objectui `7546570a4`) -- **patch** — Studio keeps a new dashboard widget as an unsaved edit until a dataset and a measure are picked, instead of showing a red error right after *Add widget* (objectui#11910). (objectui `a523a3e2e`) -- **patch** — Studio's action editor writes a script body that names its language, so the first keystroke is no longer refused (objectui#11921). (objectui `7129cd575`) -- **patch** — Studio's field inspector offers no *Default value* control on a `record`, `location` or `address` field, as it offers none on a `composite` or `repeater` field (objectui#11947). (objectui `bcd3f0803`) -- **patch** — The record picker (`RecordPickerDialog`) is laid out from the design system's own primitives (objectui#11903). It is the dialog behind a multi-select lookup's "Browse all records"… (objectui `5ab2f19b3`) -- **patch** — A create form preselects the option a field's option list marks `default: true` (objectui#11914). (objectui `ccddd1186`) -- **patch** — The list's Sort picker no longer offers a field the user may not read (objectui#11943). Choosing such a field sent a sort the server refuses, and the list went blank on "no access… (objectui `e44f9bc43`) -- **patch** — A read-only package's form designer no longer tells a screen reader how to drag a field card (objectui#11924). (objectui `94f48a28b`) -- **patch** — Studio's condition builder no longer writes `!field` or a bare `field` for a field that is not a boolean, when it knows the field's type (objectui#11894). (objectui `247f50349`) -- **patch** — Studio's page previews draw the page's own kind, as the running app does (objectui#11933): the live preview beside an `html` or `react` page's source, and the Interfaces page canv… (objectui `ccc2824cf`) -- **patch** — The list Group panel picks its field with the shared `Select`, the control the Filter and Sort panels beside it pick a field with (objectui#11865, the Group panel's part of that c… (objectui `7241a81e0`) -- **patch** — Studio's validation-rule "Runs on" boxes follow the spec's events (objectui#11923). (objectui `70e3d7721`) -- **patch** — Studio's form designer can be arranged from the keyboard on a canvas of any width (objectui#11898). With a field card picked up, each arrow key now moves it one place in the form'… (objectui `60d11cd61`) -- **patch** — Studio's field pickers offer the fields of an object that is still an unpublished draft (objectui#11895). (objectui `2fec2e007`) -- **patch** — The list's Filter panel offers only the fields the user may read (objectui#11925). Its field list now asks the same field-level read check the list's columns already use, `checkFi… (objectui `077d19825`) -- **patch** — The console is back under its first-load budget. Two recent Studio changes are rolled back to get there: the common-rule starting points in an object's Validations "New" menu (obj… (objectui `3c888c6e6`) -- **patch** — The console's record page now says what its record read actually answered: no access, not found, or could not load (objectui#11902). It used to answer every failed read with "Reco… (objectui `f1781be3a`) -- **patch** — Studio's landing page now points a read-only package at the routes that exist for customizing it (objectui#11808). (objectui `e81c84693`) -- **patch** — Four more editors stop asking for the layers of an item that was never published, so the browser console no longer logs a 404 for it (objectui#11799). The framework answers `GET /… (objectui `a025f4d12`) -- **patch** — Studio's flow designer connects two nodes the flow already has (objectui#11905). (objectui `4ebc25001`) -- **patch** — Studio's automation canvas header names the open flow by its label, not by the metadata type word (objectui#11665, the residual of objectui#11659 ruling 6). (objectui `d52d90855`) -- **patch** — Studio's form designer announces a field card's role in the author's language (objectui#11872). (objectui `fc82a31a6`) -- **patch** — Studio, and the app designer's navigation canvas, show a label first and the machine name or id as secondary text, in the places that showed only the machine name or id (objectui#… (objectui `0feabe8f6`) -- **patch** — Studio's field inspector picks a field type from a searchable list grouped by category, where it was one flat select of every type (objectui#11793). (objectui `72f5c608e`) -- **patch** — Studio's new validation rules, hooks and actions start from safe defaults (objectui#11820). (objectui `b4693b7b7`) -- **patch** — A console object list's Filter panel conditions, search term and sort are now in the URL, so a filtered, sorted list can be shared as a link or bookmarked (objectui#11860). (objectui `340bfabf8`) -- **patch** — Studio's publish review and publish confirmation say what changes (objectui#11807). (objectui `b9d9397b3`) -- **patch** — The Import Wizard preview marks a cell exactly where the server's import refuses it on dates, on rating, slider, progress and toggle columns, and on email columns, so the import b… (objectui `9fc6ad784`) -- **patch** — Studio's Interfaces pillar can now create a dashboard or a report in a writable package (objectui#11823, step 2). (objectui `a49c4bd27`) -- **patch** — Metadata-form code fields stay editable when Monaco cannot load (objectui#11858). (objectui `368359179`) -- **patch** — The Approvals Inbox no longer links to a record when the approver has no read of its object at all (objectui#11878, ruling C on objectstack-ai/objectstack#7497). Such an approver… (objectui `230e4b230`) -- **patch** — The login and register pages say when the server cannot be reached, and offer sign-up only once the server has said whether it accepts sign-ups (objectui#11806). This applies to t… (objectui `09821b1db`) -- **patch** — The console's first page load no longer downloads the documentation reader's markdown extras (objectui#11854). (objectui `7afcea06e`) -- **patch** — Both *Duplicate* forms judge a new package id by the spec's id rule, the one the server refuses by (objectui#11855). (objectui `475bda1a2`) -- **patch** — Grid inline edit marks a row modified only when a value really changed, draws one "Actions" column, and the "Edit inline" toggle reports its state (objectui#11816). (objectui `9844bbf92`) -- **patch** — Studio's form designer can be arranged from the keyboard (objectui#11871). Picking a field card up with Space or Enter, moving it with the arrow keys and dropping it with Space or… (objectui `a476dee29`) -- **patch** — The profile page states the access the console resolves, not the auth library's raw `user.role` (objectui#11866). (objectui `0855fc671`) -- **patch** — Studio stops asking questions whose expected answer is an error, so the browser console no longer fills with red lines that hide real failures (objectui#11799). (objectui `5dff0272d`) -- …and 73 more releasing changesets in this range (list capped at 100; see the objectui range below). - -⚠️ 10 of these carry a breaking change: 10 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 4 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. - -- _(no changeset)_ fix(plugin-list): the hide-fields, Group and Row color lists and the user-filter chips ask the field read (objectui#11984) (#11999) (objectui `aa67386a5`) -- _(no changeset)_ chore(scripts): raise the console eager-closure ceiling by a fixed 5,120-byte allowance for Studio repairs while the designers are eager (objectui#11942) (#11949) (objectui `1c51e973b`) -- _(no changeset)_ feat(app-shell): Studio Interfaces pillar creates a page and opens it on source plus live preview (objectui#11823 step 3) (#11932) (objectui `f3a0488c9`) -- _(no changeset)_ feat(app-shell): Studio's validation New menu opens on common rules, with the rule types under Advanced (part of objectui#11861) (#11931) (objectui `19a734866`) - - - -objectui range: `a58626c88dc8...f0268ad78485` diff --git a/.changeset/pre/console-f8267df93aba.md b/.changeset/pre/console-f8267df93aba.md deleted file mode 100644 index 03bc02a1d66..00000000000 --- a/.changeset/pre/console-f8267df93aba.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -"@objectstack/console": minor ---- - -Console (objectui) refreshed to `f8267df93aba`. Frontend changes in this range: - -Derived from the changesets objectui declared over the range — 46 releasing of 46 changesets added across 40 non-merge commits; omitted: 3 commits carrying no changeset (they ship no package code). - -- **minor** — **BREAKING** — The page renderer no longer compiles a `kind: 'jsx'` page node as `html`. This completes the retirement of the page kind `jsx` that `@objectstack/spec` 18 made (objectstack-ai/obj… (objectui `f8267df93`) -- **minor** — The page schemas no longer import or attach `@objectstack/spec`'s page-parse judge of a view-data component's retired `api` binding, which objectstack#23784 removed from the spec… (objectui `15d8ca6da`) -- **minor** — Home: "Recently accessed" brings you back to the records and list views you opened, beside the activity feed (objectui#12604). (objectui `f0e760afe`) -- **minor** — The details grid passes a pointer field's `referenceDeletedWhen` to `resolveRecordPointer` (objectui#12489). (objectui `023087f63`) -- **minor** — The record details grid draws a platform-deleted pointer target as a tombstone in the declared option's label (objectui#12489). (objectui `023087f63`) -- **minor** — `resolveRecordPointer` reads `referenceDeletedWhen`, so a pointer pair can say the platform deleted its target (objectui#12489). (objectui `023087f63`) -- **minor** — The console top bar is 48 px (`h-12`) in every frame, the height both approved console artboards draw (objectui#12639). It was 56 px (`h-14`). (objectui `dcd104102`) -- **minor** — List grids show the record a pointer field names, and the Approvals Inbox says who else is waiting (objectui#12608). (objectui `f78efdd1e`) -- **minor** — Locale keys for the console home's "My work" section (objectui#12602), in all ten packs. (objectui `db8ce2f41`) -- **minor** — The console home's "Needs your attention" card becomes the "My work" section of the approved home artboard (objectui#12602), and the pending-drafts banner above the greeting moves… (objectui `db8ce2f41`) -- **minor** — Resolve `@objectstack/*` `18.0.0-next.2` (objectui#12626). (objectui `f62b6ff4e`) -- **minor** — A kanban under a list view puts every record of the view in its column, not the list's first page, and its cards carry the view's first columns (objectui#12619). (objectui `8614705c1`) -- **minor** — **BREAKING** — `page:header` no longer reads `showStar`, ahead of `@objectstack/spec` 18 retiring the key (objectstack#23650). (objectui `608033b82`) -- **minor** — The console home's top half follows the approved home artboard (objectui#12601): a search field in the top bar, a greeting with a one-line work summary, two compact builder chips,… (objectui `6c4d00da0`) -- **minor** — `DefaultLoginPage` passes the translated names of `LoginForm`'s new password reveal toggle, `auth.login.showPasswordText` and `auth.login.hidePasswordText` (objectui#12600). It ke… (objectui `8ed7e5a8f`) -- **minor** — `LoginForm` gains an optional `hideHeader` prop and a password reveal toggle, and the shared auth form styles take the console login artboard's sizes (objectui#12600). (objectui `8ed7e5a8f`) -- **minor** — The console's login page follows the approved "refined centred" artboard (objectui#12600). (objectui `8ed7e5a8f`) -- **minor** — New `auth.login` strings for the console login page, in all ten language packs (objectui#12600): `welcomeTitle`, `welcomeDescription`, `showPasswordText`, `hidePasswordText`, `foo… (objectui `8ed7e5a8f`) -- **minor** — Every wide centred dialog keeps a 1rem gutter on each side of the window from `sm` (640px) up, by one shared class (objectui#12603). (objectui `726d8c9fc`) -- **minor** — A grouped grid now draws one table with one sticky column header. Each group is a 36 px band row between the rows: a chevron, the group value and its count, in one cell that spans… (objectui `619e3f7a4`) -- **minor** — The Studio's Publish and Changes buttons no longer open the pending-changes sheet the same way. (objectui `92c2a6976`) -- **minor** — **A record dialog with no authored layout opens bounded, and draws two fields per row wherever no `columns` is declared** (objectui#12521). Three defaults of `ModalForm` change. N… (objectui `7b5e55893`) -- **minor** — **BREAKING** — The console's list toolbar no longer has a write into the shared view, and the data-source method that write used is retired (objectui#12504). (objectui `3eb3ec138`) -- **minor** — Studio's Add field asks for the label and type before it creates the field, and the field type picker also searches descriptions (objectui#12516). (objectui `1b3b5b102`) -- **patch** — Action-param and bulk-action dialogs draw `date` and `datetime` params with the platform date picker, the same face record forms draw since objectui#12622 (objectui#12642). (objectui `c6b7d8203`) -- **patch** — The cloud-connection panel's "Not connected" card no longer repeats the page subtitle (objectui#12655). (objectui `efa9cb4fa`) -- **patch** — Record page sections: a collapsible section's header is now a real button named by the section title (objectui#12625). It carries `aria-expanded`, is reachable by Tab, and Enter o… (objectui `4be76e030`) -- **patch** — Studio's permission-set editor no longer offers the two delegated-admin switches that `@objectstack/spec` 18 retired (objectui#12406, the objectui half of objectstack#23488). (objectui `a98a23c78`) -- **patch** — Inside an app, the product mark stays in the top bar as the way home, and the app menu gains Back to home, All apps and Settings (objectui#12605). (objectui `a5bb7ac2f`) -- **patch** — Setup › Packaged automation keeps a flow row to the table's height when the platform says why the flow is not armed (objectui#12609). (objectui `8a8bcf3dd`) -- **patch** — Record forms draw `date` and `datetime` fields with the platform date picker instead of the browser's native date input (objectui#12622). (objectui `ca7a4c316`) -- **patch** — The record header draws one primary action and the rest as secondary buttons (objectui#12617). (objectui `ef71a09f9`) -- **patch** — **`marketplace.installedAdditiveNote` is plain text in all ten packs** (objectui#12610). Each value dropped its leading `Note:` markup (in that pack's language);… (objectui `f74638c1b`) -- **patch** — **Installed Apps' kernel note is the tooltip of the Uninstall it qualifies** (objectui#12610). The "kernel API is additive only" note was a paragraph at the foot of the list, show… (objectui `f74638c1b`) -- **patch** — **A settings action row draws its label once, on the button** (objectui#12610). The settings renderer's `action_button` row printed the manifest's `label` as the row label and aga… (objectui `f74638c1b`) -- **patch** — Marketplace cards show the whole app name, and the id keeps its package name (objectui#12616). (objectui `07b063249`) -- **patch** — Studio at a narrow width keeps the words that matter readable (objectui#12611). (objectui `a8560b228`) -- **patch** — The list toolbar's Display menu now closes once a layout is chosen (segmented or dropdown switcher), returning focus to the Display button, as Escape already does; the panel's tog… (objectui `79c199692`) -- **patch** — The Studio's Automations rail orders its object groups the way the package's app presents its objects (objectui#12612). The groups used to follow the alphabet of the object labels… (objectui `0a41305b0`) -- **patch** — Metric tiles keep their title readable in narrow tiles. The title now owns the first line (up to two lines, with an ellipsis on the second), the value never wraps and steps down o… (objectui `63d00da3d`) -- **patch** — The pending-changes sheet no longer lists the package stamp as one of the author's own changes (objectui#12594). Expanding a new object draft showed `_packageId` under "Also chang… (objectui `33fbc861d`) -- **patch** — An `lg` or `xl` `ModalForm` dialog keeps a 1rem gutter on each side of the window from `sm` (640px) up (objectui#12593). (objectui `85e8961f2`) -- **patch** — A boolean cell's read-only checkbox is named by its field (objectui#12584). (objectui `7d0c174ba`) -- **patch** — Studio's way home leads back to the Studio landing, and the two links that leave for Setup say so (objectui#12513). (objectui `f5eec6383`) -- **patch** — **The console's New and Edit dialog on a field-heavy object opens bounded on desktop** (objectui#12521). For an object with twelve or more authorable fields, `@objectstack/spec`'s… (objectui `7b5e55893`) -- **patch** — Studio: the draft status chip sits in the same place in every pillar's item header (objectui#12524). "Unpublished draft" in Data, Automations and Interfaces, and Access's "saved a… (objectui `ae923ece2`) - -⚠️ 3 of these carry a breaking change: 3 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. - -**In this console build, declared nowhere** — objectui merged 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)_ fix(live-e2e): the backend cold install writes overrides.vitest so npm 10 can install the published graph (objectui#12649) (#12674) (objectui `d66ceea5a`) -- _(no changeset)_ docs(skills): point two published member counts at their declarations instead of numbers (objectui#12588) (#12591) (objectui `8415d9f11`) -- _(no changeset)_ ci: delete the spec-main-shape-gate copy, the spec shape gate runs in objectstack (objectui#12352) (#12596) (objectui `c6a42be39`) - -Clause-②: no - - - -objectui range: `daeb77fc84dd...f8267df93aba` diff --git a/.changeset/pre/console-f96f771140f1.md b/.changeset/pre/console-f96f771140f1.md deleted file mode 100644 index 36bb3740cca..00000000000 --- a/.changeset/pre/console-f96f771140f1.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/console": minor ---- - -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` diff --git a/.changeset/pre/objectui-pin-citations-088e0f705520.md b/.changeset/pre/objectui-pin-citations-088e0f705520.md deleted file mode 100644 index 559bffcd42b..00000000000 --- a/.changeset/pre/objectui-pin-citations-088e0f705520.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `088e0f705520`. - -Clause-②: no - -Every anchor was mapped through the objectui diff `e5311165a8d6..088e0f705520`: 54 paths over 3 commits (objectui#12110's rich-text hint lines, objectui#12174 and objectui#12175). Files that three records cite changed on the hop, all for objectui#12175, and every cited line in them held or moved with its text byte-identical: - -- `form.tsx` (+3, registering the `position-ref` / `permission-ref` widget hints): `spanLadderFor` held at `:275-302`, and its one call site moved `:3326` -> `:3329` with the call unchanged. So `FormField.span: 'full'` still takes the whole row at every multi-column tier. -- `data-objectstack/src/index.ts` (+33 at `:5335`, the adapter's `listMetadataItems`): this lands below both reads the curated-public-blocks record cites, so `serializeOrderBy` `869-895` and the `translateFilterArray` arm `5066-5077` are unmoved. -- The `en` / `zh` / `de` packs (`fields.registryRef` keys, +4 each): the calendar record's `calendar.configRequired` strings moved by +4 and did not change. - -Every other record cites only files that are byte-identical across the hop (`git diff --quiet`, checked file by file), and each gains a hop sentence that says so. - -The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `e5311165a8d6` number. The corpus is now 8519 tracked files, and every retired name each entry counts still reads 0. The lit controls moved with the corpus, for example `objectstack` from 18355 to 18400 and `useState` from 2659 to 2661. - -No key, default, enum member or export moves. diff --git a/.changeset/pre/objectui-pin-citations-0df67f237c5a.md b/.changeset/pre/objectui-pin-citations-0df67f237c5a.md deleted file mode 100644 index 4254fd130fd..00000000000 --- a/.changeset/pre/objectui-pin-citations-0df67f237c5a.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `0df67f237c5a`. - -Clause-②: no - -Every anchor was mapped through the objectui diff `20c6d351ad74..0df67f237c5a`: 293 paths over 21 commits, none deleted and one renamed (`resolveActionParams.ts`, which no record cites). Seventeen files a record cites changed on the hop, plus the `en` / `zh` / `de` packs (whose `calendar.configRequired` strings did not change): `ObjectGrid.tsx`, `ListView.tsx`, `ObjectCalendar.tsx`, `plugin-calendar/src/index.tsx`, `ObjectTimeline.tsx` and plugin-timeline's `renderer.tsx`, `ObjectKanban.tsx`, `KanbanBoardCore.tsx`, `KanbanImpl.tsx`, `plugin-kanban.mdx`, `MePermissionsProvider.tsx`, `PermissionProvider.tsx`, `record-details.tsx`, `basic/data-list.tsx`, `data-objectstack/src/index.ts`, `form.tsx` and plugin-view's `ObjectView.tsx`. Every cited line in them moved or held with its text byte-identical, and each record that cites one is re-pointed with a hop sentence that says by how much. The records that cite only byte-identical files gain a hop sentence that says so. - -Three readings changed around a cited read, and each hop sentence says what it found: - -- `basic/data-list.tsx`: the `element:repeater` registration no longer publishes the flat `object` / `filter` / `sort` / `limit` inputs; the node-level `dataSource` binding is its one published query input (objectui#12085). The renderer still reads the four as the binding's fallback. The row's two sentences that named the published inputs are rewritten where they stand. -- `ObjectGrid.tsx`: the `delete` verdict the implicit bulk-delete is gated on now comes from the affordance-to-grant map's `rowDelete` row (objectui#12082). It still asks the managed-object policy and the object's effective API operations, now with the caller's delete grant beside them, so the `sys_api_key` reading holds. The `inlineEditable` verdict reads the same map's `listInlineEdit` row. -- `ListView.tsx`: the calendar arm gained objectui#12081's fetch window. Its date-field reads are byte-identical and still declared-only, with no floor. - -The re-read also corrects two anchors the previous hop left on the `object-tree` flat-config record: its opening still named the `case 'tree'` arm at its `47b1f0bb7` span, and its hop cited the `labelField` line one line low. - -The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `20c6d351ad74` number. The corpus is now 8424 tracked files. Every token an entry counts as zero still reads zero (98 tokens). The controls moved with the corpus, for example `objectstack` from 18047 to 18248 and `timeout` from 1694 to 1705. - -No key, default, enum member or export moves. diff --git a/.changeset/pre/objectui-pin-citations-1fb1ced36243.md b/.changeset/pre/objectui-pin-citations-1fb1ced36243.md deleted file mode 100644 index 84f50a0506b..00000000000 --- a/.changeset/pre/objectui-pin-citations-1fb1ced36243.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `1fb1ced36243`. - -Clause-②: no - -Every anchor was mapped through the objectui diff `7578323a544e..1fb1ced36243`: 116 paths over 11 commits. One set of cited files changed on the hop, and every cited line in it held its text: - -- The `en` / `zh` / `de` packs (two commits each: objectui#12248, objectui `51c661e38`, and objectui#12268, objectui `7af0ab378`) gained keys below line 1100 only (`en.ts` +9, `zh.ts` +6, `de.ts` +6). Their `calendar.configRequired` strings did not change and held at `en.ts` `:953`, `zh.ts` `:726` and `de.ts` `:706`. The functional-completeness `calendar` row says so. - -Every other asserting record cites only files that are byte-identical across the hop (`git diff --quiet`, checked file by file), and each gains a hop sentence that says so. That covers `form.tsx`, `autoLayout.ts` and `field-type-alias.ts`, so `FormField.span: 'full'` still takes the whole row at every multi-column tier, and `ObjectGrid.tsx` and `data-table.tsx`, so the `keyboardNavigation` read still finds 15 hit lines against 3 for the control (no line of the hop's diff names `keyboardNavigation`). - -The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `7578323a544e` number. The corpus is now 8642 tracked files, and every retired name each entry counts still reads 0 (a superset of 99 names: the 13 lifecycle, 31 metrics and 37 tracing exports, and 18 key names the entries retire or count; the unrelated `Span` 519 and `SpanSchema` 57 are unchanged). The lit controls moved with the corpus, for example `objectstack` from 18552 to 18601, `timeout` from 1686 to 1700 and `tenant` from 1404 to 1415. - -No key, default, enum member or export moves. diff --git a/.changeset/pre/objectui-pin-citations-20c6d351ad74.md b/.changeset/pre/objectui-pin-citations-20c6d351ad74.md deleted file mode 100644 index 8878ec5cb20..00000000000 --- a/.changeset/pre/objectui-pin-citations-20c6d351ad74.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `20c6d351ad74`. - -Clause-②: no - -Every anchor was mapped through the objectui diff `47b1f0bb7174..20c6d351ad74`: 320 paths over 24 commits, 2 of them deleted test files, none renamed. Ten files a record cites changed on the hop, plus the `en` / `zh` / `de` packs: `ListView.tsx` and `ObjectTimeline.tsx` (objectui#6152 rounds 14 and 15), plugin-view's `ObjectView.tsx`, `objectql.ts` and `objectql.zod.ts` (the same rounds and objectui#12063), `KanbanImpl.tsx` and `plugin-grid/src/index.tsx` (objectui#6349 batch 7), `ActionRunner.ts` (objectui#6349 batch 4), `data-objectstack/src/index.ts` (objectui#5144) and `previews/block-config.ts` (objectui#12056). Every cited line in them moved or held with its text byte-identical, except in the four readings below, and each record that cites one is re-pointed with a hop sentence that says by how much. The records that cite only byte-identical files gain a hop sentence that says so. - -Three readings changed around a cited read, and each hop sentence says what it found: - -- The calendar, gantt and tree arms of `ListView.tsx` changed inside (objectui `3fd862510` retired their raw block spreads, the calendar's `defaultView` lift and the tree's `titleField` label rung). The calendar and gantt rows of `functional-completeness.ts` still hold: their date bindings stay conditional, with no floor. -- `previews/block-config.ts` now writes the repeater's object and limit into the node-level `dataSource` (objectui#12056); the two inspectors span `311-351`. -- `ObjectTimeline.tsx`'s start-date chain lost its nested `timeline.dateField` rung; it still ends without a literal rung, and the component's `schema.*` read set is unchanged. - -The fourth reading: one record names a read that is gone at this pin. The `OBJECT_TREE_FLAT_CONFIG_GUIDANCE` docblock keeps `titleField` in the flat tree key set because `ListView`'s flatten resolved `treeCfg.titleField` into `labelField`. objectui `3fd862510` removed that rung. The hop sentence records it; the paragraph and the key set are left as they are, because changing the key set is a schema decision this re-read does not make. - -The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `47b1f0bb7174` number. The corpus is now 8351 tracked files. Every token an entry counts as zero still reads zero (97 tokens). The controls moved with the corpus, for example `objectstack` from 17980 to 18047 and `timeout` from 1674 to 1694. - -No key, default, enum member or export moves. diff --git a/.changeset/pre/objectui-pin-citations-47b1f0bb7174.md b/.changeset/pre/objectui-pin-citations-47b1f0bb7174.md deleted file mode 100644 index ec789811280..00000000000 --- a/.changeset/pre/objectui-pin-citations-47b1f0bb7174.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `47b1f0bb7174`. - -Clause-②: no - -Every anchor was mapped through the objectui diff `f0268ad78485..47b1f0bb7174`: 129 paths over 14 commits, none deleted or renamed. Two files a record cites changed on the hop, each for objectui#11865 (a picker drawn with the shared `Select`): `ObjectGrid.tsx` gained one import line and redrew its grouped pager's rows-per-page picker, and `ListView.tsx` redrew its "Color by field" and rows-per-page pickers. Every cited line in those two files moved with its text byte-identical, so the nine records that cite them are re-pointed, and each hop sentence says by how much. The other 34 records cite only files that are byte-identical across the hop, and each gains a hop sentence that says so. - -No record says anything false at the new pin, so no reading is rewritten. - -The `FormField.span` describe changes its sha only: `WIDE_FIELD_TYPES`, the field-type alias table and `spanLadderFor` are byte-identical at the new pin. The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `f0268ad78485` number. The corpus is now 8281 tracked files. Every token an entry counts as zero still reads zero: none of them occurs on a line the hop adds or removes. The controls moved with the corpus, for example `objectstack` from 17956 to 17980 and `timeout` from 1658 to 1674. - -No key, default, enum member or export moves. diff --git a/.changeset/pre/objectui-pin-citations-4997995022f8.md b/.changeset/pre/objectui-pin-citations-4997995022f8.md deleted file mode 100644 index 11d1d3c5128..00000000000 --- a/.changeset/pre/objectui-pin-citations-4997995022f8.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `4997995022f8`. - -Clause-②: no - -Every anchor was mapped through the objectui diff `0df67f237c5a..4997995022f8`: 80 paths over 7 commits, none deleted and one renamed (a schema-catalog example, which no record cites). Three files a record cites changed on the hop, plus the `en` / `zh` / `de` packs (whose `calendar.configRequired` strings did not change): `ObjectGrid.tsx` (objectui#12081 item 3 and objectui#12082), `ObjectGantt.tsx` (objectui#12082) and `form.tsx` (objectui#12121). Every cited line in them moved or held with its text byte-identical, and each record that cites one is re-pointed with a hop sentence that says by how much. The records that cite only byte-identical files gain a hop sentence that says so. - -Two readings sit beside a cited read, and each hop sentence says what it found: - -- `ObjectGrid.tsx`: a bulk `update` def is now withheld unless the edit verdict allows it (objectui#12082), which narrows the declared defs and adds no implicit one, so the `sys_api_key` reading holds. The selection block still hashes to `c88443302d40`. -- `ObjectGantt.tsx`: the chart's in-chart write handlers are withheld from a caller without the grant (objectui#12082). No `schema.*` read moved; the renderer reads the same 49 names at both pins. - -The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `0df67f237c5a` number. The corpus is now 8441 tracked files. Every token an entry counts as zero still reads zero (96 tokens; `Span` and `SpanSchema` read 517 and 57 at both pins, as the tracing entry records). The controls moved with the corpus, for example `objectstack` from 18248 to 18271 and `timeout` from 1705 to 1704. - -No key, default, enum member or export moves. diff --git a/.changeset/pre/objectui-pin-citations-7578323a544e.md b/.changeset/pre/objectui-pin-citations-7578323a544e.md deleted file mode 100644 index 74709155402..00000000000 --- a/.changeset/pre/objectui-pin-citations-7578323a544e.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `7578323a544e`. - -Clause-②: no - -Every anchor was mapped through the objectui diff `9646e2ed2bf3..7578323a544e`: 288 paths over 25 commits. Three files that records cite changed on the hop, and every cited line in them held its text; where it moved, it moved byte-identical and is re-pointed: - -- `plugin-list/src/ListView.tsx` (+34/-79, objectui#12217, objectui `f5b66bf97`: an empty gallery draws the grid empty state). One import line was added at `:16` and a four-line docblock near `:1040`; the other hunks change lines in place (`:10`, `:12`, `:1044`) or sit at `:5677` and below. So `resolveListMapConfig` moved `:209` -> `:210`, `FLAT_MAP_CONFIG_SPELLING` `:95` -> `:96` and `resolveTimelineDateBinding` `695` -> `696`. The `kanban` / `calendar` / `timeline` / `gantt` / `map` / `tree` arms moved by five, to `3767` / `3818` / `3907` / `3964` / `4084` / `4125-4149`. The functional-completeness `calendar`, `gantt`, `timeline` and `map` rows, the `object-gantt` and flat-`TreeConfig` records and the map-config record say so, and the tree record's `4120-4144` and the map-config record's two quoted anchors are re-pointed. The `kanban` row-cap record names `ListView.tsx` and `ObjectView.tsx` only as an earlier re-read's record, and its sentence says so. -- `core/src/actions/ActionRunner.ts` (+39/-23, objectui#12191, objectui `334f0cff9`): eleven import lines were added above `composeSuccessMessage`, which moved `1498` -> `1509` with its body byte-identical, and every other hunk lies below it. The `outcomeMessages` record in `action.zod.ts` re-points its anchor, and it and the header of `action-outcome-messages.test.ts` say so. The four action renderers are byte-identical. -- The `en` / `zh` / `de` packs (five commits each): the `calendar.configRequired` strings did not change and moved `en.ts` `:949` -> `:953`, `zh.ts` `:725` -> `:726` and `de.ts` `:705` -> `:706`. - -Every other record cites only files that are byte-identical across the hop (`git diff --quiet`, checked file by file), and each gains a hop sentence that says so. That covers `form.tsx`, so `FormField.span: 'full'` still takes the whole row at every multi-column tier. - -The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `9646e2ed2bf3` number. The corpus is now 8600 tracked files, and every retired name each entry counts still reads 0. The lit controls moved with the corpus, for example `objectstack` from 18438 to 18552, `timeout` from 1707 to 1686 and `tenant` from 1393 to 1404. - -No key, default, enum member or export moves. diff --git a/.changeset/pre/objectui-pin-citations-9646e2ed2bf3.md b/.changeset/pre/objectui-pin-citations-9646e2ed2bf3.md deleted file mode 100644 index 7bd16c7b11c..00000000000 --- a/.changeset/pre/objectui-pin-citations-9646e2ed2bf3.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `9646e2ed2bf3`. - -Clause-②: no - -Every anchor was mapped through the objectui diff `088e0f705520..9646e2ed2bf3`: 64 paths over 9 commits. One file that any record cites changed on the hop: `plugin-list/src/ListView.tsx` (+9/-9, objectui#12189, objectui `26b0bd2d1`, which renames the FilterBuilder group type to `FilterBuilderRowGroup`). All nine lines were replaced in place, the last at `:1558`, so no line in the file moved and no cited range contains a changed line: - -- the `kanban` / `calendar` / `gantt` / `timeline` / `map` / `tree` arms of its per-type props builder held at `3762` / `3813` / `3959` / `3902` / `4079` / `4120-4144`; -- `resolveListMapConfig` (`:209-214`) and `resolveTimelineDateBinding` (`:695-699`) held, and each ends before the next changed line below it (`:324`, `:756`). - -That file is cited by the four view-type rows of the functional-completeness table (`calendar`, `gantt`, `timeline`, `map`) and by the `object-gantt` and flat-`TreeConfig` records. Each of those gains a hop sentence naming it. The `kanban` row-cap record names `ListView.tsx` only as an earlier re-read's record, and its sentence says so. - -Every other record cites only files that are byte-identical across the hop (`git diff --quiet`, checked file by file), and each gains a hop sentence that says so. That covers `form.tsx`, so `FormField.span: 'full'` still takes the whole row at every multi-column tier. - -The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `088e0f705520` number. The corpus is now 8540 tracked files, and every retired name each entry counts still reads 0. The lit controls moved with the corpus, for example `objectstack` from 18400 to 18438, `tenant` from 1365 to 1393 and `useState` from 2661 to 2662. - -No key, default, enum member or export moves. diff --git a/.changeset/pre/objectui-pin-citations-a58626c88dc8.md b/.changeset/pre/objectui-pin-citations-a58626c88dc8.md deleted file mode 100644 index 2d206d29259..00000000000 --- a/.changeset/pre/objectui-pin-citations-a58626c88dc8.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `a58626c88dc8`. - -Clause-②: no - -Every anchor was mapped through the objectui diff `0abd4f9f8769..a58626c88dc8`, 252 paths over 36 commits. Fifteen of those paths are files an asserting record cites: `ObjectKanban.tsx`, `KanbanImpl.tsx`, `ObjectTree.tsx`, `ObjectTimeline.tsx`, `record-details.tsx`, `action-group.tsx`, `action-menu.tsx`, `static-params.ts`, `MetricWidget.tsx`, `MetricCard.tsx`, `form.tsx`, `plugin-kanban.mdx` and the `en` / `zh` / `de` packs. In each, every cited line is byte-identical at the new pin, so each anchor that moved was re-pointed to its new line and the record says by how much; no read point an asserting record cites changed content or died. Every other cited file is byte-identical across the hop (`git diff --quiet`). The seven quoted anchor lines verify against objectui at the new pin. Three records carry a count, and each count was re-taken by its record's own method with the same reading: the `keyboardNavigation` hit lines (15, against 3 for the `schema.editable` control), `ObjectKanban.tsx`'s `quickAdd` / `onQuickAdd` (2 each, against 11 for `onCardClick`), and the `ElementDataSourceGate` occurrences in five `src/index.tsx` shells (0, 3, 3, 3 and 4). - -The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that first reproduced every `0abd4f9f8769` number. The corpus is now 7754 tracked files. All 99 checked tokens (the export lists of `plugin-lifecycle-advanced.zod.ts`, `tracing.zod.ts` and `metrics.zod.ts`, plus every named key) still read zero, except `Span` / `SpanSchema`, which read 509 / 57: the one new `Span` hit is a `colSpan`. - -No key, default, enum member or export moves. diff --git a/.changeset/pre/objectui-pin-citations-b9814b334d8a.md b/.changeset/pre/objectui-pin-citations-b9814b334d8a.md deleted file mode 100644 index 3f1305b5317..00000000000 --- a/.changeset/pre/objectui-pin-citations-b9814b334d8a.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `b9814b334d8a`. Four descriptions this pin makes true change with it. - -Clause-②: no - -The four descriptions, each now read by the console at this pin: - -- `EmptyStateSchema.link`, the one link an empty state carries, no longer opens with `[PLANNED — not yet rendered]`. The console draws it in a list's empty state (the `grid` and `gallery` visualizations) and in an `object-grid` block's empty state, and follows it through its action runner (objectui#12270). -- A list page's own `interfaceConfig.emptyState` no longer opens with `[PLANNED — not yet rendered]`. The console's list page prefers it, whole, over its source view's empty state (objectui#12270). -- A `record:details` section's `count` no longer opens with `[EXPERIMENTAL — not enforced]`. The record detail page fills the heading's `{{ count }}` hole (objectui#12192). -- The `object-grid` block's `emptyState` description names `link` among the members the grid draws. - -The two liveness-ledger notes that excluded `emptyState.link` and `interfaceConfig.emptyState` from their rows' `live` verdict now say both are covered. - -Every asserting citation (55, across 2047 spec sources) was mapped through the objectui diff `1fb1ced36243..b9814b334d8a`: 397 paths over 36 commits. Where a cited file changed, each cited line was re-read and its new number recorded; none changed what it reads. The moved files are `ObjectGrid.tsx`, `data-table.tsx`, `ListView.tsx`, `ObjectCalendar.tsx`, the `en` / `zh` / `de` packs, `record-details.tsx`, `record-highlights.tsx`, `record-quick-actions.tsx`, `data-objectstack/src/index.ts`, `objectql.ts`, `plugin-kanban/src/index.tsx`, `plugin-grid/src/index.tsx`, `plugin-calendar/src/index.tsx` and `block-types.ts`. Every other record cites only files that are byte-identical across the hop, and each gains a hop sentence that says so. Three quoted anchors of the `map` block record moved and were re-pointed; they are `case 'map':` in `ObjectView.tsx`, and `ObjectMapConfigSchema` and `LIST_VIEW_LOCAL_OVERRIDES` in `objectql.zod.ts`. - -The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `1fb1ced36243` number. The corpus is now 8743 tracked files. A superset of 98 names reads 0: the 13 lifecycle, 31 metrics and 37 tracing exports, and 17 further names the entries count (their retired key names and the two logging defs). The unrelated `Span` 519 and `SpanSchema` 57 are unchanged. The lit controls moved with the corpus, for example `objectstack` from 18601 to 18854, `timeout` from 1700 to 1750 and `tenant` from 1415 to 1420. - -No key, default, enum member or export moves. diff --git a/.changeset/pre/objectui-pin-citations-e5311165a8d6.md b/.changeset/pre/objectui-pin-citations-e5311165a8d6.md deleted file mode 100644 index aed08ffeb0d..00000000000 --- a/.changeset/pre/objectui-pin-citations-e5311165a8d6.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `e5311165a8d6`. - -Clause-②: no - -Every anchor was mapped through the objectui diff `4997995022f8..e5311165a8d6`: 287 paths over 24 commits, four deleted (`StudioHomePage.tsx`, its two tests and `permission-set-clone-dispatch.ts`, none of which a record cites) and none renamed. Five files a record cites changed on the hop, plus the `en` / `zh` / `de` packs (whose `calendar.configRequired` strings did not change): `ObjectGrid.tsx` (objectui#12110 and objectui#12143), `data-table.tsx` (objectui#12124 and objectui#12145), `form.tsx` (objectui#12110, objectui#12130 and objectui#12155), `record-related-list.tsx` (objectui#12082) and `data-objectstack/src/index.ts` (objectui#12112). Every cited line in them moved or held with its text byte-identical, and each record that cites one is re-pointed with a hop sentence that says by how much. The records that cite only byte-identical files gain a hop sentence that says so. - -Two readings sit beside a cited read, and each hop sentence says what it found: - -- `record-related-list.tsx`: the list's fallback remove now reads the affordance-to-grant map's `relatedRowDelete` row (objectui#12082). The hunks sit above and below the `requiredPermissions` gate; the gate and the related object's read gate are byte-identical, 17 lines down. -- `form.tsx`: the built-in branches now settle the widget a field hint renders and the props they hand their control (objectui#12110, objectui#12155, objectui#12130). `spanLadderFor` and its one call site are byte-identical, so the `span` describe's reading holds. - -The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `4997995022f8` number. The corpus is now 8506 tracked files. Every token an entry counts as zero still reads zero: 124 candidate tokens (every export of the four spec files the entries name, and every key they name) read zero at both pins. `Span` reads 519 against 517, both new hits `colSpan`, and `SpanSchema` reads 57 at both pins, as the tracing entry records. The controls moved with the corpus, for example `objectstack` from 18271 to 18355 and `timeout` from 1704 to 1706. - -No key, default, enum member or export moves. diff --git a/.changeset/pre/objectui-pin-citations-f0268ad78485.md b/.changeset/pre/objectui-pin-citations-f0268ad78485.md deleted file mode 100644 index 826285c56a8..00000000000 --- a/.changeset/pre/objectui-pin-citations-f0268ad78485.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `f0268ad78485`. - -Clause-②: no - -Every anchor was mapped through the objectui diff `a58626c88dc8..f0268ad78485`, 1061 paths over 170 commits. Where a cited line moved with its text byte-identical, the record is re-pointed and its new hop sentence says by how much. Where objectui rewrote a cited line around the same read, the range is re-read and the hop sentence says so. - -Three records said something that is false at the new pin, and only those sentences are rewritten: - -- `element:repeater` reads the node-level `dataSource` binding first since objectui#11880 (objectui `aaba8655a`), with its flat `object` / `filter` / `sort` / `limit` keys as the fallback. The row's docblock said the binding was not read. It now states the precedence the renderer applies, and that a node naming its object only through `dataSource` renders while this row still requires `object`: that requirement can follow the renderer only by a schema change, which is objectstack#11509's. -- `action:button` now publishes `undoable` and hands the runner the record in scope as its Undo baseline (objectui#11168, objectui `f0496bdf1`). The row's docblock said the input stayed unpublished because the block wrote no row stash. -- `action:icon` reads `label` six times, not five: the tooltip of a predicate-disabled icon takes it as its heading (objectui#11839, objectui `cef0eeec0`). - -The `FormField.span` describe changes its sha only: `WIDE_FIELD_TYPES`, the field-type alias table and `spanLadderFor` are byte-identical at the new pin. The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that first reproduced every `a58626c88dc8` number. The corpus is now 8234 tracked files. All 99 checked tokens still read zero, except `Span` / `SpanSchema`, which read 517 / 57: the eight new `Span` hits are each a `colSpan`. - -No key, default, enum member or export moves. diff --git a/.changeset/pre/org-ownership-scim-provider-inventory.md b/.changeset/pre/org-ownership-scim-provider-inventory.md deleted file mode 100644 index 42543784f48..00000000000 --- a/.changeset/pre/org-ownership-scim-provider-inventory.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -fix(cli): `os migrate organization-ownership` plans a v17 database that still holds the retired `sys_scim_provider` table, listing it as fate 4 instead of refusing it (ADR-0131 D10) - -Clause-②: no - -`sys_scim_provider` was retired from the platform before the ceremony's inventory was taken, so the inventory had no row for it. Its ADR-0087 entry (`scim-provider-object-retired`) leaves the table in place, untouched, by ruling, so every database that booted a release from before the retirement still holds it, `organization_id` column included. The plan refused such a database with `uninventoried-platform-table` (`sys_scim_provider carries organization_id but the ADR-0131 D10 inventory gives it no fate`), and so did `--apply`, so the ceremony could not run there at all and `os dev` / `os serve` stayed refused. - -- **Now** the inventory lists `sys_scim_provider` with fate 4 (`report`), under a new census population, `retired-before-census`. The plan reports its rows and its NULL-organization rows by id. It never attributes them, not even to the Default Organization under `single`, and never deletes them. -- **NOT NULL:** the table is exempt, so the plan reports it as not receiving NOT NULL. The object is retired, no declaration anchors tenancy on the column, and a SCIM connection scoped to no organization was a legitimate row. -- The plan's `inventoryDigest` changes with the new row. A plan file written by an earlier CLI no longer matches, so `--apply --plan-file` refuses it with `plan-digest-mismatch`. Re-run `os migrate organization-ownership --out PLAN`, review it, and apply that. diff --git a/.changeset/pre/request-scoped-grants-memo.md b/.changeset/pre/request-scoped-grants-memo.md deleted file mode 100644 index 434b3cbd634..00000000000 --- a/.changeset/pre/request-scoped-grants-memo.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -'@objectstack/core': patch -'@objectstack/plugin-auth': patch ---- - -perf(core,plugin-auth): an authenticated request resolves its caller's grants once, not twice - -Clause-②: no - -`resolveAuthzContext` learns who the caller is from the transport's `getSession`. Against `@objectstack/plugin-auth` that is better-auth's `getSession`, whose `customSession` hook resolves the principal's grants for the session payload's `positions[]` and `isPlatformAdmin`; `resolveAuthzContext` then resolved the same grants again, with the same arguments, for the request's envelope. Every authenticated request on a door that resolves identity through `resolveAuthzContext` with a better-auth session read (the runtime dispatcher and the REST server among them) paid every grant read twice: eight reads per resolution for a caller in an organization. - -`resolveAuthzContext` now runs inside a request-scoped grants memo. A resolution that already completed inside the same call, with the same user, organization and seeds, is served to the next caller that asks for exactly that resolution, so the hook's resolution serves the resolver's own. The decision a request is authorised with is unchanged: - -- The memo lives for one `resolveAuthzContext` call (an `AsyncLocalStorage` scope, closed when the call settles). Nothing is cached across requests; the cross-request grants cache keeps its own default-off switch, `OS_AUTHZ_GRANTS_CACHE_TTL_MS`. -- An entry is served only when a fresh read would agree with it. No write may have started, been executed at the driver, or still be in flight on the engine since the first resolution opened: the engine's write epoch (bumped when a write starts) and a write observer the memo registers as an engine middleware (counting writes into the chain and, after the driver step, out of it) must both read what they read at the open, with nothing inside the chain. The observer sees statement execution, not commit visibility. A write inside an `engine.transaction()` becomes visible only at its COMMIT, outside every chain. On driver-sql (not on the Turso remote face, which opens no transaction), a request whose step 2 falls inside that one commit round trip is authorised as of its first resolution, and the next request reads fresh — the same answer as a write from another process. No grant validity boundary may lie between the two clocks. The organization must be the same (the session arm's dropped-claim re-resolution is its own entry). A `bypassGrantsCache` caller is never served, a failed resolution is never stored, and an engine without the write-epoch seam or `registerMiddleware` declines entirely. -- `plugin-auth`'s hook now passes the session's email as its seed email, exactly as `resolveAuthzContext` does for the same session, so the two calls ask for the same resolution. The seed reaches only the envelope's `email`, which the hook does not read: the payload's `positions[]` and `isPlatformAdmin` are unchanged, and platform-admin standing still compares the stored `sys_user.email`, never a seed. - -Where the saving does not apply, the request reads the grants twice, as before — always the safe direction: - -- a request that meets a concurrent write on its engine; -- a request whose session read itself writes: the first request on a fresh auth instance generates its signing key, and with an idle timeout configured `enforceSessionControls` stamps `sys_session.last_activity_at` about once a minute per session; -- the first `resolveAuthzContext` call on an engine, which registers the write observer and stores nothing for it. diff --git a/.changeset/pre/v18-ceremony-dev-databases.md b/.changeset/pre/v18-ceremony-dev-databases.md deleted file mode 100644 index 8debe93fbf0..00000000000 --- a/.changeset/pre/v18-ceremony-dev-databases.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -"@objectstack/cli": minor -"@objectstack/runtime": minor ---- - -The v18 organization-ownership ceremony now completes on a long-lived 17.x database, and `os dev` runs it for you on a local development database (ADR-0131, amendment 2026-10-16). - -**`os dev` upgrades a v17-shape development database in place.** When the boot refuses a local SQLite development database that is still in the v17 organization-ownership shape, `os dev` first copies the database and its `telemetry` sibling (with `-wal` / `-shm`) into `/backups/organization-ownership-/`. It then runs `os migrate organization-ownership --dev` (the plan), applies that plan, and starts the server again. The plan, the apply report and the ceremony's log are kept beside the copy. If a step blocks, `os dev` stops and prints the blocker, where the copy is, and how to resume. `os serve` / `os start`, and any database that is not a local SQLite file, keep the manual ceremony and the boot refusal unchanged. - -**What the ceremony decides for you is printed as a warning before it writes anything.** The plan carries a `warnings` list. The plan prints it, `--apply` prints it again before its first write, and the apply report keeps it. - -**The `telemetry` sibling is covered.** A development boot (or an `OS_TELEMETRY_DB` deployment) keeps `sys_audit_log`, `sys_job_run`, `sys_notification` and the other lifecycle objects in a sibling database. The ceremony now plans, attributes and drops columns for each object in the database it lives in, reading anchors across databases. Before, the boot refusal read those tables in the sibling while the ceremony only read the primary, so an upgraded database could still never boot. A copy of such a table that an older boot left in the primary is listed under `staleCopies`, warned about, and left untouched; it no longer blocks the column drops. - -**The shipped permission sets an install seeded before 2026-08-15 are adopted.** Those installs store the eight shipped sets (`organization_admin`, `member_default`, …) as `managed_by: 'admin'` rows. The authored-conversion step used to refuse them with "rename the authored row". The ceremony now re-owns them as the platform seeds they are and deletes them as mirrors once every grant names its set, so the shipped definitions apply. The warning names each row; a Setup edit to one of them is not carried over, and the pre-upgrade backup keeps it. - -**`os migrate organization-ownership --dev`** composes the stack as `os serve --dev` does: the development auth secret's fallback (so the security and audit plugins), the development `.env*` files, and the `telemetry` sibling. A development boot's refusal now names the command with `--dev`, and every refusal now names an apply the ceremony accepts: `--out PLAN` first, then `--apply --plan-file PLAN --backup-taken`. A bare `--apply` is refused without a reviewed plan file. Before, running the ceremony from a shell without `NODE_ENV=development` composed neither plugin, and its apply blocked on the shipped permission sets and on `sys_audit_log`. - -`OrganizationOwnershipBootGuardPlugin` (`@objectstack/runtime`) takes `{ dev }`; `organizationOwnershipRefusalMessage` and `assertOrganizationOwnershipMigrated` take the same option. diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index ef667678886..81907775f9f 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -75,7 +75,7 @@ docker run -p 8080:8080 \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET \ -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.7.0 + ghcr.io/objectstack-ai/objectstack:18.0.0 ``` (`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come @@ -93,7 +93,7 @@ docker run -p 8080:8080 \ -e OS_ARTIFACT_URL="https://releases.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.7.0 + ghcr.io/objectstack-ai/objectstack:18.0.0 ``` Both schemes work: `https://…` is fetched at boot, `file:///…` is read directly @@ -150,7 +150,7 @@ COPY . . RUN npx os build # → dist/objectstack.json # ── Runtime: the official ObjectStack runtime image ────────────────── -FROM ghcr.io/objectstack-ai/objectstack:17.7.0 +FROM ghcr.io/objectstack-ai/objectstack:18.0.0 COPY --from=build --chown=node:node /app/dist/objectstack.json /srv/app/objectstack.json ``` @@ -173,7 +173,7 @@ FROM node:22-slim RUN npm install --prefix /tmp/os-installer-npm --no-save --no-audit --no-fund "npm@11.21.0" \ && export PATH="/tmp/os-installer-npm/node_modules/.bin:$PATH" \ && test "$(npm --version)" = "11.21.0" \ - && npm install -g @objectstack/cli@17.7.0 "pg@^8.0.0" "mysql2@^3.0.0" \ + && npm install -g @objectstack/cli@18.0.0 "pg@^8.0.0" "mysql2@^3.0.0" \ && npm cache clean --force \ && rm -rf /tmp/os-installer-npm diff --git a/content/docs/upgrading.mdx b/content/docs/upgrading.mdx index 5faa4507528..3957837e2f4 100644 --- a/content/docs/upgrading.mdx +++ b/content/docs/upgrading.mdx @@ -52,7 +52,7 @@ The official image is `ghcr.io/objectstack-ai/objectstack`, and its tags mirror ```bash # docker-compose.yml, or your orchestrator's manifest -image: ghcr.io/objectstack-ai/objectstack:17.7.0 +image: ghcr.io/objectstack-ai/objectstack:18.0.0 ``` On a host running the artifact directly under systemd, the same move is a file diff --git a/docker/README.md b/docker/README.md index c5dfc558e5d..fe6188382bd 100644 --- a/docker/README.md +++ b/docker/README.md @@ -29,7 +29,7 @@ Multi-arch: `linux/amd64` + `linux/arm64`. [Self-Hosted Deployment](https://objectstack.ai/docs/deployment/self-hosting)): ```dockerfile -FROM ghcr.io/objectstack-ai/objectstack:17.7.0 +FROM ghcr.io/objectstack-ai/objectstack:18.0.0 COPY --chown=node:node dist/objectstack.json /srv/app/objectstack.json ``` @@ -40,7 +40,7 @@ docker run -p 8080:8080 \ -v "$PWD/dist/objectstack.json:/srv/app/objectstack.json:ro" \ -e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \ -e OS_AUTH_SECRET -e OS_SECRET_KEY \ - ghcr.io/objectstack-ai/objectstack:17.7.0 + ghcr.io/objectstack-ai/objectstack:18.0.0 ``` `OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come @@ -72,7 +72,7 @@ for a `file:…` path — one box only, wrong for multi-node) and MongoDB (`libsql://…` / Turso). Add one by extending the image: ```dockerfile -FROM ghcr.io/objectstack-ai/objectstack:17.7.0 +FROM ghcr.io/objectstack-ai/objectstack:18.0.0 USER root RUN npm install -g tedious USER node @@ -100,5 +100,5 @@ reverse-proxy / multi-node guidance: ## Local build of this image ```bash -docker build -t objectstack:dev --build-arg OS_CLI_VERSION=17.7.0 docker/ +docker build -t objectstack:dev --build-arg OS_CLI_VERSION=18.0.0 docker/ ``` diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index dd9abacf1e4..b6b37d1cf91 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,447 @@ # @objectstack/example-crm +## 4.0.100 + +### Patch Changes + +- Updated dependencies [5b14b7f] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [c395405] +- Updated dependencies [1e8b628] +- Updated dependencies [3b8e2b1] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [cd3d391] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [9e9e6f9] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [ca135dc] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [0fea05f] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [cb3bb93] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [117817e] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [8024e12] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [a3d47c2] +- Updated dependencies [21c8644] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [d4aaeaf] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [917b1a0] +- Updated dependencies [d9d7edd] +- Updated dependencies [4e0838d] +- Updated dependencies [d45d0e9] +- Updated dependencies [cb9841a] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [01d5034] +- Updated dependencies [f24574a] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [5d3018d] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [28e294f] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- 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 [3fed1eb] +- 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 [a35f7a6] +- 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 [9fcea20] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [ee0e115] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [cd86e41] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [395ca21] +- Updated dependencies [9e0945c] +- Updated dependencies [23d0bc7] +- Updated dependencies [7428da0] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [b55be42] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [f7a5ebe] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9d4fb3c] +- Updated dependencies [c5cf546] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [34b740f] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [ae4dfaf] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2e15620] +- Updated dependencies [0cb45e9] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [605aa01] +- Updated dependencies [d050a8f] +- Updated dependencies [9e797a8] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [6e43b7b] +- Updated dependencies [6dede7a] +- Updated dependencies [727091c] +- Updated dependencies [62f959d] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [d702b18] +- Updated dependencies [a06f0d4] +- Updated dependencies [9f024d8] +- Updated dependencies [5a9a80b] +- Updated dependencies [1489952] +- Updated dependencies [8eccf51] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [1bf5f6e] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [58a2faa] +- Updated dependencies [2bcd406] +- Updated dependencies [e74ec56] +- Updated dependencies [af284b0] +- Updated dependencies [cbfa5e4] +- Updated dependencies [14a8bd2] +- Updated dependencies [9822b05] +- Updated dependencies [889bf12] +- Updated dependencies [7a8733f] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [d79dc48] +- Updated dependencies [1173017] +- Updated dependencies [51ad8e4] +- Updated dependencies [c4526a7] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [6868514] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [964a237] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [cdf3777] + - @objectstack/runtime@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/service-i18n@18.0.0 + ## 4.0.100-next.5 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index 5c2adda7dc8..eda88107a5f 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.5", + "version": "4.0.100", "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 d2bb69e5c8f..23441e7e514 100644 --- a/examples/app-multi-package/CHANGELOG.md +++ b/examples/app-multi-package/CHANGELOG.md @@ -1,5 +1,344 @@ # @objectstack/example-multi-package +## 0.0.7 + +### Patch Changes + +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0 + ## 0.0.7-next.5 ### Patch Changes diff --git a/examples/app-multi-package/package.json b/examples/app-multi-package/package.json index 88fac774981..71881a6d408 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.5", + "version": "0.0.7", "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 8af4e3d6a62..ed3cdf3a4ec 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,478 @@ # @objectstack/example-showcase +## 0.3.22 + +### Patch Changes + +- Updated dependencies [5b14b7f] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [c395405] +- Updated dependencies [1e8b628] +- Updated dependencies [3b8e2b1] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [cd3d391] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [8a399b2] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [8601526] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [b15f8a1] +- Updated dependencies [d9a749b] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [ca135dc] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [f8566a0] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [86f53a4] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [0fea05f] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [cb3bb93] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [a360cee] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [117817e] +- Updated dependencies [6a6a4d3] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [8024e12] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [a3d47c2] +- Updated dependencies [21c8644] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [d4aaeaf] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [08b38cb] +- Updated dependencies [87d0619] +- Updated dependencies [917b1a0] +- Updated dependencies [d9d7edd] +- Updated dependencies [4e0838d] +- Updated dependencies [d45d0e9] +- Updated dependencies [cb9841a] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [01d5034] +- Updated dependencies [f24574a] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [5d3018d] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [28e294f] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- 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 [3fed1eb] +- 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 [a35f7a6] +- 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 [9fcea20] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [ee0e115] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [cd86e41] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [395ca21] +- Updated dependencies [53646fc] +- Updated dependencies [9e0945c] +- Updated dependencies [23d0bc7] +- Updated dependencies [7428da0] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [b55be42] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [f7a5ebe] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9d4fb3c] +- Updated dependencies [c5cf546] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [34b740f] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [ae4dfaf] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2e15620] +- Updated dependencies [0cb45e9] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [605aa01] +- Updated dependencies [d050a8f] +- Updated dependencies [9e797a8] +- Updated dependencies [75ced06] +- Updated dependencies [c4111c6] +- Updated dependencies [a237d7f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [1fee13d] +- Updated dependencies [66d2757] +- Updated dependencies [c7168ac] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [6e43b7b] +- Updated dependencies [6dede7a] +- Updated dependencies [727091c] +- Updated dependencies [62f959d] +- Updated dependencies [8692a08] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [2bcaefb] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [d702b18] +- Updated dependencies [a06f0d4] +- Updated dependencies [9f024d8] +- Updated dependencies [5a9a80b] +- Updated dependencies [1489952] +- Updated dependencies [6973014] +- Updated dependencies [669296b] +- Updated dependencies [8eccf51] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [aa31569] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [1bf5f6e] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [58a2faa] +- Updated dependencies [2bcd406] +- Updated dependencies [e74ec56] +- Updated dependencies [af284b0] +- Updated dependencies [cbfa5e4] +- Updated dependencies [14a8bd2] +- Updated dependencies [9822b05] +- Updated dependencies [889bf12] +- Updated dependencies [7a8733f] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [d79dc48] +- Updated dependencies [1173017] +- Updated dependencies [51ad8e4] +- Updated dependencies [c4526a7] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [6868514] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [71fa166] +- Updated dependencies [ff48407] +- Updated dependencies [964a237] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [cdf3777] + - @objectstack/runtime@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/cloud-connection@18.0.0 + - @objectstack/driver-sql@18.0.0 + - @objectstack/service-datasource@18.0.0 + - @objectstack/service-print@18.0.0 + - @objectstack/connector-mcp@18.0.0 + - @objectstack/service-i18n@18.0.0 + - @objectstack/connector-openapi@18.0.0 + - @objectstack/connector-rest@18.0.0 + - @objectstack/connector-slack@18.0.0 + ## 0.3.22-next.5 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index ff8a5dc9be9..a3bd0532d35 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.5", + "version": "0.3.22", "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 482636adc44..d1af5b514ef 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,523 @@ # @objectstack/example-todo +## 4.0.100 + +### Patch Changes + +- Updated dependencies [f8ef144] +- Updated dependencies [5b14b7f] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [913fd6d] +- Updated dependencies [8eac321] +- Updated dependencies [de43524] +- Updated dependencies [a5a781b] +- Updated dependencies [04669a4] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [23dae89] +- Updated dependencies [c395405] +- Updated dependencies [1e8b628] +- Updated dependencies [3b8e2b1] +- Updated dependencies [14ccb1d] +- Updated dependencies [7879391] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [cd3d391] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [0b997ea] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [ca135dc] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [018b077] +- Updated dependencies [0fea05f] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [cb3bb93] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [87cd458] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [1959aac] +- Updated dependencies [117817e] +- Updated dependencies [4663801] +- Updated dependencies [d294951] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [8024e12] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [a3d47c2] +- Updated dependencies [21c8644] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [d4aaeaf] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [917b1a0] +- Updated dependencies [d9d7edd] +- Updated dependencies [4e0838d] +- Updated dependencies [d45d0e9] +- Updated dependencies [cb9841a] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [01d5034] +- Updated dependencies [f24574a] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [5d3018d] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [24e064f] +- Updated dependencies [0f05907] +- Updated dependencies [4f1e435] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [28e294f] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- 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 [3fed1eb] +- 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 [a35f7a6] +- 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 [9fcea20] +- Updated dependencies [0fb55fc] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [ee0e115] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [c7dd48a] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [cd86e41] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [361a1c4] +- Updated dependencies [3bc407d] +- Updated dependencies [395ca21] +- Updated dependencies [76560a1] +- Updated dependencies [4c21e57] +- Updated dependencies [9e0945c] +- Updated dependencies [23d0bc7] +- Updated dependencies [7428da0] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [b55be42] +- Updated dependencies [abb6df0] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [f7a5ebe] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9d4fb3c] +- Updated dependencies [c5cf546] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [34b740f] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [ae4dfaf] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2e15620] +- Updated dependencies [6d1660d] +- Updated dependencies [0cb45e9] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [605aa01] +- Updated dependencies [a33f3d5] +- Updated dependencies [d050a8f] +- Updated dependencies [9e797a8] +- Updated dependencies [b25d98a] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [6e43b7b] +- Updated dependencies [6dede7a] +- Updated dependencies [727091c] +- Updated dependencies [62f959d] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [d702b18] +- Updated dependencies [a06f0d4] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [5a9a80b] +- Updated dependencies [1489952] +- Updated dependencies [8eccf51] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [aa31569] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [1bf5f6e] +- Updated dependencies [1bf5f6e] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [58a2faa] +- Updated dependencies [2bcd406] +- Updated dependencies [e74ec56] +- Updated dependencies [af284b0] +- Updated dependencies [cbfa5e4] +- Updated dependencies [14a8bd2] +- Updated dependencies [9822b05] +- Updated dependencies [889bf12] +- Updated dependencies [7a8733f] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [d79dc48] +- Updated dependencies [1173017] +- Updated dependencies [51ad8e4] +- Updated dependencies [ec749fb] +- Updated dependencies [c4526a7] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [2f569a9] +- Updated dependencies [69147ab] +- Updated dependencies [3a734e0] +- Updated dependencies [6868514] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [9112bcd] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf16a0d] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [71fa166] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [45bec43] +- Updated dependencies [964a237] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [cdf3777] + - @objectstack/objectql@18.0.0 + - @objectstack/runtime@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/metadata@18.0.0 + - @objectstack/client@18.0.0 + - @objectstack/mcp@18.0.0 + - @objectstack/service-i18n@18.0.0 + - @objectstack/driver-sqlite-wasm@18.0.0 + - @objectstack/knowledge-memory@18.0.0 + - @objectstack/service-knowledge@18.0.0 + ## 4.0.100-next.5 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index eb85568d23e..2c83d9fc95f 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.5", + "version": "4.0.100", "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 99a3c6465c7..d2841833290 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,403 @@ # @objectstack/example-embed-objectql +## 0.0.40 + +### Patch Changes + +- Updated dependencies [f8ef144] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [913fd6d] +- Updated dependencies [8eac321] +- Updated dependencies [de43524] +- Updated dependencies [a5a781b] +- Updated dependencies [04669a4] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [7879391] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [0b997ea] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [018b077] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [87cd458] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [1959aac] +- Updated dependencies [4663801] +- Updated dependencies [d294951] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [24e064f] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [989aa48] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [cd86e41] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [f77ba41] +- Updated dependencies [76560a1] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [abb6df0] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [a33f3d5] +- Updated dependencies [d050a8f] +- Updated dependencies [b25d98a] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [aa31569] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [1bf5f6e] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [ec749fb] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [2f569a9] +- Updated dependencies [69147ab] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [9112bcd] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf16a0d] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [45bec43] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/objectql@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/driver-memory@18.0.0 + ## 0.0.40-next.5 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index c3c5f129c00..2dd5143fe77 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.5", + "version": "0.0.40", "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 1fa1b050028..a190ab8321d 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,344 @@ # @objectstack/hono +## 18.0.0 + +### Minor Changes + +- 5cfd866: feat(sharing): a share-link password can be sent in the `X-Share-Password` header whatever its characters, under a declared encoding (`X-Share-Password-Encoding: utf-8`) + + Clause-②: yes (widening) + + - **What was missing.** A browser cannot put a character above U+00FF in a request header (`Headers` throws a `TypeError` before the request leaves), and it strips leading and trailing spaces. `createLink` accepts any password, so a link whose password has a CJK character or an emoji could not be opened through the header. + - **What is now accepted.** A new companion request header, `X-Share-Password-Encoding`, declares how `X-Share-Password` is encoded. Its one value is `utf-8`, compared case-insensitively. Under it, `X-Share-Password` carries the password's UTF-8 bytes percent-encoded, as `encodeURIComponent(password)` produces them, and both public routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) decode it on both mounts: the sharing plugin's routes and the runtime dispatcher's `/share-links` domain. Both read the pair through one helper, `readSharePasswordHeader`, exported from `@objectstack/types` with the header-name constants. + - **Unchanged.** Without `X-Share-Password-Encoding`, `X-Share-Password` is read raw, exactly as before, so every value a client sends today resolves as it did. That includes a Latin-1 password and a raw password containing `%`; the server never percent-decodes a value nobody declared encoded. The `?password=` query parameter is still read first, and when it is present the header pair is not read. + - **What is refused.** `X-Share-Password-Encoding` naming any other value, or a password header that is not percent-encoded UTF-8 under `utf-8` (a `%` without two hex digits, octets that are not well-formed UTF-8, a character outside visible ASCII), answers `400 VALIDATION_FAILED` before the token is looked up. It is never compared raw instead. The message names the headers and the rule, never the presented value. + - **Response headers.** Both public routes now answer `Vary: X-Share-Password, X-Share-Password-Encoding`, still beside `Cache-Control: no-store`. + - **Cross-origin clients.** `X-Share-Password-Encoding` is in the default CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS` in `@objectstack/plugin-hono-server`, which the `@objectstack/hono` adapter also applies). A deployment that passes its own `allowHeaders` must add `X-Share-Password-Encoding` beside `X-Share-Password` to let a cross-origin client send an encoded password. +- 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) +- 75ced06: fix(hono)!: `createHonoApp` refuses a declared JSON body that does not parse with `400 INVALID_REQUEST` when the dispatched domain reads it, instead of dispatching it as `{}` + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The `${prefix}/*` catch-all handed `dispatch()` `{}` for a body that did not parse, and the data domain read that as an empty query or an empty update: a truncated `POST /data/OBJECT/query` answered every row the caller could read, and a truncated `PATCH` answered `200` for an update that changed nothing. + + What changes for an integrator, on a host that serves `createHonoApp`: + + - A `POST`, `PUT` or `PATCH` body sent as `application/json` (matched case-insensitively) that is not blank and does not parse, to a domain that reads the parsed body (the data, metadata and every other domain that takes `body`): FROM that domain's answer for `{}`, TO `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "The request body could not be parsed as JSON (it was sent as application/json). It was refused rather than read as an empty body: send a well-formed JSON document, or no body at all." } }`. The domain acts on nothing past its read of the body, and the dispatcher's own gates still answer first. This is the same status and body `@objectstack/plugin-hono-server` answers for the same request. + - The same applies to the `/auth/*` legacy fallback that runs when no auth service is registered, for every method it hands a body (all but `GET` and `HEAD`). + + **Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body. + + **Unchanged.** + + - An empty or whitespace-only JSON body is dispatched as `{}` (an empty query), as before. + - A body that does not declare JSON is read as before: parsed when it is JSON, `{}` when it is not. + - The request handed on beside the body still arrives unread, in every case. A domain that forwards it and reads its own bytes answers exactly as before, a malformed JSON body included: `/webhooks/redeliver` and the inbound automation hooks give their own refusal after their own sign-in or signature check, and the `/approvals/act` form `POST` reads its form. + - The catch-all dispatches `GET`, `HEAD` and `DELETE` with no body, and the `/auth/*` fallback hands `GET` and `HEAD` `{}`, as before. +- 76d9dca: fix(hono)!: `createHonoApp` reads a dispatched body by `@objectstack/plugin-hono-server`'s rule, so a JSON body sent as `text/plain`, or any other body that declares neither JSON nor a form, is refused `400 INVALID_REQUEST` when the dispatched domain reads it, instead of being parsed or dispatched as `{}` + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The `${prefix}/*` catch-all and the `/auth/*` legacy fallback parsed every body as JSON whatever its `Content-Type`, and handed `dispatch()` `{}` for any body that did not parse. So the two Hono doors answered one request two ways: `@objectstack/plugin-hono-server` (`objectstack dev`, `os serve`) refused a valid query body sent as `text/plain`, and this door honoured it; a garbled body that did not declare JSON reached the data domain here as `{}`, the empty query, and read every row the caller could read. + + What changes for an integrator, on a host that serves `createHonoApp`, for a `POST`, `PUT` or `PATCH` body read by a domain that reads the parsed body (the data, metadata and every other domain that takes `body`), and on the `/auth/*` legacy fallback for every method it hands a body (all but `GET` and `HEAD`): + + - A JSON body sent as `text/plain`, or as any media type other than JSON or a form: FROM parsed and honoured, TO `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "The request body was sent as text/plain, which this server does not read into a request body. It was refused rather than read as an empty body: send it as application/json." } }`, the message naming the media type that was sent. + - Any other body that declares neither JSON nor a form (`text/plain`, `application/octet-stream`, a `+json` suffix type such as `application/merge-patch+json`, or no `Content-Type` at all), when the request declares a body (`Content-Length` other than `0`, or `Transfer-Encoding`): FROM `{}`, TO the same refusal ("sent with no Content-Type" when there is none). + - A `multipart/form-data` or `application/x-www-form-urlencoded` body: FROM `{}`, TO its parsed fields, as `plugin-hono-server` hands them (Hono's `parseBody`: the last value of a repeated name). A form that does not parse: TO the refusal "The request body could not be parsed as form data (it was sent as multipart/form-data). It was refused rather than read as an empty body." + + The domain acts on nothing past its read of the body, and the dispatcher's own gates still answer first. Each answer is the status and body `@objectstack/plugin-hono-server` gives the same request. This replaces the earlier `createHonoApp` entry's statement that a body that does not declare JSON is read as before. + + **Remedy, in one line.** Send a JSON body with `Content-Type: application/json`, or send no body. + + **Unchanged.** + + - A body sent as `application/json` (matched case-insensitively) is parsed when well-formed, `{}` when blank or whitespace-only, and refused when it does not parse, as before. + - A request that declares no body is dispatched as `{}`, whatever its media type. + - The request handed on beside the body still arrives unread, in every case. A domain that forwards it and reads its own bytes answers exactly as before, whatever the body declares: `/webhooks/redeliver` and the inbound automation hooks give their own refusal after their own sign-in or signature check, and the `/approvals/act` form `POST` reads its form. + - The catch-all dispatches `GET`, `HEAD` and `DELETE` with no body, and the `/auth/*` fallback hands `GET` and `HEAD` `{}`, as before. + +### Patch Changes + +- 26aa999: fix(runtime,hono): approval e-mail links work on a kernel with no raw HTTP app — the dispatcher serves `/approvals/act`, and the Hono catch-all no longer consumes request bodies + + Clause-②: no + + On a self-hosted server, `@objectstack/plugin-approvals` serves the ADR-0043 action page (the approve / reject link in an approval e-mail or IM message) by mounting `GET` and `POST /api/v1/approvals/act` on the raw Hono app. A hosted tenant kernel has no raw app, so on that shape the path answered `404 ROUTE_NOT_FOUND` and every action link was dead. + + **`@objectstack/runtime`.** `HttpDispatcher` now serves exactly `/approvals/act` (no sub-paths) for `GET`, `HEAD` and `POST`. It forwards the request, unread, to the request kernel's `approvals` service (`IApprovalService.handleActionPage`) and returns that `Response` as it is. + + - `HEAD` answers as the self-hosted mount does: the `GET` page's status and headers, with no body. It renders and never decides. + - No `approvals` service registered, or one without `handleActionPage`: `501 NOT_IMPLEMENTED`, naming which. Never `ROUTE_NOT_FOUND`. + - A member that throws: a sanitised `500 INTERNAL_ERROR`, with the original error in the server log. The same answer, logged, when a transport hands over a `POST` whose body was already read, instead of an "invalid link" page for a live link. + - The token is the only credential, as on the self-hosted mount. The project-membership gate now skips the exact act path (unscoped or `/environments/:id`-scoped), so a signed-in caller who is not a member of the environment reaches the page like an anonymous one instead of getting `403 PROJECT_MEMBERSHIP_REQUIRED`. Every other path, `/approvals/act/...` and `/approvals/requests/...` included, is checked as before. + + **`@objectstack/hono`.** The `createHonoApp` catch-all (and the `/auth/*` fallback) read the JSON body with `c.req.json()`, which consumed the raw request for every `Content-Type`. A domain that forwarded the request got it empty. The body is now parsed from a clone, so `context.request` reaches the dispatcher unread, byte for byte. The parsed `body` the domains receive is unchanged for every input: JSON values as parsed, and `{}` for an empty, form, text or invalid body. The cost is that a request body is buffered twice for the life of the request. + + Nothing changes on a self-hosted server: the plugin's raw-app mount still serves the path there and never reaches the dispatcher. +- 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. +- 9822b05: fix(hono): on a `createHonoApp` host a `HEAD` is answered as its `GET` (no body), as the standalone server answers it; `HEAD /api/v1/mcp/skill` no longer answers `405` with `Allow: GET` + + Clause-②: no + + `createHonoApp`'s `${prefix}/*` catch-all handed the dispatcher the request's raw verb. Hono answers a `HEAD` from the `GET` route, but the verb still read `HEAD`, and the dispatcher's domains decide by comparing it with `GET`, so a `HEAD` was not answered as its `GET` on this host. Measured: `HEAD /api/v1/mcp/skill` answered `405 METHOD_NOT_ALLOWED` with `Allow: GET`; `/health`, `/ready` and the scoped root `/environments/:id` answered `404 ROUTE_NOT_FOUND`; signed in, `/analytics/meta` answered `404` and `/ai/agents` `501`; anonymous, the public `/share-links/:token/resolve` answered `401` where `GET` answers `501`. With an auth service, `HEAD /auth/config` answered `404` (it was forwarded to better-auth instead of answered as the `GET`). + + - **`@objectstack/hono`.** The catch-all now dispatches a `HEAD` as `GET`, and the `/auth/config` branch answers a `HEAD` as its `GET`. Every dispatcher route answers `HEAD` with its `GET` answer's status and headers, and Hono drops the body. A path that serves no `GET` keeps its `GET` answer for `HEAD` (its `405` and `Allow`, or `401` to an anonymous caller). The standalone server (`HonoServerPlugin` with the dispatcher plugin) already answered `HEAD` this way. Only the verb changes: a domain that forwards the request (the `/mcp` transport, the `/approvals/act` page) is still handed the client's `HEAD` request. + - **`@objectstack/runtime`.** The `/approvals/act` domain decides whether a request is a `HEAD` from the forwarded request's own method, not from the dispatcher's verb. So a `HEAD` still reaches the approvals member as a `GET` and is answered with no body. +- 1ef0ee8: fix(hono): on a `createHonoApp` host, `GET /api/v1/mcp/skill` answers the raw markdown with one `Content-Type: text/markdown; charset=utf-8` and `Cache-Control: no-store`, as the standalone server does + + Clause-②: no + + - **What a caller got.** The skill is served as a single string event on the dispatcher's `result` stream channel. The adapter's stream writer framed every event outside a Vercel data stream as `data: `, so the body was an SSE frame of a JSON-encoded string (`data: "---\nname: …"`), not markdown. It also set its own `Content-Type` and `Cache-Control` beside the domain's, so the answer carried `text/markdown; charset=utf-8, text/markdown; charset=utf-8` and `no-cache, no-store` (on a socket: two `Content-Type` lines and two `Cache-Control` lines). + - **What it answers now.** A string event is written as given, in every stream mode, and a header the producer sets replaces the adapter's default of the same name, whatever its case. Object events are still one `data:` frame each (one JSON line in a Vercel data stream), and a stream that sets no headers still gets `text/event-stream`, `no-cache` and `keep-alive`. + - **Unchanged:** the AI chat streams, which reach the same writer as pre-encoded Vercel data stream frames or as object parts, and every other route. +- Updated dependencies [5b14b7f] +- Updated dependencies [191e043] +- Updated dependencies [c395405] +- Updated dependencies [3b8e2b1] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [67b669e] +- Updated dependencies [45e206e] +- Updated dependencies [cd3d391] +- Updated dependencies [95a8435] +- Updated dependencies [9e9e6f9] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [240890c] +- Updated dependencies [1fb274e] +- Updated dependencies [5cfd866] +- Updated dependencies [a543e24] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3aa0c7b] +- Updated dependencies [fbcbcf1] +- Updated dependencies [f4069c5] +- Updated dependencies [a45d5d8] +- Updated dependencies [ca135dc] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [c572fe2] +- Updated dependencies [b53b949] +- Updated dependencies [faf6348] +- Updated dependencies [0fea05f] +- Updated dependencies [26aa999] +- Updated dependencies [cb3bb93] +- Updated dependencies [5081090] +- Updated dependencies [ec7c7e0] +- Updated dependencies [6d5191e] +- Updated dependencies [8558668] +- Updated dependencies [fe58bbc] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [8bc7836] +- Updated dependencies [117817e] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [0aef788] +- Updated dependencies [8c10776] +- Updated dependencies [8024e12] +- Updated dependencies [a3d47c2] +- Updated dependencies [21c8644] +- Updated dependencies [d4aaeaf] +- Updated dependencies [3c1a046] +- Updated dependencies [917b1a0] +- Updated dependencies [d9d7edd] +- Updated dependencies [d45d0e9] +- Updated dependencies [cb9841a] +- Updated dependencies [8ed3786] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [01d5034] +- Updated dependencies [f24574a] +- Updated dependencies [430e5f5] +- Updated dependencies [5d3018d] +- Updated dependencies [3d231ba] +- Updated dependencies [0f05907] +- Updated dependencies [28e294f] +- 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 [9fcea20] +- Updated dependencies [19c554d] +- Updated dependencies [5bb9a83] +- Updated dependencies [c263588] +- Updated dependencies [ee0e115] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [f7cd580] +- Updated dependencies [c9810dd] +- Updated dependencies [cd86e41] +- Updated dependencies [395ca21] +- Updated dependencies [9e0945c] +- Updated dependencies [7428da0] +- Updated dependencies [6ee483c] +- Updated dependencies [b55be42] +- Updated dependencies [f7a5ebe] +- Updated dependencies [9d4fb3c] +- Updated dependencies [c5cf546] +- Updated dependencies [34b740f] +- Updated dependencies [ae4dfaf] +- Updated dependencies [2e15620] +- Updated dependencies [0cb45e9] +- Updated dependencies [605aa01] +- Updated dependencies [9e797a8] +- Updated dependencies [75ced06] +- Updated dependencies [66d2757] +- Updated dependencies [6e43b7b] +- Updated dependencies [6dede7a] +- Updated dependencies [727091c] +- Updated dependencies [62f959d] +- Updated dependencies [f792290] +- Updated dependencies [eda6f14] +- Updated dependencies [d702b18] +- Updated dependencies [a06f0d4] +- Updated dependencies [5a9a80b] +- Updated dependencies [8eccf51] +- Updated dependencies [1bf5f6e] +- Updated dependencies [58a2faa] +- Updated dependencies [2bcd406] +- Updated dependencies [e74ec56] +- Updated dependencies [af284b0] +- Updated dependencies [cbfa5e4] +- Updated dependencies [9822b05] +- Updated dependencies [889bf12] +- Updated dependencies [7a8733f] +- Updated dependencies [1e001c7] +- Updated dependencies [d79dc48] +- Updated dependencies [51ad8e4] +- Updated dependencies [c4526a7] +- Updated dependencies [fa26308] +- Updated dependencies [6868514] +- Updated dependencies [2a1e48a] +- Updated dependencies [2ec6827] +- Updated dependencies [964a237] +- Updated dependencies [cdf3777] + - @objectstack/runtime@18.0.0 + - @objectstack/types@18.0.0 + - @objectstack/plugin-hono-server@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index 29cb260d351..a2558ed1712 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.5", + "version": "18.0.0", "license": "Apache-2.0", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/apps/account/CHANGELOG.md b/packages/apps/account/CHANGELOG.md index 7c6398bc41c..691178088b5 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,376 @@ # @objectstack/account +## 18.0.0 + +### Patch Changes + +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index 4d5ae88b03f..e435201e10d 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "ObjectStack Account — the end-user account/self-service console app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/apps/setup/CHANGELOG.md b/packages/apps/setup/CHANGELOG.md index efc4414eff8..982389a8135 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,376 @@ # @objectstack/setup +## 18.0.0 + +### Patch Changes + +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index 0782d2a60bc..e204af26154 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "ObjectStack Setup — the platform administration app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/apps/studio/CHANGELOG.md b/packages/apps/studio/CHANGELOG.md index 23e04a35eb2..8b6dc9d62b3 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,376 @@ # @objectstack/studio +## 18.0.0 + +### Patch Changes + +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index 9dfd03c4c9f..24fa8defd91 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "ObjectStack Studio — the metadata builder app, packaged as its own ObjectStack app package (ADR-0048: one app per package).", "main": "dist/index.js", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 6567e0f2a7b..adb117030e4 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,3035 @@ # @objectstack/cli +## 18.0.0 + +### Minor Changes + +- b12e16a: feat(service-settings)!: the settings audit trail's global rung moves to the new deployment-level `sys_platform_setting_audit` + + Clause-②: yes (narrowing) + + + + **BREAKING** for a reader of the settings audit trail, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D7; ruling record 6257510973, Q4 A). This is the expand half of the change: it adds the new write shape, and the 18.0 rows stay where they are and readable. No row an 18.0 install wrote is moved, deleted or re-stamped. + + - **Before.** A write of a global-scope settings key appended its history row to `sys_setting_audit`, stamped with the Default Organization under `single`. Any principal the settings read door let read the namespace could list it there. + - **Now.** That row goes to the new `sys_platform_setting_audit`. It has the same row shape, `scope` is always `global`, and there is no organization column (`systemFields: { tenant: false }`). Reading it through the generic data API requires the `manage_platform_settings` capability, as `sys_platform_setting` does, and each namespace's `readPermission` applies there too. Tenant and user rows stay in `sys_setting_audit`, stamped as before. + - **18.0 rows.** Global-rung rows an 18.0 install wrote stay in `sys_setting_audit`, stamped, until the upgrade ceremony moves them. The global rung's history is both tables: the sibling, plus `sys_setting_audit` rows at `scope = global`. + + **The remedy.** If you list or filter the global rung's settings history (`sys_setting_audit` with `scope = global`), read `sys_platform_setting_audit` as well, with a principal that holds `manage_platform_settings`. Select the 18.0 rows by `scope`, never by a missing organization: they carry the Default Organization. Nothing changes for a reader of tenant or user rows. +- 9e9e6f9: feat(cli)!: `os meta resync` is deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `os meta resync` reconciled the platform-default `sys_permission_set` rows (`admin_full_access`, `member_default`, …) to the shipped declaration. Since the authorization resolver reads permission sets from the security catalog (ADR-0131 D3), an edited default set is served from its declaration at the next boot, and the rows the command reconciled decide nothing. The command, its `--yes` / `--json` modes and its skip-count explanation are gone; a script that calls it gets oclif's unknown-command error. + + In `@objectstack/plugin-security`, `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments and one warning sentence (the permission-set backfill's re-provision list) no longer name the command. Their behaviour is unchanged. +- c52bfb4: feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less `sys_platform_setting`, and `sys_setting.scope` no longer declares `global` (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + A settings value for a key declared at `global` scope is a deployment-wide value: the mail transport, the SMS, storage, AI and knowledge providers, auth policy, the lifecycle retention defaults. It used to be a `scope: 'global'` row of `sys_setting`, a tenant-scoped table, where the injected `organization_id` column only ever held NULL and a walled posture hid the row from every reader. ADR-0131 D7 moves the rung out: it is now stored in a new platform object, `sys_platform_setting`. + + - **`sys_platform_setting`** (registered by the settings service, beside `sys_setting`): one row per `(namespace, key)` for the deployment, with the `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns of a settings row, and no `scope`, `user_id` or `organization_id` (`systemFields: { tenant: false }`). It is governed by object permission: a generic data read needs `manage_platform_settings` (`requiredPermissions`), which platform administrators hold. Writes go only through the settings door. + - **`SettingsService`** writes a global-scope key to `sys_platform_setting` and reads the cascade's global rung from there alone. Its `sys_setting` reads exclude `scope = 'global'`, so there is one source per rung. The cascade order, the lock semantics, `SpecifierScope` and the `source: 'global'` resolution value are unchanged. A global-scope change's `config_change` audit row names `sys_platform_setting` (`CONFIG_CHANGE_GLOBAL_OBJECT_NAME`); tenant- and user-scope changes still name `sys_setting`. + - **`sys_setting.scope`** no longer declares the `global` option: no write produces such a row. `sys_setting_audit.scope` keeps it, because a global-scope change is still audited there. + - **`os secret orphans` / `os secret rewrap`**: the settings family of the `sys_secret` reference union now reads both `sys_setting.value_enc` and `sys_platform_setting.value_enc`. Without that, every credential held at the global rung would read as unreferenced and be swept. An unreadable `sys_platform_setting` gaps the family, which refuses deletion. + + **What moves for consumers.** + + - **Existing databases — nothing moves automatically** (ADR-0131 D14). A `sys_setting` row at `scope = 'global'` is no longer read; until the v18 upgrade ceremony moves it, that key answers from its next rung or the manifest default. The ceremony moves each such row to `sys_platform_setting` by namespace and key, its `value_enc` handle included. An encrypted value needs no re-encryption: the ciphertext's associated data binds the settings scope, namespace and key, never the holding object or an organization. + - **Authored references.** A filter, list-view column or seed that names `scope = 'global'` on `sys_setting` matches nothing: point it at `sys_platform_setting`, which has no `scope` column. + - **Generic data reads of the global values.** Read `sys_platform_setting`; it needs `manage_platform_settings`. The settings door (`/api/settings/:namespace`) is unchanged and keeps applying each manifest's own read and write capability. + - **Kernels that register the settings objects by hand.** Register `SysPlatformSetting` (`@objectstack/platform-objects/system`) beside `SysSetting`; `SettingsServicePlugin` already does. The service reads both on every resolution, so a kernel missing one fails the read loudly rather than answering a cascade with a rung missing. +- 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 + ``` +- ae6bd1a: feat(cli)!: 18.0 supports fresh `single` installs only — `os migrate organization-ownership` refuses, and `os serve` refuses a walled posture at boot + + Clause-②: yes (narrowing) + + + + **BREAKING** (a command and a boot posture that used to run now refuse), shipped as `minor` under the launch-window convention for breaking changes. 18.0 supports fresh installs under the `single` tenancy posture only; upgrading a v17 database and the walled postures arrive in an 18.x release. + + - **FROM** `os migrate organization-ownership` (plan) and `os migrate organization-ownership --apply …` running the v18 ownership ceremony **TO** both refusing with exit `1` and the standard code `NOT_IMPLEMENTED` (under `--json`: `{ error: 'plan_refused' | 'apply_refused', reason: 'upgrade-unavailable', code: 'NOT_IMPLEMENTED', detail }`), reading and writing nothing. The 18.0 ceremony is knowingly incomplete, and an incomplete ceremony must never certify an old database. **Fix:** keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path. `os dev`'s in-place upgrade of a v17 development database runs this command, so it now stops with the same refusal and changes nothing. + - **FROM** `os serve` (and so `os dev` and `os start`) booting with `OS_TENANCY_POSTURE=group` or `isolated`, or with `OS_MULTI_ORG_ENABLED=true`, **TO** a FATAL refusal at the tenancy-posture gate, exit `1`, naming `NOT_IMPLEMENTED` and the 18.x release, before a config is read. `OS_ALLOW_DEGRADED_TENANCY` does not lift it, and no flag or variable does. **Fix:** set `OS_TENANCY_POSTURE=single` (or unset it and `OS_MULTI_ORG_ENABLED`), or keep a walled deployment on a 17.x release. + + **What stays as it was.** A fresh install under `single` boots exactly as before. The boot guard that refuses a v17-shaped database is unchanged. Kernel-level boots (`@objectstack/verify`'s `bootStack`, embedded hosts) still accept every posture. +- 78652c0: feat(cli,plugin-sharing)!: in the 17 → 18 ownership ceremony, a row whose owner cannot be derived blocks certification, and a declared-rule mirror's grants are renamed only onto a rule plugin-sharing serves + + Clause-②: yes (narrowing) + + + + **BREAKING** (narrowings), shipped as `minor` under the launch-window convention for breaking changes. + + - **FROM** (`@objectstack/cli`): `os migrate organization-ownership --apply` on a database holding a row whose owner cannot be derived exited `0` with `blocking: 0`, counted the row in `advisory`, and verified the `sys_migration` marker. This covers a reported (fate 4) row, and a row whose anchor is missing under a wall (fate 3). **TO:** it exits `1`, counts the row in `blocking`, and leaves the marker unverified, so a v18 `os serve` / `os start` does not start on the database. **Fix:** give each row its organization (write its `organization_id`, or repair the anchor its reason names), or delete it by hand, then re-run `--apply` with the same plan file. + - **FROM** (`@objectstack/cli`): a database an earlier version of this command certified while it held such a row was certified again by the next `--apply`. **TO:** that run leaves its marker unverified, for the same rows. **Fix:** the same as above. + - **FROM** (`@objectstack/cli`): `os dev`'s in-place upgrade of a development database started the server again whenever every ceremony step finished. **TO:** it starts it only when the post-check also holds nothing (`blocking` 0); otherwise it prints each table that holds, with its count and remedy, and the command that resumes the run. **Fix:** resolve the rows it names, then run the printed command. + - **FROM** (`@objectstack/cli`): the `mirror-deletion` step renamed the grants of a declared-rule mirror onto its rule's name, and deleted the mirror, even when plugin-sharing does not serve that rule (a condition that does not translate, an unmappable recipient, a retired owner-based rule); the next boot's orphaned-grant sweep then revoked those grants. **TO:** while `sys_record_share` grants name such a mirror, the step blocks, naming the rule, its rows and the reason, and writes nothing. **Fix:** correct the rule's declaration in its package so it is served, or delete each mirror row and the grants that name it, then re-run `--apply`. + + `@objectstack/cli` — **unattributable rows hold certification (ADR-0131 D10 fate 4).** The ceremony still never guesses an owner for such a row and never deletes it. The post-check now holds each one against the verdict: + + - Each table that holds one carries `held: { rows, remedy }` in the apply report, and counts those rows in its `remaining`. The terminal output names each table, its row count and the remedy. + - `advisory` now counts only the rows of an advisory departure and the reported rows of a table whose `organization_id` is not the tenancy anchor (`sys_sso_provider`, the retired `sys_scim_provider`). A row there that is bound to no organization is legitimate, is reported, and does not block. + - So a verified marker means no organization-less row on any table that the tenancy anchor governs. + - The plan's summary line says that such rows will hold `--apply`'s certification until they are resolved. + + `@objectstack/cli` — **the ceremony asks plugin-sharing which declared sharing rules it serves.** The port the `mirror-deletion` step reads (`DeclaredSharingRulesPort.rules()`, formerly `names()`) judges each declared rule through plugin-sharing's own translation. A mirror's grants are renamed only onto a rule that is served. A mirror of a refused rule that no grant names is still deleted. + + `@objectstack/plugin-sharing` — **exports `translateDeclaredSharingRule`** with its `DeclaredSharingRule` and `DeclaredSharingRuleSkip` types. It is the one translation enforcement serves a declared rule through. It is pure, and it is unchanged. +- c7d6037: A package declares how it may be installed — `installModes: ['managed', 'unmanaged']` — and `os package install --install-mode unmanaged` copies it once into the environment as the environment's own metadata (ADR-0131 D6) + + Clause-②: yes (widening: the new optional manifest key `installModes`; the new exports `INSTALL_MODES`, `InstallModeSchema`, `InstallMode`, `DEFAULT_INSTALL_MODES`, `resolveInstallModes`, `UNMANAGED_INSTALL_KEY_DISPOSITIONS`, `UNMANAGED_INSTALL_REFUSING_DISPOSITIONS`, `UnmanagedInstallKeyDisposition`, `unmanagedInstallValueCarries` and `unmanagedInstallPostureRefusal`; the install-local body field `installMode`; the CLI option `--install-mode`; the error codes `STACK_UNMANAGED_INSTALL_UNCOPYABLE` and `INSTALL_MODE_NOT_PERMITTED`) + + A package installs one of two ways. **Managed**: it is registered as code, stays the authority, upgrades, and nothing it ships is edited in place. **Unmanaged**: its metadata is copied once into this environment under a new writable base; the copy's items are this environment's own metadata, the next save edits them, and it follows no upgrade. This entry adds the manifest key that declares the modes a package permits, and the install door that honours it; the copy itself is the companion entry `15213-unmanaged-import-verb`. + + - **`installModes`** (`@objectstack/spec`, `ManifestSchema`). Optional, a non-empty list of `'managed'` / `'unmanaged'`. Absent means `['managed']`, read through one resolver, `resolveInstallModes(manifest)`; there is no parse-time default, so no existing manifest changes by a byte. A package may permit `unmanaged` alone. + - **Refused at authoring.** `defineStack` — so `os validate` and `os build` — refuses a package that permits `unmanaged` while it carries what the copy cannot write as environment metadata: a non-empty collection of a type the metadata-type registry declares runtime-uncreatable (`agents`, `apis`, `capabilities`, `jobs`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), or an extension of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`). The refusal is `STACK_UNMANAGED_INSTALL_UNCOPYABLE` (422), one `issues` entry per key. Fix: remove `'unmanaged'` from `manifest.installModes`, or move what cannot be copied out of the package. The install door judges the same body with the same key classification (`UNMANAGED_INSTALL_KEY_DISPOSITIONS`), so a package that passes `os validate` is not refused at install for a key nobody named. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local`). The body takes `installMode: 'managed' | 'unmanaged'`; absent means `managed`, so every existing caller is unchanged. Before anything is written, on the catalog and the air-gapped source alike: + - a mode the package's `installModes` does not permit answers `422 INSTALL_MODE_NOT_PERMITTED`, with `details.permitted`; + - `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value) answers `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture; + - a managed install of a package whose namespace a writable base holds — its unmanaged copy — answers `409 RESOURCE_CONFLICT`, naming that base: a managed install would otherwise take the namespace and stop serving the edited copies. + An unmanaged install writes no install-ledger entry, registers nothing under the package's id and does not load its sample data (the answer says so under `seeded`). The answer names the new base, the items copied and `installedFrom` (the source package id and version, for information only). The package's translation bundles are copied as `translation` items, so they reload at every boot with the environment's own translations. + - **`os package install --install-mode managed|unmanaged`** (`@objectstack/cli`). Without the option, nothing is sent and the runtime installs managed. An unmanaged answer prints the base, the item count and the source. A runtime that predates the install mode ignores the option and installs managed; the command then exits 1 and says so. The command's help now calls the catalog and the air-gapped artifact the two *sources*, so "mode" means one thing. + - **Copy semantics** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` copies `translations` (each locale of each bundle as one `translation` item named `_`, bundles merged in order), reads `installModes` without writing it onto the base, and no longer refuses an empty code container (`functions: {}`, `plugins: []`). `unmanagedInstallPostureRefusal` is exported, so the door and the import answer the walled posture with one sentence. + + Nothing existing is removed: every new key, field and option is optional, and an install that does not name a mode is managed, as before, with one stricter answer. That answer is the refusal above: a managed install of a package whose namespace a writable base holds now answers `409 RESOURCE_CONFLICT` before anything is written, where a catalog install of it answered `200`. +- 2d21b9c: An unmanaged install no longer copies a package's protection lock, in either spelling: every copied item is editable, and the install's answer names each lock it dropped with the package's reason (ADR-0131 D6, ADR-0010) + + Clause-②: yes (widening: the new response field `strippedLocks` on `ImportUnmanagedPackageResponseSchema`, relayed on the install-local answer for `installMode: 'unmanaged'`) + + An unmanaged install copies a package into this environment as the environment's own metadata. A package can lock an item in two spellings: the `_lock` envelope (`_lock`, with `_lockReason`, `_lockSource` and `_lockDocsUrl`), or the author-facing `protection: { lock, reason, docsUrl }` block that a managed install translates into it. Until now an item locked with `_lock` was copied with that lock, so the next save of the copy answered `403 ITEM_LOCKED`; an item locked with `protection` was copied with an inert block that bound nothing, so the vendor's lock vanished without a word. The package permitted the unmanaged install through its `installModes`, so it chose to let environments take editable copies; a lock whose source is the package does not survive the copy. + + - **The copy** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` writes every item without `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl`, and, on a type whose schema declares one, without its `protection` block. Its answer gains `strippedLocks`: one entry per lock that bound (`no-overlay`, `no-delete` or `full`), in the order the items were written — `{ type, name, lock, reason? }`, with `reason` the package's reason verbatim and absent when it gave none. Each entry is the lock a managed install of the same item would have bound: an item carrying both spellings is reported once, with the `protection` block's lock and reason, because that is the one a managed install binds. A package that locks nothing answers `strippedLocks: []`. + - **Refusals are unchanged.** A lock the spec's declarations refuse (an unknown `_lock` value, a reason that is not a string, a malformed `protection` block) is not stripped: the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back. So is a `protection` block on a type whose schema does not declare one (`action`, `hook`, `page`). A managed install is unchanged: the package stays the authority, and its locks bind. + - **The declaration** (`@objectstack/spec`). `ImportUnmanagedPackageResponseSchema` declares `strippedLocks`; `lock` takes the three binding values of `MetadataLockSchema`. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local` with `installMode: 'unmanaged'`). The answer carries the import's `strippedLocks` as the import states it. + - **`os package install --install-mode unmanaged`** (`@objectstack/cli`). Each dropped lock prints as one line under `Locks removed`, spelled `.: — `, so the installer reads why the vendor locked an item before editing it. An answer from a runtime that states no list prints no lock line. + + Nothing is removed or narrowed: the answer gains a field, and every refusal of the unmanaged install is unchanged. +- 54f062b: feat(verify): the dashboard date-window family — every widget a dashboard's `dateRange` binds counts, under each preset of the picker, the rows that window selects, runnable as `os verify --dashboard-windows` (#15953) + + Clause-②: yes + + A seventh derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs`, `runDeleteBehaviorProofs`, `runUniqueScopeProofs` and `runParentWriteReachProofs`. It needs no hand-written test: from the app's own dashboards and datasets, read through the spec's own parse (`DashboardSchema`, `DatasetSchema`), it takes every dashboard that declares a `dateRange` and every widget that window binds, and asks the dataset door what each one counts. + + Where each half of the promise is read from: + + - the field a widget's window lands on is `DashboardWidgetSchema.filterBindings`' contract: `filterBindings.dateRange` names it, `false` opts the widget out (reported `unbound`), and absent means the dashboard's `dateRange.field` (`created_at` when unauthored). It resolves against the object behind the widget's dataset, as the stack registers it, and must be a `date` or `datetime` field; + - a preset's window is `DATE_RANGE_PRESET_MACRO_WINDOWS`, the `{date-macro}` pair each of the 13 `DATE_RANGE_PRESETS` names. It is handed to `POST /analytics/dataset/query` as written (`{ $gte: '{7_days_ago}', $lte: '{today}' }`, `{ $gte: '{month_start}', $lte: '{month_end}' }`), ANDed with the widget's own filter as `runtimeFilter`, and the server resolves the tokens; + - the days a window selects, for the ground truth, are that same window resolved by `@objectstack/core`'s `resolveFilterTokens` on the caller's reference timezone (UTC unless the `ExecutionContext` carries one), first and last day inclusive. + + Per bound widget and count measure, fixture rows are written through the seed door on every preset's first and last day and the day either side (never after today), each stamped at UTC noon (a `date` field takes the day itself), owned by the caller, with every column the widget's filter, the dataset's filter and the measure's filter compare held to a value they select, and read back before anything is judged. The widget is asked as the signed-in caller with no window and under every preset, before and after the fixtures, so each count is a difference and the app's own rows cancel out. With no window it must count every fixture row before any window is judged. Under each preset it must count exactly the fixture rows that window selects: a widget that shows nothing there is `blank`, one that counts another number is contradicted, and one whose every preset answers what no window answers is named inert. + + A widget is `proven`, `contradicted` (a preset went blank or counted other rows), `failed` (the door refused or faulted on a windowed query while the widget's own query answered), `inconclusive` (a fixture could not be written or did not read back as written, the widget with no window did not count every fixture row, its own query is refused, the day turned mid-probe, or the caller's timezone is not UTC), `unbound` or `excluded` (`excludedKind` names why: no count measure, a filter the ground truth cannot model, a window on a missing or non-temporal field, an undeclared or unparsed dataset or dashboard, an object the app does not declare or the CRUD derivation cannot synthesize). `contradicted + failed` widgets are the report's `hardFailures`. An average, a sum or a derived measure is counted as excluded; a held column is an equality, an `$in`, a `$nin` or `$ne` on a field that declares its options, or `{current_user_id}`. + + **`@objectstack/verify` — new exports.** + + - `runDashboardWindowProofs(stack, token, config, { now? })` → `DashboardWindowReport`. Run it on a fresh stack of its own, as a principal that reads the dashboards' objects: it writes fixture rows. + - `deriveDashboardWindowCases(config)`, `formatDashboardWindowReport(report)`. + - Types: `DashboardWindowExclusion`, `DashboardWindowMeasureCase`, `DashboardWindowCase`, `DashboardWindowDashboard`, `DashboardWindowPreset`, `DashboardWindowAnswer`, `DashboardWindowPresetProbe`, `DashboardWindowMeasureResult`, `DashboardWindowWidgetStatus`, `DashboardWindowWidgetResult`, `DashboardWindowReport`, `DashboardWindowProofOptions`. + + ```ts + import { bootStack, runDashboardWindowProofs, formatDashboardWindowReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp); + const report = await runDashboardWindowProofs(stack, await stack.signIn(), myApp); + console.log(formatDashboardWindowReport(report)); // report.presets names what each preset handed the door + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --dashboard-windows`.** Opt-in, like `--unique-scopes` and `--parent-write-reach`, on a fresh boot of its own, as the signed-in admin. A widget that went blank or counted other rows than its window selects, or a windowed query the door refused or faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `dashboardWindows` when the flag is given; without the flag the run and its document are unchanged. +- 9f4b652: feat(verify): the delete-behaviour family — every `deleteBehavior` a reference field declares is what a delete of the referenced record does, runnable as `os verify --delete-behaviors` (#15953) + + Clause-②: yes + + A fourth derived proof family beside `runCrudVerification`, `runRlsProofs` and `runReferenceShapeProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`) and reads what the declaration states through the spec's own parse of the field (`FieldSchema`), which is the value a built artifact carries: a `lookup` or `tree` that authors nothing states `set_null`, an authored value is kept, and a bare `master_detail` states none. For each field it creates a fresh target record and a holder referencing it, deletes the target by id, and reads back what the engine left, through two doors: the REST data route as the signed-in caller, and the engine's own delete as the system principal. + + - `cascade` holds when the holder is gone with the target. + - `set_null` holds when the holder survives and no longer references the deleted record. + - `restrict` holds when the delete is refused with `DELETE_RESTRICTED` naming the holder's object, and both records are still there. + - In every case a second holder, referencing another record of the same target object, must be untouched. + + A refusal for any other reason (a hook's veto, a validation rule, a permission) is `unattributed`, so an engine that refused every delete would not read as a proven `restrict`. A field is `proven`, `contradicted` (a delete did something else), `failed` (a door faulted), `inconclusive` (an unattributed refusal, or a fixture the app refused; the report says why), `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. + + Two shapes are `excluded` rather than given an expectation, because an engine rule decides what a delete does to them, not the declaration: a `set_null` on a required reference (it cannot be cleared), and a field whose parse carries no `deleteBehavior` (a bare `master_detail`, a `user`). A field is `not-derivable` when no fixture can be built from the app's metadata: its target is not an object the app declares (such as `sys_user`), it is `readonly` or `system`, or the CRUD derivation cannot synthesize its holder or target. Only by-id deletes are asked; a predicate or multi-row delete is outside the family. + + **`@objectstack/verify` — new exports.** + + - `runDeleteBehaviorProofs(stack, token, config, { doors? })` → `DeleteBehaviorReport`. Run it on a fresh stack of its own: every `restrict` probe leaves its records behind. + - `deriveDeleteBehaviorCases(config)`, `formatDeleteBehaviorReport(report)`. + - Types: `DeclaredDeleteBehavior`, `DeleteBehaviorCase`, `DeleteBehaviorDoor`, `DeleteBehaviorAnswer`, `DeleteBehaviorProbeResult`, `DeleteBehaviorFieldStatus`, `DeleteBehaviorFieldResult`, `DeleteBehaviorReport`, `DeleteBehaviorProofOptions`. + + ```ts + import { bootStack, runDeleteBehaviorProofs, formatDeleteBehaviorReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp); + const report = await runDeleteBehaviorProofs(stack, await stack.signIn(), myApp); + console.log(formatDeleteBehaviorReport(report)); + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --delete-behaviors`.** Opt-in, like `--reference-shapes`, on a fresh boot of its own. A contradicted declaration, or a door that faulted, counts in `hardFailures` and fails the run. A `tree` field's delete is not honoured today (deleting the parent leaves the child's reference in place), so an app that declares one fails the run with the flag. The `--json` document carries the report as `deleteBehaviors` when the flag is given; without the flag the run and its document are unchanged. +- 965a1bf: feat(verify): the parent-derived write reach family — a write on a `controlled_by_parent` detail is admitted exactly when the caller holds that verb on the detail and can edit its master, runnable as `os verify --parent-write-reach` (#15953) + + Clause-②: yes + + A sixth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs`, `runDeleteBehaviorProofs` and `runUniqueScopeProofs`. It needs no hand-written test: from the app's own objects it takes every object the spec's own parse (`ObjectSchema`) declares `sharingModel: 'controlled_by_parent'` with exactly one `master_detail` field, walks the chain of masters above it to the first one that governs its own rows, and asks the engine what a detail write meets as a signed-in caller: the insert, update and delete by id the REST data route makes, with the caller's context, where a refusal keeps the object and the operation it names (the REST envelope names the route's object whatever was refused). The system door bypasses authorization and is not asked. + + The promise is ADR-0055 §3 and its chain amendment: editing, deleting or creating a detail requires edit access to its master, and the detail's own object permission is the first door. So each write is expected admitted exactly when both halves hold: + + - the caller holds the verb on the detail's object — read from the server's effective permissions for the caller (`GET /auth/me/permissions`), folded by `objectPermissionGrants`; + - the caller can edit the master — measured, never re-derived: an empty update of the master as the caller, which runs every write gate the master has, the gate above a master that is itself controlled by its parent included. + + The writes are made by three verifier personas whose permission sets the family authors from the app's own objects — the root master by the all-records grant (`modifyAllRecords`) with every verb on the detail; the root master readable but not editable with every verb on the detail; the all-records grant with no delete on the detail — and by one persona per position the app declares, whose capability is what the app binds to the position. Each writes under a master chain another principal owns (so the all-records grant, not ownership, is exercised) and one it owns, on a detail another principal owns and one it owns. A refusal holds only as the platform's `403 PERMISSION_DENIED` naming the detail and the operation; any other refusal (a rule, a hook's own code, a refusal on another object) is `unattributed`. An object is `proven`, `contradicted` (a write met the other answer), `failed` (a door faulted), `inconclusive`, `excluded` (no one master: none, two, an undeclared one, a cycle, or a chain at the write gate's depth bound) or `not-derivable`. `contradicted + failed` objects, plus personas that could not be provisioned, are the report's `hardFailures`. + + A detail two levels below its root master (a line under a quote under an account) is answered the same way: its write is admitted exactly when the caller holds the verb on the detail and may update the middle master directly, which also needs edit access to the root, and refused otherwise, so the family reports such a chain `proven`. + + **`@objectstack/verify` — new exports.** + + - `runParentWriteReachProofs(stack, config)` → `ParentWriteReachReport`. Run it on a fresh stack of its own, booted with `parentWriteReachSecurity(config)`: it signs up personas and writes, deletes and creates details. On a stack booted without it the verifier personas are reported not run, with the reason, and counted. + - `parentWriteReachSecurity(config)` → the `SecurityPlugin` that registers the verifier personas' permission sets beside the app's declared default profile. + - `deriveParentWriteReachCases(config)`, `formatParentWriteReachReport(report)`. + - Types: `ParentWriteReachHop`, `ParentWriteReachCase`, `ParentWriteReachVerb`, `ParentWriteReachOwner`, `ParentWriteReachAnswer`, `ParentWriteReachProbeResult`, `ParentWriteReachReach`, `ParentWriteReachObjectStatus`, `ParentWriteReachObjectResult`, `ParentWriteReachPersona`, `ParentWriteReachReport`. + + ```ts + import { bootStack, runParentWriteReachProofs, formatParentWriteReachReport, parentWriteReachSecurity } from '@objectstack/verify'; + + const stack = await bootStack(myApp, { security: parentWriteReachSecurity(myApp) }); + const report = await runParentWriteReachProofs(stack, myApp); + console.log(formatParentWriteReachReport(report)); // report.personas names who wrote + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --parent-write-reach`.** Opt-in, like `--delete-behaviors` and `--unique-scopes`, on a fresh boot of its own with the verifier personas' sets registered. A write whose answer disagrees, a door that faulted, or a persona that could not be provisioned counts in `hardFailures` and fails the run. The `--json` document carries the report as `parentWriteReach` when the flag is given; without the flag the run and its document are unchanged. + + **The unique-scope family — one change and one correction.** `runUniqueScopeProofs` is new in this same release, and two sentences of its own entry in these notes no longer hold: + + - **Attribution changed.** A unique refusal that names no field is now `unattributed` where a unique `indexes[]` entry covers the probed column. That entry refuses the same second holder, naming no field either, and admits the twin, so there the refusal may be the entry's rather than the field's own declaration. This is a change to the family's attribution rule: a field-less refusal is attributed to the field only when its twin with only the probed value changed is admitted AND no unique `indexes[]` entry covers the field. + - **How the second organization is provisioned, corrected.** The family provisions it by `signUp`, then `POST /auth/organization/create`, not by a sign-up that creates its own organization as that entry's parenthetical says. +- eab7236: feat(verify): the reference-value shape family — every client-writable reference field refuses a value that is not a record id (ADR-0104 D1), runnable as `os verify --reference-shapes` (#15953) + + Clause-②: yes + + A third derived proof family beside `runCrudVerification` and `runRlsProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`), reads what the declaration promises from `valueSchemaFor(field, 'stored')` (a record-id string, or an array of them when the field is multi-valued), and asks the engine whether three wrong-shaped values are refused: a boolean, a number, and an embedded record object (the expanded read shape written back). A multi-valued field gets each one as an array element. Every probe is written on insert and on update, through two doors: the REST data routes as the signed-in caller, and the engine's own write as the system principal. + + A probe counts as refused only when the engine names the probed field in its refusal. A refusal that names something else is reported `unattributed`, so an engine that refused every write would not read as proven. A field is `proven` (every probe refused), `holes` (a probe was admitted), `failed` (a door faulted), `inconclusive` (a probe could not be attributed or written; the report says why) or `excluded` (declared `readonly` or `system`, which the caller does not write). `holes + failed` is the report's `hardFailures`. + + **`@objectstack/verify` — new exports.** + + - `runReferenceShapeProofs(stack, token, config, { doors? })` → `ReferenceShapeReport`. Run it on a fresh stack of its own: a door that admits a probe lands a row. + - `deriveReferenceShapeCases(config)`, `referenceShapeProbeValue(kind, multiple, id)`, `formatReferenceShapeReport(report)`, `REFERENCE_SHAPE_PROBE_ID`. + - Types: `ReferenceShapeCase`, `ReferenceShapeDoor`, `ReferenceShapeOperation`, `ReferenceShapeProbeKind`, `ReferenceShapeProbeResult`, `ReferenceShapeFieldStatus`, `ReferenceShapeFieldResult`, `ReferenceShapeReport`, `ReferenceShapeProofOptions`. + + ```ts + import { bootStack, runReferenceShapeProofs, formatReferenceShapeReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp); + const report = await runReferenceShapeProofs(stack, await stack.signIn(), myApp); + console.log(formatReferenceShapeReport(report)); + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --reference-shapes`.** Opt-in, like `--rls`, on a fresh boot of its own. It is opt-in because the engine still admits wrong-shaped elements on a `multiple: true` `lookup` or `user` field (#22908), so an app that declares one would fail the run until the engine refuses them. A field with holes, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `referenceShapes` when the flag is given; without the flag the run and its document are unchanged. +- f93bf12: feat(verify): the unique-scope family — every field declared `unique` collides as its declared scope says, runnable as `os verify --unique-scopes` (#15953) + + Clause-②: yes + + A fifth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs` and `runDeleteBehaviorProofs`. It needs no hand-written test: from the app's own objects it takes every field whose `unique` the spec's own parse of the field (`FieldSchema`) declares — the value a built artifact carries, so an `autonumber` that authors no `unique` states `'organization'` — and asks the engine what a second holder of one value meets, through two doors: the REST data route as a signed-in caller, and the engine's own write as the system principal. + + - **Within one organization** — by insert and by update — the second holder must be refused, whatever the scope. On a `single`-posture boot that organization is the install's one: the untenanted install, where every write lands in it. + - **Across two organizations** — on an org-scoped boot only — the family provisions a second organization the way a user makes one (a sign-up that creates its own) and offers it the value. A `true` or `'organization'` field must admit it; a `'global'` field, or any field of an object with no organization column (`tenancy.enabled: false`), must refuse it. On a boot with one organization this leg is reported not run, with the reason, on the report's `boot`. + + A refusal proves the collision only when it is the platform's unique refusal (`UNIQUE_VIOLATION` at the REST door, `DUPLICATE_RECORD` at the system door) and it is this field's: it names the field, or it names none and the same record with only the probed value changed is admitted. A per-organization constraint is an expression index, which the driver reports by index name, so its refusal carries no `field`, and that twin is what attributes it. Any other refusal (an app hook's own dedupe, a validation rule, a permission, a constraint on another field) is `unattributed`, so an engine that refused every write would not read as a proven scope. A field is `proven`, `contradicted` (a second holder met something other than its scope), `failed` (a door faulted), `inconclusive`, `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. + + A field the caller cannot set (`readonly`, `system`, a runtime-issued `autonumber`) is probed through the system door only, which keeps an explicit value. A derived value (`formula`, `summary`) is `excluded`; a field with no fresh values to offer (a `boolean`, a `select`, a multi-valued reference, a target the app does not declare) is `not-derivable`. The population is the field-level declaration: a unique `indexes[]` entry is a different surface, with its own meaning of bare `true`, and is not in it. + + **`@objectstack/verify` — new exports.** + + - `runUniqueScopeProofs(stack, token, config, { doors? })` → `UniqueScopeReport`. Run it on a fresh stack of its own: it writes duplicates on purpose. Boot the stack with `{ multiTenant: true }` to run the cross-organization leg. + - `deriveUniqueScopeCases(config)`, `formatUniqueScopeReport(report)`. + - Types: `DeclaredUniqueScope`, `UniqueScopeCase`, `UniqueScopeDoor`, `UniqueScopeLeg`, `UniqueScopeOperation`, `UniqueScopeAnswer`, `UniqueScopeProbeResult`, `UniqueScopeFieldStatus`, `UniqueScopeFieldResult`, `UniqueScopeReport`, `UniqueScopeProofOptions`. + + ```ts + import { bootStack, runUniqueScopeProofs, formatUniqueScopeReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp, { multiTenant: true }); + const report = await runUniqueScopeProofs(stack, await stack.signIn(), myApp); + console.log(formatUniqueScopeReport(report)); // report.boot names the legs this boot ran + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --unique-scopes`.** Opt-in, like `--reference-shapes` and `--delete-behaviors`, on a fresh boot of its own; with `--multi-tenant` it runs both legs. A second holder met wrongly, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `uniqueScopes` when the flag is given; without the flag the run and its document are unchanged. +- cdeabec: `os migrate meta` stops listing semantic notices whose surface the stack provably does not declare. It counts them instead, and `--all` lists them in full. + + Clause-②: yes + + - **`SemanticMigration.relevantWhen`** (`@objectstack/spec/migrations`) is a new optional field. It holds a structured question over the loaded stack, `{ kind: 'stack-declares', keys: [...] }`: does the stack declare anything under one of these top-level keys? A key's value in a `packages[].manifest` body counts the same as a top-level value. The question is closed and named. It is never free text, and it never matches against the prose of `surface`. The new types `SemanticRelevance`, `StackDeclaresRelevance` and `SemanticRelevanceKey` are exported beside `SemanticMigration`. + - **`applyMetaMigrations`** asks each entry's question of the stack it is given and of every hop checkpoint. + - **`todos` is unchanged.** `MigrationChainResult.todos` and `MigrationHopResult.todos` still hold every semantic entry of every hop crossed, whatever the stack holds, as before. + - **`absentTodos` is a new required member** of `MigrationChainResult` and `MigrationHopResult`. It names the subset of `todos` whose question answered `absent` in all of them: the same objects, in chain order. Code that only reads a chain result needs no change. Code that builds one of these two interfaces itself must now supply `absentTodos` (an empty array when nothing is proven absent). + - **Only a positive proof names an entry.** These cases answer `unknown` and leave it off `absentTodos`: + - a value the question cannot read (a function, a promise, a scalar, a getter that throws); + - a stack that is not a plain object; + - any `plugins`, `devPlugins` or `tiers` entry, since a plugin, or the platform plugins a tier preset loads, can register metadata the stack does not show. + + An entry that judges a conversion which applied an edit in the same run is never named either. + - **The first batch is 25 entries** (5 from protocol 17, 20 from protocol 18). Each one's surface lives only under named top-level stack keys: `analyticsCubes`, `apis`, `jobs`, `mappings`, `hooks`, `agents`, `tools`, `dashboards` (with `reports` and `pages` for the chart-config entry), `datasets`, `permissions` and `sharingRules`. None of them names a code door. Each entry was also checked to confirm that its acceptance criteria send the author to no stored row and no runtime door. Every other entry is never named absent, so it is listed exactly as before. + - **`os migrate meta`** lists `todos` minus `absentTodos`. After the listed notices it prints one line that counts the entries proven absent and names `--all`. A second line says that the proof covers the stack this run loaded, and not metadata a deployment stores. + - `--all` prints each of those entries in full, with the keys it was proven absent under. + - `--json` keeps `todos` whole and adds `absentTodos`, plus `hops[].absentTodos` with `--step`. + - `--step` reports each hop's listed count, and adds a `not listed` count to the hop line when that count is not zero. + - A run whose only notices are proven absent still writes `--out`. +- dd39171: The path an author writes as a public form's `sharing.publicLink` now answers: `GET /forms/SLUG` redirects to the Console's public form page, `/_console/f/SLUG`, when the anonymous form door serves that slug + + Clause-②: yes (widening) + + - **What was wrong.** An app declares a public form as `sharing: { enabled: true, allowAnonymous: true, publicLink: '/forms/contact-us' }`, the spelling the platform's own examples use. The Console serves that form to an anonymous visitor at `/_console/f/contact-us`, but the path as written answered `404 ENDPOINT_NOT_FOUND`, so the link an author put on a website reached nothing. + - **What it does now.** Wherever the Console is mounted (`os serve` / `os dev` with the Console, or any host that mounts `createConsoleStaticPlugin` from `@objectstack/cli/console`), `GET /forms/SLUG` answers `302` with `Location: /_console/f/SLUG`, followed by the request's query string. It does so only when the anonymous form door, `GET /api/v1/forms/SLUG`, serves the form to the same request: the form's `sharing` has `enabled` and `allowAnonymous` set and names the slug, no other metadata layer withdraws it, and the deployment's tenancy posture lets it take an anonymous submission. The redirect asks that door in-process with the visitor's own request, so it decides nothing the door does not, and it reveals nothing the door does not. + - **Unchanged.** Every other request under `/forms/` answers exactly as before: a disabled form, a form not open to anonymous visitors, an unknown slug, a form the posture withholds, any method other than `GET` / `HEAD`, a trailing slash or a deeper path all get the same `404 ENDPOINT_NOT_FOUND`. Nothing is mounted at the root for a bare slug. The signed-in Console route `/_console/forms/NAME` is untouched. A deployment without the Console (`--no-ui`, `--no-console`, `OS_DISABLE_CONSOLE=1`, or no built Console) mounts no redirect. + - **Status and target.** The redirect is a `302`, never a permanent one, because whether a form is served can change with its sharing. The `Location` path is built from the Console path and the slug, encoded as one path segment, so no request can point it anywhere else. The request's query string is carried verbatim after that path, because the public form page seeds its fields from `?prefill_FIELD=` parameters: `/forms/contact-us?prefill_source=website` lands on `/_console/f/contact-us?prefill_source=website`. A query string can change only the query of the page the visitor lands on, never its origin or path. + - **Nothing to migrate.** No key, export or signature changes. `sharing.publicLink` keeps its meaning and its accepted spellings (`/forms/x`, `forms/x` and `x` name one slug). +- 54d32e5: feat(lint, cli): one-line author-time rule verdicts, and `os explain ` for the reasoning + + Clause-②: yes (widening) + + - **Shorter warnings.** `field-no-consumers` and `security-owd-unset` now print one verdict sentence and one fix. `os validate`, `os build` and `os dev` used to print `field-no-consumers` as a single line of about 860 characters, and `os build` added a second paragraph of about 700; the warning now reads: + + ```text + ⚠ object "my_app_ticket" · field "description": declared, but nothing in this stack displays or reads it (inert) + fix: add it to a view column or a form section, or remove the declaration + rule: field-no-consumers at objects[1].fields.description — `os explain field-no-consumers` for what counts as a consumer + ``` + + The finding's `message` no longer restates its `where`, and no longer carries the list of consumer and carrier kinds, the exemptions or the scanned roots; `hint` is the fix alone (for a `carrier-only` verdict it still names each carrier site a removal must clean). The `verdict`, `carriers` and `rootsScanned` fields of a `field-no-consumers` finding are unchanged. A tool that matched the old message text should match on `rule`, `where` and `path` instead. + + `security-owd-unset` also changes on the runtime wire. It runs at the metadata write door's publish gate for `object` writes (Studio, REST `/meta`, MCP), and an active write of a custom object (neither `isSystem: true` nor `sys_`-named) with no `sharingModel` is refused with a 422 whose `security-owd-unset` issue now carries the message `custom object declares no sharingModel (OWD); the runtime falls back to 'private', but the baseline must be an authored decision` and the hint `declare sharingModel: 'private' (owner + shares; recommended), 'public_read', 'public_read_write', or 'controlled_by_parent' (master-detail children)`. The old message named the object, which the issue's `where` and `path` still carry, and told the leave_request incident, which `os explain security-owd-unset` now prints. + - **`os explain `.** `os explain` takes an author-time rule id as well as a schema name — `os explain field-no-consumers`, `os explain security-owd-unset` — and prints the reasoning the warning no longer carries; `--json` prints `{ rule, covers, paragraphs }`. A schema name resolves exactly as before. With no argument it also lists the rule ids that have an explanation (`--json` adds `rules: [{ id, covers }]`). The `rule:` line names the command only for those rule ids. An argument that is neither still exits 1, and its error string changes from `Unknown schema: "X"` to `Unknown schema or rule id: "X"`, followed by a second list, `Rules with an explanation: …`; the `--json` `error` field changes the same way, from `Unknown schema: X` to `Unknown schema or rule id: X`. + - **`os explain constructor` (or `__proto__`) is refused as an unknown id** instead of printing `Schema: Object … undefined` and crashing with `schema.required is not iterable`: both lookups read own keys only. + - **`os validate` prints the `fix:` and `rule:` lines** under each author-time warning, the way `os build` does, and the hint line under every author-time finding (`os build`, `os validate`, `os verify`, `os init`) is now labelled `fix:`. `os lint` adds the same `os explain` pointer to its rule line. + - **The `fix:` line is always a fix.** `expression-invalid` no longer puts the authored source in `hint`, where it printed as `fix: source: …`: the source now ends the finding's `message` as `` — source: `…` `` (the spelling the flow engine's runtime refusals use), so the CLI verdict line still carries it, and so does the issue `message` at the runtime publish gate for `flow`, `action`, `hook` and `object` writes (Studio, REST `/meta`, MCP): in the 422 for an `error`, in the 2xx `advisories` for a `warning`. Its `hint` is empty, so no `fix:` line prints and the runtime issue's `hint` is `''`. `component-props-invalid`, `flow-time-relative-descriptor-invalid`, `react-prop-missing-required` (where the component contract describes the binding) and `liveness-experimental-property` carried context in `hint`; each now leads with the instruction, and `component-props-invalid`'s message states its consequence (nothing refuses it today, so the renderer receives the props as written). Two of the four run at the runtime publish gate. `flow-time-relative-descriptor-invalid` is an `error` on `flow` writes, so its new hint reaches the 422 issue `hint`. `liveness-experimental-property` runs on `email_template`, `mapping` and `datasource` writes as a `warning`, so its hint would ride the 2xx `advisories`, but none of those three ledgers has an `experimental` row today, so it reaches no runtime response yet. `component-props-invalid` is CLI-only, and `react-prop-missing-required` judges no `page` write at the gate, so their new text reaches the CLI only. + - **New exports in `@objectstack/lint`:** `RULE_EXPLANATIONS`, `explainRule(ruleId)` and the type `RuleExplanation`, from the root entry and from the import-free `@objectstack/lint/rule-explanations` entry. +- db9cf80: feat(cli): `os migrate security-catalog-overlays` lists, and with `--apply` deletes, the environment-wide rows a v18 cold boot refuses — run it before the first v18 boot + + Clause-②: yes (widening) + + - **What it is for.** A v18 cold boot refuses to start when the environment catalog holds a permission-set or position name that a configured package also declares (ADR-0048 N.3). A deployment upgraded from 17.x can carry such rows: a set or position saved in the environment over a package's name before the packaged locks refused that, or over one of the platform security plugin's own sets (`member_default`, `admin_full_access` and the rest). The server cannot clear them, because it does not start. This step is the offline remedy. + - **What it lists.** Every active, environment-wide `sys_metadata` row (`organization_id IS NULL`, `state = 'active'`) of type `permission` or `position`, the legacy plurals `permissions` and `positions` included, whose name a configured package holds. That is the population the cold boot refuses: the list is computed from the engine's own reading of who holds a name, not from a copy of it. A draft row and an organization-scoped row are not loaded at boot, so they are never listed. Each row is shown with its stored type, its `package_id` binding and the package that holds its name. + - **How it gets there without the refusal.** It composes the deployment as `os serve` does: the host config's plugins, the application or the compiled artifact, and the security plugin behind `serve`'s auth gate. It runs the kernel's first phase for them only, and boots without reading `sys_metadata` back into the registry, so the cold-boot check meets nothing and the boot comes up. No server starts. The security plugin's shipped sets are package-held names only where `serve` composes that plugin, so the step reads the environment `serve` reads. With `OS_AUTH_SECRET` (or `AUTH_SECRET` / `BETTER_AUTH_SECRET`) set, or on a development boot, the plugin is composed and its sets are held, unless the `auth` tier is off. The step takes `os serve`'s `--preset` and `--dev` with `serve`'s meaning, read through the rules `serve` reads them by: `--preset` names the tier preset the auth tier falls back to, and `--dev` composes the config's `devPlugins` and makes the boot a development one (as does `NODE_ENV=development`). The report says which way it went. **Run the step with the flags and environment the deployment boots with.** + - **Preview by default.** The preview boots read-only, like `os migrate plan`. It writes nothing, creates no database file, and exits `1` when it lists a row: the next boot would be refused. `--apply` deletes the listed rows, after a `[y/N]` prompt or `--yes`, and prints one audit line per row: what was deleted, through which write path, or why not. It exits `0` when every listed row is gone. `--json` prints one document (`listed`, `deleted`, `failed`, `rows[]` with `outcome`, `securityPlugin` and `serveFlags`). `--force` gets past a busy SQLite file. `--database-url` / `$OS_DATABASE_URL` name the target. + - **How it deletes.** A row stored under `permission` or `position` goes through the metadata protocol's own delete, the one `DELETE /api/v1/meta/:type/:name` uses. That writes a `sys_metadata_history` tombstone and a `sys_metadata_audit` row, with actor `os migrate security-catalog-overlays`. A row stored under a legacy plural is out of that door's reach, so it goes through the `sys_metadata` repository beneath it. That delete is addressed by the row's stored type, name, `package_id` and checksum, and writes the same history tombstone. + - **The refusal names it.** The cold boot's `NAMESPACE_CONFLICT` message now points at `os migrate security-catalog-overlays` for the environment's rows, in place of "through the metadata API on a boot that leaves the package out of the configuration, or in the database". The envelope (`code`, `status`, `conflicts[]`) is unchanged. + - **What it never does.** It adopts nothing: `managed_by` and `package_id` are never rewritten, and no row is renamed. A row the environment needs under its own name must be re-created under a name no package holds. A host config that exists and cannot be loaded is refused before any row is read, because the held names would be incomplete. + - **New exports it is built on.** + - `@objectstack/objectql` exports `findPackageHeldSecurityCatalogNames(registry)`: every package-held permission-set and position name, with its holder packages. The cold-boot check now reads the same list. + - `@objectstack/core` exports the auth gate `os serve` and the step share. `resolvePlatformAuthComposition` answers whether the platform composes `AuthPlugin` and the security plugin beside it, or why not. With it come `resolveStackTiers`, `STACK_TIER_PRESETS`, `CAPABILITY_TO_TIER`, `resolveAuthSecret`, `isDevelopmentBoot`, `DEV_AUTH_SECRET_FALLBACK`, `stackSuppliesAuthPlugin`, `isHostKernelComposition` and the `PlatformAuthComposition` / `PlatformAuthSkipReason` types. `Serve.TIER_PRESETS` and `Serve.CAPABILITY_TO_TIER` are now handles over the core declarations, and `os serve` composes exactly what it composed before. + - `@objectstack/runtime`'s `createStandaloneStack` accepts `hydrateMetadataFromDb: false`, which skips the `sys_metadata` read-back. The default stays on. + - `@objectstack/spec` registers the ADR-0087 D3 entry `security-catalog-environment-overlay-refused` in its migration registry and `spec-changes.json`, so `os migrate meta --from 17` names the refusal and this step as its remedy. Its projection is the entry under protocol 18 in the upgrade guide (`docs/protocol-upgrade-guide.md`). +- faf6348: feat(i18n): a refused `end` node's message translates in the run's locale (`flows..refusals..message`) + + Clause-②: yes (widening) + + - **What is new.** An `end` node declaring `outcome: 'refused'` shows its `message` to the person who started the run. Translate it under `flows..refusals..message`, keyed by the flow's `name` and the `end` node's `id`, the same addressing the `screens` keys use. Before this key, a refusal rendered in the source language on every console. + - **Keep the holes.** The translation is a `{{ }}` template like the message it translates (`'已拒绝:{{ record.name }} 是重复记录'`). A single-brace `{token}` in a translation is refused when the bundle is parsed, with the same text-slot refusal the source message gets. + - **Where it is read.** The engine picks the translated template before it renders the holes, through the `i18n` service, in the run's locale, and the run stores and returns that text (`refusalMessage`). With no translation for that locale, the authored message renders. `flowRefusalMessageKey(flowName, nodeId)` (`@objectstack/spec/system`) spells the key. + - **The run's locale.** `AutomationContext.locale` (`@objectstack/spec/contracts`) is new and optional. The trigger door (`POST /api/v1/automation/:name/trigger`, the legacy trigger route, a `type: 'flow'` endpoint) and the action door (`POST /api/v1/actions/...`, the MCP `run_action` bridge) set it from the request's resolved locale: `Accept-Language` first, then the `localization` settings. It is persisted with a paused run, so a resumed leg renders in the locale the run was started in. A run no person started (a record-change or schedule trigger) has no locale and stores the authored message. + - **`objectstack validate`** refuses a refusal key over an unknown flow, an unknown node, a completed `end` node, or a node of another type (`translation-target-unknown`, error). + - **`os i18n extract` and the coverage gate** now scaffold and demand the refusal message of every refusing `end` node in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` and has a refusing `end` with no translation now gets one missing-key issue per locale for it. + - **`AutomationEngine.setI18nServiceSource()`** (`@objectstack/service-automation`) attaches the `i18n` reader. The automation plugin wires it, so a host using `AutomationServicePlugin` needs no change. A host constructing the engine directly gets the authored message until it attaches one. +- f89b5f5: feat(i18n): a screen field's option labels and a flow's terminal toasts have translation keys (`flows..screens..fields..options.`, `flows..successMessage` / `.errorMessage`) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. Two options of one screen field whose values read the same as text — `1` and `"1"`, `true` and `"true"`, or a repeated value — are now refused when the flow is parsed (`FlowSchema`, `objectstack validate`, the metadata save door, `registerFlow`), at the second option's `value`. Neither the option's label translation (keyed by the value as text) nor the console's select can tell such a pair apart. To fix a refused flow, give each option of the field a value that stays distinct as text, or drop the duplicate. Measured reach in this repository: 0 — no flow in its examples carries such a pair. + + - **What is new.** A fully translated screen flow no longer has to keep its select options and its completion or failure toast in the source language. Translate an option's label under `flows..screens..fields..options.`, and the flow's own toasts under `flows..successMessage` and `flows..errorMessage`. + - **How an option is addressed.** By its value read as text: `String(value)`, so the option `{ value: 1, label: 'Tier 1' }` is translated under `options.1` and `{ value: true, … }` under `options.true`. `flowScreenFieldOptionKey(value)` (`@objectstack/spec/automation`) spells the key. Not by position (reordering options would re-point every translation), and not by an object field's options (a screen field declares no such binding). + - **One new refusal**, the narrowing the BREAKING note above describes: option values of one screen field must stay distinct as text, because the label translation is keyed by them. + - **The toasts translate only where the flow authors one.** A bundle cannot add a toast to a flow that declares none; `objectstack validate` refuses such a key (`translation-target-unknown`, error). + - **Where it is read.** `translateFlow` (`@objectstack/spec/system`) overlays both, and `resolveFlowScreenFieldOptions(bundle, flowName, nodeId, field)` resolves a served screen field's options. The console's flow runner reads them in a following release; until then a translation is stored and the authored text is drawn. + - **`objectstack validate`** warns on an option key that names no declared option of the field, or names it by its label (`translation-option-key-unknown`). + - **`os i18n extract` and the coverage gate** now scaffold and demand each screen field's option labels, the failure toast of every flow that declares one, and the completion toast of a flow with a screen, in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` gets one missing-key issue per locale for each until it translates them. + - **A screen's `description`** is keyed too, in the same release, and translated by the engine rather than the console: see the entry for `flows..screens..description`. +- d25b187: feat(cli): `os i18n extract` and the coverage gate scaffold and demand a screen's `description` + + Clause-②: yes (widening) + + - `os i18n extract` now writes `flows..screens..description` into the skeleton for every screen that authors body text, seeded with the authored `{{ }}` template so a translator keeps its holes. + - The coverage gate demands it in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` gets one missing-key issue per locale for each screen that authors a `description`, until it translates it. A screen with no `description` is never asked for one. +- cc305a3: feat(cli): `os migrate organization-ownership` — the read-only plan of the v18 organization-ownership ceremony (ADR-0131 D10, ceremony item 1) + + Clause-②: yes (widening) + + A new command beside the `os migrate` family. It reads the database and writes a plan file the operator keeps; it never changes a row or a column, and it has no writing mode yet. + + - **Per table:** the object's fate from the ADR-0131 D10 inventory (column drop, mirror deletion, attribution through a parent anchor, or report) with its citation, the row counts that fate touches, the rows whose owner cannot be derived (listed by id, with the reason), and whether the table will receive the `NOT NULL` constraint. + - **Ruled populations are counted by name:** the organization-scoped `sys_metadata` rows that the ceremony promotes, with their conflict list; the customized email templates; and the global settings rung that moves to `sys_platform_setting`. + - **Refuses, naming the table:** a table it cannot enumerate. That covers an unsupported driver, a read that fails, a column the fate needs that the table lacks, a platform table the inventory gives no fate, a name it cannot quote, and a database with no `sys_organization`. ⛔ It never reports such a table as empty. An inventoried object with no table on the database is listed as `absent`. + - **Under the `single` posture,** a NULL row that no anchor derives is attributed to the Default Organization (slug `default`). Under a walled posture it is reported. + + ```bash + os migrate organization-ownership # writes organization-ownership-plan-TIMESTAMP.json + os migrate organization-ownership --out plan.json --json + ``` + + The plan file is never overwritten. Nothing about bare `os migrate` (the schema-drift plan) or any other subcommand changes. +- 1eff322: fix(cli)!: `objectstack build` refuses to lower a hook body that reaches `ctx.dispatch.scope` or `ctx.submitted` (#22810) + + Clause-②: no (narrowing) + + The sandboxed body face does not carry either member, and the spec says so: `HookContextSchema.dispatch` gives a body `{ mode, index }` "and not `scope`", and `submitted` is "NOT marshalled into the sandboxed `body` face". The build lowered such a handler anyway, at exit 0, with no warning. Measured through `bootStack`'s artifact door: a body writing `ctx.dispatch.scope.` threw `TypeError: cannot set property … of undefined` on every insert, so REST answered `500 INTERNAL_ERROR` and stored nothing, while the same handler in-process answered `201`. A body reading `ctx.submitted` saw `undefined` on an update, where the in-process handler sees the caller's submission, so a guard written on it evaluated differently with no error at all. + + The hook body extractor (`extractHookBody`) now refuses both members as forbidden tokens, the way it already refuses `.sudo(` and `.create(`. `os lint` calls the same extractor, so it reports them too. + + **BREAKING — what moves for consumers.** + + - **`objectstack build`.** A handler that writes or reads `ctx.dispatch.scope`, or reads `ctx.submitted`, was lowered to a `body` that fails in the sandbox. It is now bundled into the runtime module (`objectstack-runtime.*.mjs`) instead, with a warning naming the hook and the member, and the build still exits 0. Where that module is served beside the artifact the handler runs in-process, so `scope` and `submitted` are real again: measured, the same insert answers `201` and stashes. A deployment that serves `objectstack.json` alone has no function to bind the hook to, so it logs the hook as refused at boot and the hook does not fire. + - **`objectstack build --strict-body`.** Such a handler built at exit 0. It now fails the build (exit 1), naming the hook and the member. + - **`os lint`.** It said nothing. It now reports a `hook-body/bundled-fallback` warning at `hooks[i].handler`. + - **The spellings refused.** Direct and optional (`ctx.dispatch.scope`, `ctx?.submitted`), bracket (`ctx.dispatch['scope']`), through an alias (`const d = ctx.dispatch; d.scope`, `const c = ctx; c.submitted`), and destructured (`const { scope } = ctx.dispatch`, `const { submitted } = ctx`). For `scope` the body must also name `dispatch` somewhere, because `scope` is an ordinary field name. + - **Not refused.** `ctx.dispatch.mode` and `ctx.dispatch.index`, and a field that happens to be named `scope` or `submitted` read through `ctx.input` or `ctx.previous` (`ctx.input.scope`). A body reading such a field off a local row variable (`row.submitted`), or spelling `x.submitted` inside a string, is refused too. That is the over-refusal side: the handler is bundled and keeps working. + + **The one-line fix**, for a handler you want shipped as a body: do the cross-dispatch work once at `ctx.dispatch.index === 0`, or keep the state in the record through `ctx.api`, instead of stashing on `ctx.dispatch.scope`; read the stored row on `ctx.previous` instead of `ctx.submitted`. A handler you are content to run bundled needs no change. + + **Unchanged.** No spec key, export, type or stored shape moves. The sandbox still marshals exactly what it did: `{ mode, index }` for `ctx.dispatch`, no `ctx.submitted`. An in-process handler, a top-level `functions:` entry referenced by name, and a hook that carries an explicit `body` are not lowered here and are not judged. + + +- 6d36017: fix(cli)!: `objectstack build` rebinds a hook handler's parameter to the sandbox's `ctx` instead of dropping it, and refuses the parameter lists it cannot rebind (#22837) + + Clause-②: yes (narrowing) + + The lowering ships a hook handler's statements as `body.source`, and the runtime runs a body as `(async (ctx) => { SOURCE })`, so `ctx` is the only name a body is given. The handler's parameter list was dropped. A handler written `async (hookCtx) => { hookCtx.input.note = … }`, or `async ({ input }) => { input.note = … }`, lowered at exit 0 with no warning, and its body threw `ReferenceError: 'hookCtx' is not defined` on every run. Measured through `bootStack`'s artifact door: REST answered `500 INTERNAL_ERROR` and stored nothing, while the same handler in-process answered `201`. + + The hook body extractor (`extractHookBody`) now re-emits the handler's one parameter as the body's first statement, `var hookCtx = ctx;` or `var { input } = ctx;`, so the handler's own code runs as written. Both boots of the door above now store the same row. `os lint` calls the same extractor, so it agrees. + + **BREAKING: what moves for consumers.** + + - **A hook handler whose parameter is not named `ctx`.** It was lowered to a body that failed in the sandbox. It is now lowered to a body that runs. A handler whose parameter is `ctx`, or that takes none, lowers byte-for-byte as before. + - **A hook handler whose parameter list cannot be rebound.** This covers more than one parameter, a default value or a rest parameter on it, a destructuring pattern that binds the name `ctx`, a body that declares its own `ctx` with `let` / `const` / `class` / `function`, and a destructured `log` / `crypto` / `title` / `api` taken apart further than one plain name (capability inference cannot follow that). Such a handler was lowered to a body that failed, or that ran with a parameter the sandbox never bound. It is now bundled into the runtime module (`objectstack-runtime.*.mjs`), as `.sudo(` and `fetch(` already are, with a warning that names the hook and the parameter. `objectstack build` still exits 0, `objectstack build --strict-body` now fails on it (exit 1), and `os lint` reports `hook-body/bundled-fallback` at `hooks[i].handler`. A handler taking `(ctx, extra)` is refused even when `extra` is unused: an in-process hook handler is only ever called with the context. + - **A hook handler reaching `ctx.provenance`, `ctx.ql` or `ctx.transaction`.** These are the rest of the in-process `HookContext` that the sandbox does not carry. A body reads `ctx.provenance` as `undefined`, so a guard on `ctx.provenance.flowRunId` evaluated as if the write had no origin. `ctx.ql` (the engine) and `ctx.transaction` (a driver handle) are live host objects the sandbox cannot hold. Such a handler is now bundled, with the same warning, build exit code, `--strict-body` failure and lint rule as above. `ctx.api.transaction(fn)`, `ctx.api.object(name)` and a field merely named `provenance`, `ql` or `transaction` read through `ctx.input` or `ctx.previous` are not refused. + - **A member destructured in the parameter list now meets the refusal the body spelling meets.** `async ({ input, submitted }) => …` and `async ({ dispatch: { scope } }) => …` were lowered (and failed). They are now refused like `const { submitted } = ctx`. + - **A method-shorthand handler with exactly one nested function** (`async handler(ctx) { … rows.map((r) => r.id) … }`) is now judged on the method rather than on the nested function. A method-body name that nested function closes over was reported as a free identifier, and the handler was bundled. It now lowers. A module-scope helper called outside the nested function went unreported, and the handler lowered and threw `ReferenceError`. It is now refused and bundled, like any other module-scope reference. + - **The published `extractHookBody` (`@objectstack/cli/hook-body`) is the hook face.** Its signature is unchanged. An action's `(input, ctx)` target passed to it is refused for its second parameter, where `objectstack build` lowers it unchanged: an action body runs in its own `(input, ctx)` wrapper and gets no rebind. + + **The one-line fix**, for a refused handler you want shipped as a body: take the context as the one parameter (under any name, or destructured), drop the default, and read the member you need through `ctx.api` or `ctx.previous`. A handler you are content to run bundled needs no change. + + **Unchanged.** No spec key, export, type or stored shape moves, and `extractHookBody`'s published signature and refusal kinds are the same. The sandbox still marshals exactly what it did. An action target's lowering is unchanged. An in-process handler, a top-level `functions:` entry referenced by name, and a hook that carries an explicit `body` are not lowered here and are not judged. + + +- 6364375: fix(cli)!: `objectstack build --strict-body` counts each callable once, and `--json` `issues` lists callables only (#22839) + + Clause-②: no (narrowing) + + `--strict-body` printed "N callable(s) lack a metadata body" with N one higher than the callables it listed: one refused handler read 2, four read 5. The list was the extractor's refusals plus one extra row whenever any callable was not lowered, with `origin: ''` and the reason "N callable(s) lowered to handler ref but produced no body". The header counted that extra row as a callable, and `--json` published it in `issues` beside the real ones. + + The list is now one row per callable that was not lowered into a metadata body, each callable once. It is the population the extra row's number was meant to count, itemized: in the two shapes below that number undercounted it. The header's N is the number of rows. + + **BREAKING — what moves for consumers.** + + - **`--json` `issues`, on the `strict-body: missing body` exit.** Before: the refusals plus the `` row. Now: callables only, and no row has the origin ``. A handler the extractor refused keeps its row unchanged (`origin`, `reason`, `kind`, `freeIdentifiers`). A callable that was never tried now gets its own `{ origin, reason }` row instead of being folded into the aggregate's number: a top-level `functions` entry (`function 'name'`, which has no `body` slot), or a hook or action whose author wrote its own `body` beside an inline function (that function still rides the runtime module). + - **The header.** It reads the number of rows listed. For one refused handler that is 1, not 2. + - **A stack that carries the same callable at the top level and inside a `packages[i].manifest` body.** Such a stack is walked twice, so a refusal was listed twice and a lowered body was counted twice. Each callable is now listed once. A `functions` entry in such a stack beside a lowered hook was hidden by the double count, so the build passed `--strict-body`; it is now refused, and named. + - **A stack that binds one function as the handler of two or more hooks or actions, beside a `functions` entry or a hook or action with an authored `body`.** This happens in a single walk too. The shared function was lowered at each of its slots, and each slot counted as one lowered body, while the function counted as one callable. The surplus cancelled the callable beside it that was never lowered, so the old count read 0, no warning was recorded, and the build passed `--strict-body`. Such a stack is now refused, and the row names the `functions` entry, or the hook or action whose author wrote its `body`. + + **The one-line fix**, for a script that read the `` row: read `issues.length` for the number of callables, and the rows themselves for which ones. + + **Unchanged.** For a stack in which each callable is bound at one slot, `--strict-body` refuses exactly the stacks it refused before. Every refusal keeps the same exit code (1) and the same `error` string. The default build (no `--strict-body`), its warning lines, `bodyExtractionWarnings` in the success payload and the runtime-bundle decision do not move. No spec key, export, type or stored shape moves. + + +- aa94566: fix(cli)!: `objectstack build` writes the runtime module whenever a callable still needs it, and its warning counts each refused handler once (#22853) + + Clause-②: no (narrowing) + + `objectstack build` decided whether to write `objectstack-runtime.*.mjs` from the number of callables minus the number of slots lowered into a metadata body. The first counts each callable once and the second counts each slot, so a callable lowered at two slots cancelled a `functions` entry beside it. The build then printed `Skipping legacy runtime bundle (all 2 callables are body-only)`, exited 0 and wrote `dist/objectstack.json` alone, and the `functions` entry shipped with no implementation. Three shapes reached it: + + - the same hook at the top level and inside a `packages[i].manifest` body, beside a `functions` entry; + - one handler shared by two hooks, beside a `functions` entry; + - one function bound both as a hook handler and as a `functions` entry. + + The module is now decided from the list `--strict-body` counts: one row per callable the runtime module has to carry, each callable once. A slot that has no body, which is a `functions` entry or a hook or action whose extraction was refused, keeps its callable on that list even when another slot lowered the same function. The default build's warning (`N handlers could not be lowered to a metadata body`) and its summary line now count the refused handlers on that list, each once. + + **BREAKING: what moves for consumers.** + + - **`objectstack build`.** It now writes the runtime module for the three shapes above, and for a function bound at a refused slot and at a slot that lowered it. Every stack it wrote the module for before still gets one. + - **`objectstack build --no-runtime-bundle`.** Those stacks are now refused (exit 1). They passed at exit 0 while the `functions` entry, or the refused hook, had no implementation. The message's first number counts the callables that lack a body, and its second counts the refused handlers, each once. + - **`objectstack build --strict-body`.** A stack that binds one function at a slot that lowered it AND as a `functions` entry or at a refused slot is now refused, and the row names the `functions` entry or the refused slot. It passed before. For a function bound at several slots, the row in `--json` `issues` is the extractor's refusal when any slot refused it, then the `functions` entry's row, then the authored-`body` row. Before, it was the first such slot's row. + - **The default build's warning count.** A refused handler shared by two hooks, or carried at the top level and in a package body, counts as one handler. It counted once per slot. + + **The one-line fix**, for a CI step that runs `--no-runtime-bundle` on a stack carrying a `functions` entry: drop the flag. A `functions` entry has no metadata body, so it runs only from the runtime module. + + **Unchanged.** For a stack in which each callable is bound at one slot and walked once, the module decision, `--no-runtime-bundle` and `--strict-body` give the same verdicts as before. Exit codes and `error` strings are the same. `--json` `bodyExtractionWarnings` still carries one record per refused slot. No spec key, export, type or stored shape moves. + + +- ed03834: feat(cli): `os migrate organization-ownership --apply` promotes customized email templates and moves the settings global rung (ADR-0131 D10) + + Clause-②: yes (widening) + + Two steps join the ceremony, after the authored catalog conversion and before the verified id→name rewrite. Each is checkpointed and resumable like the others. + + - **`template-promotion`** (ruling C on ADR-0131 §6 Q1). Every `sys_email_template` row the mirror deletion keeps for it (customized, or with `customized` unknown, and every row no seeder wrote: `managed_by` NULL or other than package / platform / system, with or without an organization) becomes an environment `email_template` item through the metadata door, one per `(name, locale)`, before mirror deletion removes the rows. It runs under every posture. Seeded, uncustomized copies are not promoted. A promotion that already landed is skipped. The step stops, keeping every row of the template, in four cases: + - a conflict: rows of one `(name, locale)` that differ. Each conflict is listed in the plan and in the `--apply` report, with every candidate's row id and organization. It counts as remaining and holds back `verified_at` until the operator chooses one row with the new repeatable flag `--template-choice ROW_ID`. A choice that names no candidate, or two candidates of one conflict, is refused before anything is written. The chosen body is promoted, and the other candidates are deleted only after that promotion is verified; + - a customized or authored row with no name; + - an environment template that already exists and differs, since the environment's edit is never overwritten; + - a door refusal. + - **`settings-move`** (ADR-0131 D7). Every `sys_setting` row with `scope = 'global'` and no organization is copied into `sys_platform_setting` by the database itself (`INSERT … SELECT`), so `value` and the `value_enc` secret handle move verbatim. The destination row is read back, and only when it equals the source does the row leave `sys_setting`. A key `sys_platform_setting` already holds with the same value is only removed from `sys_setting`. The step stops, moving nothing for that key, in two cases: + - `sys_platform_setting` already holds the key with a different value (the one the runtime serves); + - `sys_setting` holds differing rows for the key. + + The `global-rung-move` departure is now executed, so it reads zero after the run and no longer holds back `verified_at`. The `declared-rule-mirrors` departure still counts until the declared-rule seeder is retired. + + Mirror deletion now also deletes the promoted template rows, and only those. Before its first template delete it runs the promotion pass again, so a row customized or authored between runs (on a resumed ceremony) is promoted, not deleted. It then deletes the seeded, uncustomized mirrors and, by id, the rows whose environment copy that pass verified. Any other promotion row stays and blocks. The apply report gains `movedOut` / `movedIn` per table, and row counts reconcile as before − deleted − moved out + moved in. +- 2808069: feat(cli): `os migrate organization-ownership --apply` — the v18 organization-ownership ceremony's apply step and post-check (ADR-0131 D10, ceremony items 2 to 4) + + Clause-②: yes (widening) + + The command that planned the ceremony now performs it. Without `--apply` nothing changes: the read-only plan, written to a file. With `--apply`: + + - **The backup comes first.** It is the operator's act: pass `--backup-taken`, or answer yes at the prompt. A non-interactive run without the flag is refused before the database is opened. + - **The fates run in order**, each step stopping the run where it meets a blocker: + 1. **Attribution** — every NULL `organization_id` row the plan derives an owner for takes it (a parent, subject or holder anchor; under `single`, the Default Organization). A row with no derivable owner is never assigned one: it stays NULL and is listed by id. + 2. **Authored catalog rows** — permission sets and positions an organization authored in Setup become environment metadata through the metadata door (under `single`; under a wall they are reported), with each position's binding rows carried into its `permissionSets`. + 3. **The verified id→name rewrite** — every `sys_user_permission_set` grant names its set in `permission_set`, then every grant is verified to name a set the security catalog resolves. `permission_set_id` stays. + 4. **Mirror deletion** — only after that verification, read again just before the first delete: the seeded catalog copies (per organization and NULL residue), the binding rows, seeded capabilities and seeded templates. Authored, customized and organization-stamped rows stay. + 5. **Column drops** — `organization_id` leaves the deployment-level objects through the schema-drift ceremony (`os migrate apply`'s machinery), never a hand `ALTER`. A table whose rows still need another step keeps its column, and the run says why. + - **Idempotent and resumable.** Every write is conditioned on the state it changes, and each finished step is checkpointed in `sys_migration` (row `adr-0131-organization-ownership`); a run interrupted mid-step resumes at that step and never re-derives an owner already written. + - **The post-check** re-reads the plan and reports, per table, what remains. The ledger row records `applied_at` once every step has finished and `verified_at` when the post-check also reads zero discrepancies. Exit code 1 while the ceremony is unfinished or the post-check finds a discrepancy. + - A JSON report is written beside the plan (`organization-ownership-apply-TIMESTAMP.json`, or `--out`); an existing file is never overwritten. + + ```bash + os migrate organization-ownership --out plan.json # read the plan first + os migrate organization-ownership --apply --backup-taken # then perform it + ``` +- 707b3cd: feat(cli,spec): `os migrate organization-ownership --fate-supplement FILE`. A host declares the ADR-0131 D10 fates of the objects it provides, and the ceremony plans and applies them as it does the platform's own rows + + Clause-②: yes (widening) + + The v18 organization-ownership ceremony refuses any platform-prefixed table that carries `organization_id` when its inventory gives that table no fate. That is right for the platform's own objects, but a host's objects have no row in the inventory, so every database a host runs was refused. The host now declares those fates itself, in one JSON file, and passes the file to the command. **Adoption: a host passes its supplement through the flag; with no flag, the ceremony behaves as before.** + + - **The file is declared in the spec.** `OrganizationOwnershipSupplementSchema` (`@objectstack/spec/system`) is a JSON array of `OrganizationOwnershipSupplementEntrySchema` rows. Each row gives `object`, `fate` (`column-drop`, `mirror-deletion`, `attribution` or `report`), `citation`, and only the fields its fate reads: `anchors` (required for `attribution`), `mirror`, `departures`, `categories`, `notNullExempt` and `note`. Every shape refuses an unknown key, and a key written on the wrong fate is refused rather than ignored. + - **Both modes read it.** The plan plans each row on the same terms as a platform row, and `--apply` performs it the same way. A row whose table is not on the database being planned is listed `absent`, so one supplement serves both a control-plane database and a tenant database. + - **It is refused, never half-read.** The command refuses a missing file, a file that is not JSON, a row the schema refuses, two rows for one object, or a row naming an object the platform inventory already gives a fate. The refusal comes before the database is opened, inside the `--json` envelope (`plan_refused` / `apply_refused`), and names the file, the row index and the object. A row naming one of the cloud-provided objects that the inventory lists with no fate (`cloudCarried`) replaces that placeholder row. + - **Nothing is read without the flag:** no environment variable and no conventional path. A host table with no declared fate is still refused by name, and the refusal now names `--fate-supplement FILE` as the remedy. + - The supplement's rows are part of the plan's `inventoryDigest`, so the apply's checkpoint is keyed on the supplement as well. + + ```bash + os migrate organization-ownership --fate-supplement host-fates.json --out plan.json + os migrate organization-ownership --fate-supplement host-fates.json --apply --backup-taken + ``` +- 6673c80: feat(cli)!: the v18 upgrade carries stored comment reactions — `os migrate comment-reactions` turns each reaction stored in the retired `sys_comment.reactions` column into a `sys_comment_reaction` record, and `os migrate apply --allow-destructive` refuses to drop that column until the conversion is verified + + Clause-②: no (narrowing) — `os migrate apply --allow-destructive` now refuses the `sys_comment.reactions` drop it accepted before, until a verified conversion is recorded; the conversion itself only writes records of an existing object, so nothing widens. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **What the upgrade now carries.** A database upgraded from 17.x keeps `sys_comment.reactions` and every emoji reaction stored in it (schema sync is additive). Those reactions are no longer lost. `os migrate comment-reactions` converts them into records: + + - **One record per stored reaction.** Each (comment, emoji, user) in a stored map becomes one `sys_comment_reaction` row with the same comment, emoji and user, in the comment's organization, owned by the user who reacted. That user can remove it like any reaction made on v18. A user id listed twice under one emoji is one reaction. + - **Nothing is guessed.** A stored value that is not JSON or not a map of emoji to user-id lists, an entry that is not a user id, an emoji longer than a reaction record holds, and a user id that names no `sys_user` row are each listed in the report with their comment id. Nothing is written for them. The readable entries of the same comment still convert. + - **Idempotent.** A reaction that already has its record is skipped, so a second run writes nothing, and re-running finishes an interrupted run. + - **Dry run by default.** `--apply` writes the records, reads the column back, and records the run in `sys_migration`. The run is recorded as verified only when every readable reaction has its record and no stored value is left unreadable. `--json` gives the full report. A run that lists an unreadable value or fails a write exits `1`, and so does an `--apply` run whose read-back finds anything unconverted. + + **The column drop waits for the conversion (the narrowing).** Until a verified run is recorded, `os migrate apply --allow-destructive` refuses to drop `sys_comment.reactions` and exits `1` (`--json`: `message: "refused_comment_reactions_unconverted"`, with the recorded run under `conversion`). Nothing else is applied on that run. This also covers the boot drift line, which names that command as the remedy for the orphaned column. What to do when it refuses: run the conversion below until it reports the run verified, then re-run the apply. + + **`os migrate apply --json` exits `1` when the `sys_account.issuer` retirement pre-flight refuses.** Its two refusals, `refused_account_issuer_collisions` and `refused_account_issuer_preflight_unreadable`, emitted their document with exit code `0`, so a caller gating on the status read a refused apply as a successful one. They now exit `1`, as the same refusals do without `--json`. The document is unchanged. + + **Upgrade order:** take a backup, run `os migrate comment-reactions`, then `os migrate comment-reactions --apply`. If it lists unreadable values, correct each one in the database so it reads as a map of emoji to user ids, or clear it if it holds nothing to keep, and run `--apply` again. Once the run is verified, run `os migrate apply --allow-destructive` to drop the column. Run these with the flags and environment the deployment boots with (an auth secret set), so the audit plugin's objects are composed. + + This supersedes the "stored reactions are not migrated" sentence in the entry that retired the column. `@objectstack/spec`: the protocol-18 semantic entry `sys-comment-reactions-column-retired` now names the conversion in its replacement, reason and acceptance criteria, and orders it before the drop. `os migrate meta` and the upgrade guide carry that text. Its id and surface are unchanged. +- 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`. +- 5b6c257: feat(plugin-audit): `sys_activity` rows carry two writer-stamped read-rule columns, and the withheld-row rule is a predicate over them, with no SYSTEM pre-scan (ADR-0139 E6) + + **Run `os migrate activity-changed-fields --apply` at once after upgrading.** After the upgrade, and until it runs, the `sys_activity` rows written before the upgrade carry no stamp and are withheld from every non-system reader: activity feeds, the header bell and record timelines show only the rows written since the upgrade. The door is `os migrate activity-changed-fields`, dry run by default, `--apply` to write, idempotent, and it stamps both columns in one run. Its flags are `os migrate audit-metadata-bodies`'s, one for one (`--database-url`, `--apply`, `--yes`, `--force`, `--json`). The columns arrive with the upgraded deployment's schema sync, which an `--apply` run performs itself; a dry run against a database no upgraded process has opened cannot read them and exits `1`. + + Clause-②: yes + + `sys_activity` gains two columns, stamped at write time by one derivation (`planActivityStamp`): the CRUD mirror stamps its own rows, and a `sys_activity` before-insert hook stamps every row any other writer inserts (an app's own server action, the seed loader), over whatever value that writer sent. + + - `changed_fields` (`tags`) — the key set of the recorded change: for an update, every field its recorded `old` / `new` values name; `[]` for a create, a delete, a note, or an update that touched only `internal` fields. It is served narrowed to the fields the reader is served. + - `milestone_key` (`text`) — for a fired activity milestone, one sorted, comma-joined key of every field the object's `activityMilestones` declaration watched when it fired; `null` for every other row. It is never served. + + The withheld-row rule is now a WHERE over those two columns, one branch per parent object, on `find`, `findOne`, `count` and `aggregate`. It reads no row, so the 2,000-row SYSTEM pre-scan and its window are gone: a broad read of the stream is served every row the caller may read, with a true `total`. What the rule withholds: + + - an update row whose `changed_fields` is non-empty and names no field the reader is served (unchanged); + - a fired milestone row, unless its key equals the key of the object's CURRENT `activityMilestones` declaration and the reader is served every field that declaration watches. **The cost:** a milestone row written under an earlier declaration, or naming a since-removed field, is withheld from every non-exempt reader until retention ages it out (14 days declared; `lifecycle.retention_overrides` may lengthen it); + - an unstamped row, from every non-system reader: one at rest from before the upgrade until the backfill stamps it, and one the planner cannot judge (a milestone row naming no readable watched-field list), which stays unstamped. + + The parent-field query guard covers both columns as it covers `metadata`: a reader withheld any field of the parent is refused a filter, sort, group or aggregate over `changed_fields` or `milestone_key` (`PERMISSION_DENIED` / 403). + + `@objectstack/plugin-audit` exports the backfill, `backfillActivityChangedFields` (with `ACTIVITY_BACKFILL_PAGE_SIZE` and its report and engine types), and the planner, `planActivityStamp` (with `ACTIVITY_CHANGED_FIELDS_COLUMN`, `ACTIVITY_MILESTONE_KEY_COLUMN` and `ActivityStamp`). Inside the module (none of it is exported from the package), `computeWithheldUpdateFilter` keeps its name and now builds the predicate, `isWithheldOnlyUpdate` keeps its name as the update half over `changed_fields`, and `isWithheldMilestone` / `isWithheldRow` are gone with the pre-scan they judged for. +- ae4dfaf: feat(driver-turso,cli): the v18 organization-ownership ceremony runs on a Turso remote database + + Clause-②: yes (widening) + + `os migrate organization-ownership`, the plan and `--apply --backup-taken`, now completes against a Turso database reached over the remote transport (`libsql://…`), the transport hosted environments use. Before, the plan stopped at its boot with `Deferred schema DDL is not supported by the Turso REMOTE transport`, and the apply's column drops had no remote path either. + + - **`@objectstack/driver-turso`**, remote face: + - `detectManagedDrift()` answers instead of refusing with `NOT_IMPLEMENTED` / 501. The verdict is the shared differ's, the one the local face runs; the physical schema it diffs is read from the remote catalog (`pragma_table_info`, `sqlite_master`, `pragma_index_list` / `pragma_index_info`, and the ADR-0120 D4 duplicate probe). The declarations it diffs against are recorded by every remote schema door and by the DDL-free `registerObjectMetadata`, so a boot that skipped schema sync is covered too. + - `applyMigrationEntries()` applies `drop_index`, `create_index`, `recreate_index`, `replace_unique_index` and `drop_column` instead of refusing. Each entry runs as one libSQL `write` batch, so it lands whole or not at all: a `drop_column` drops the column's indexes with it in the same transaction, and a re-created index that fails to build leaves the old one in place. An entry is reported `applied` only once a read-back shows its target state. Every other column entry (nullability, width, a dropped DEFAULT) needs the SQLite table rebuild, which this transport does not run, so it is reported `skipped` and logged, as the in-place dialects report an op they cannot perform. + - The remote face declares `driftReportCaveat`: a sentence saying its drift report is for reading only and naming the reason. The OS_ARTIFACT_URL boot gate reads it (below). The local and embedded-replica faces declare `null`. + - The remote driver's `config` now carries its `url`, so the CLI labels a remote database by its URL (credential-free) instead of `better-sqlite3`. The ceremony writes that label into its plan and refuses an apply whose plan names another database; that refusal can now tell two remote databases apart. + - **`@objectstack/cli`**: the ceremony's plan and apply boots skip schema sync, instead of arming deferred DDL, when the primary datasource is a Turso remote one. No DDL is attempted, so the plan stays read-only, and every other datasource the boot connects is still deferred. The environment's own boot has already run the additive sync the ceremony reads. Every other `os migrate` command keeps refusing on that transport: their dry runs list held-back schema work, which a boot that holds nothing back cannot list. + - **`@objectstack/runtime`**: `createStandaloneStack` takes `skipSchemaSync`, passed to `ObjectQLPlugin` as the option `OS_SKIP_SCHEMA_SYNC=1` names. Unset, the plugin reads the variable as before. + + On a Turso remote primary, the `OS_ARTIFACT_URL` boot migration gate on `os serve` keeps its earlier answer: cannot judge, warn and boot. It applies no drift and refuses no boot. Only its warning's wording changes, from `Could not check the physical schema` to `Did not check the physical schema against the artifact: …`, followed by the driver's reason. The gate now decides this from a capability the driver declares (`driftReportCaveat`, a string on the remote face and `null` on the local and embedded-replica faces), not from a refusal it catches. Two follow-ups, filed separately and not closed by this change, have to land before that gate may act on a remote report: + - #23847: the remote transport's schema sync honours `storage.notNull` on create and add column, so the report stops naming a tightening its own sync produced; + - #23848: `os migrate apply` works on a Turso remote database, so the destructive drift the gate would refuse on has a remedy there. +- 61e5ee9: fix(cli,driver-turso): `os migrate plan` and `os migrate apply` run on a Turso REMOTE database, and the apply names every drift entry it could not apply + + Clause-②: yes + + On a primary reached over the Turso REMOTE transport (`libsql://…`, every hosted tenant), `os migrate plan` and `os migrate apply` used to stop at their boot with `Deferred schema DDL is not supported by the Turso REMOTE transport`, so the remedy the drift report and the `OS_ARTIFACT_URL` boot gate print for destructive drift, `os migrate apply --allow-destructive`, could not run there at all. Both commands now boot the way `os migrate organization-ownership` does on that transport: with schema sync skipped instead of deferred. What an operator on a remote primary now sees: + + - **The drift half runs.** `os migrate plan` lists the drift; `os migrate apply` applies what the transport can express, after the same confirmation: a column drop (with the indexes keyed on it, in one transaction) and every index entry. Nothing is written before the prompt, as before. + - **The additive half is reported as NOT examined, never as "nothing to create".** Both commands print a warning under the database line saying that tables and columns the metadata declares and the database lacks are neither listed nor created by this run, and that the serving boot (`os serve` / `os start`) creates them on this transport. The `--json` documents carry `schemaSync: "skipped"`, so their `pending: []` reads as not examined; an apply with no drift answers `message: "in_sync_partial"`, not `"in_sync"`. + - **Every entry the driver did not apply is named,** by table, column and kind, in both faces (`--json`: a new `notApplied` array on the post-apply document). The REMOTE transport runs no SQLite table rebuild, so a tightening, a relaxed NOT NULL, a width change or a dropped DEFAULT is not applied there. + - **A destructive entry confirmed with `--allow-destructive` that was not applied makes the run PARTIAL:** the human face ends `PARTIAL apply: …` and the command exits `1`; `--json` carries `message: "partial"` and exits `1`. A non-destructive entry that was not applied is named and the exit stays `0`. + + On every other database the apply behaves as before, with one difference in the same direction: a destructive change confirmed with `--allow-destructive` that the driver could not apply (its DDL failed, or the dialect cannot perform it) now exits `1` and is named, where the run used to exit `0` with a count. Its human line for the entries held back without `--allow-destructive` now reads `Skipped N destructive change(s) (re-run with --allow-destructive to include them)`. + + `@objectstack/driver-turso`: the REMOTE face's `driftReportCaveat` no longer says a destructive entry has no remedy on this transport; it says `os migrate apply --allow-destructive` applies a column drop or an index change there and names the rest. + + The other `os migrate` commands whose dry run arms deferred DDL (`duplicates`, `account-issuer`, `unmapped-columns`, `multi-value-columns`, and the dry runs of the data commands) still refuse on the REMOTE transport, unchanged. +- 67e8792: feat(plugin-audit): `sys_comment` rows carry a writer-stamped parent pair, `object_name` + `record_id`, and the comment-thread read gate narrows by it with one `ReadableIds` leaf per parent object, with no pre-scan (ADR-0139 E7) + + **Run `os migrate comment-parent-pair --apply` at once after upgrading.** After the upgrade, and until it runs, the `sys_comment` rows written before the upgrade carry no pair, and the read gate answers them as it answered every comment before: by a SYSTEM pre-scan of at most 2,000 candidate rows per read, fail closed. So a broad comment list — one not scoped to a single record's thread — can omit comments the reader may see, and its `total` agrees with the short list; a record's own comment thread is unaffected. The door is `os migrate comment-parent-pair`, dry run by default, `--apply` to write, idempotent, and it stamps both columns in one run, keeping each row's `updated_at`. Its flags are `os migrate activity-changed-fields`'s, one for one (`--database-url`, `--apply`, `--yes`, `--force`, `--json`). The columns arrive with the upgraded deployment's schema sync, which an `--apply` run performs itself; a dry run against a database no upgraded process has opened cannot read them and exits `1`. + + Clause-②: yes + + `sys_comment` gains two `readonly` text columns and an index over them. They are derived from `thread_id` (`{object}:{record_id}`, split on the first colon) by one planner, `planCommentParentPair`, and `thread_id` stays the key authors write: nothing changes for a writer. A `sys_comment` before-insert hook stamps every inserted row, whoever writes it, over whatever value the writer sent; an update that names `thread_id` (a re-point) is re-stamped from it; and an update that names either column without a thread has the pair re-derived from the row's stored thread, for every caller — a historical import (`preserveAudit`) included, where the engine's update-side `readonly` strip keeps a value — so no writer's value for either column lands. A multi-row update that names either column without a thread is refused whole (`VALIDATION_ERROR` / 400, nothing written) when its rows' threads name different records, since one write cannot store each row's own pair; over rows sharing one thread it stores that thread's pair. A thread that names no record stamps `null` / `null`, and such a comment stays hidden from every non-system reader, as before. + + The comment-thread read gate (`installCommentReadVisibility`) is now the parent-record gate the activity stream and the compliance ledger use, over the comment's own pair: one branch `{ object_name: P, record_id: { $in: ReadableIds.of(P) } }` per registered object other than `sys_comment`, ORed and ANDed into the read's `where`, on `find`, `findOne`, `count` and `aggregate`. A broad comment read is served every comment the reader may read, on any table size, with a true `total`. What keeps the bounded 2,000-row fail-closed probe, chosen up front by name, never by catching a refusal: + + - ADR-0139 D5's three classes: a delegated (on-behalf-of) caller (the whole read); a parent narrowed by an object-scoped read middleware (`sys_attachment`, `sys_comment_reaction`, `sys_approval_request`, …); a parent bound to another datasource; + - a security service that answers no `canReadObject`, or none at all (no leaf is written without it), and an engine that cannot classify a parent — each sends the whole read to the probe and is said once; + - a row with no stamped pair (until the backfill reaches it): probed by its `thread_id`, restricted to rows with no pair, never leafed; + - a composition whose registered `sys_comment` declares no pair: the thread probe over every row, said once. + + **The cost:** a deployment whose driver compiles no `ReadableIds` leaf — `driver-mongodb`, until ADR-0139's O3 — now fails a non-system comment read with `INTERNAL_ERROR` / 500 naming the driver in the log, as the activity, ledger and attachment reads already do (ADR-0139 D3); the gate does not catch that refusal into an empty list. + + Comment reactions are unchanged: their only parent is `sys_comment`, which this gate narrows by an object-scoped middleware, so the reaction gate keeps the bounded probe (ADR-0139 D5.2). + + `installCommentReadVisibility` takes an optional third argument, the security source whose `canReadObject` admits a leaf (`ParentObjectReadAdmissionSource`); without it every comment read is the bounded probe, as before. Its first parameter widens from `CommentAccessEngine` to `ParentRecordGateEngine` (`CommentAccessEngine` plus three optional members: a registry view, `hasObjectMiddleware` and `resolveEffectiveDatasource`); every existing caller's engine still satisfies it. Both types reach the published declaration through that signature. `CommentAccessEngine` gains an optional `getSchema`. `@objectstack/plugin-audit` exports the backfill, `backfillCommentParentPair` (with `COMMENT_PARENT_PAIR_BACKFILL_PAGE_SIZE` and its report and engine types), the planner `planCommentParentPair` (with `COMMENT_PARENT_OBJECT_COLUMN`, `COMMENT_PARENT_RECORD_COLUMN` and the `CommentParentPair` type). +- adce192: feat(cli): `os migrate organization-ownership --apply` certifies a database whose packages declare sharing rules — the declared sharing-rule mirrors this release's boot still seeds are reported as advisory, not as discrepancies + + Clause-②: yes (widening — the apply report file, the plan file and both `--json` payloads gain an `advisory` field on a departure entry, and the run's `advisory` count includes that departure's rows) + + - **What an operator saw.** On a v17 database whose packages declare sharing rules (HotCRM declares eleven), `--apply` ended with `complete: true` and `blocking: 11`, and never certified: the `adr-0131-organization-ownership` row of `sys_migration` kept `verified_at` null. The post-check waited for the `declared-rule-mirrors` departure on `sys_sharing_rule` to leave. That departure is the uncustomized `managed_by` `package` / `platform` rows with no organization. This release's own served boot writes those rows on every start, including the first v18 boot that the organization-ownership refusal stops, so they could never leave on this release. + - **What it sees now.** `--apply` reports `blocking: 0` and certifies: `verified_at` is set, with `blocking` 0 in the marker every reader checks. The mirror rows are named, counted and kept: + - the plan and the apply print a warning before the first write: `sys_sharing_rule holds N organization-less row(s) of … which stay: …`. The plan file's and the report's `warnings` carry it too; + - in the plan file and in the apply report (`--json` and the `--out` file), the `declared-rule-mirrors` entry of `tables[].departures[]` keeps `id`, `label`, `as` and `rows`, and gains `advisory`: why the rows stay, and what removes them. A departure without `advisory` still blocks; + - `tables[].remaining` no longer counts these rows. The run's `advisory` and the ledger's `advisory` count them, beside the unattributable rows; + - the rows keep `organization_id` NULL, so `sys_sharing_rule`'s `notNull` reads `willReceive: false` with `N row(s) stay NULL on this release, reported as advisory: …`. The plan file used to read `willReceive: true` for that table; + - the human summary prints `N row(s) advisory, not held against certification — …` under the table. Its closing warnings name the unattributable rows and the advisory rows separately; + - nothing is deleted. Every rule row stays as it was. + - **What still blocks.** Every other departure: `global-rung-move`, `template-promotion`, and a host fate supplement's own departures, even one named `declared-rule-mirrors`. Every other remaining row blocks too. + - **Already ran `--apply` and stopped at `blocking: 11`?** Plan again on this release, review the plan, and run `--apply` with it. This change leaves the inventory digest as it was, so a checkpoint recorded under this release's inventory resumes: only the post-check runs, and it certifies. A checkpoint recorded by a release whose inventory differed is superseded: every step runs again, each one idempotent, and the post-check certifies. + - **When blocking returns.** The release that retires the declared sharing-rule seeder (ADR-0131 D2) sets `DECLARED_SHARING_RULE_SEEDER_RUNS` to `false` in the same change. From that release on, these rows hold certification again until its ceremony deletes them. +- 5e97907: feat(plugin-sharing,cli)!: declared sharing rules are read from the registry — the boot writes no `sys_sharing_rule` row for them, and the 17 → 18 ownership ceremony deletes the copies the retired seeder wrote + + Clause-②: no (narrowing) + + + + The narrowings, FROM → TO, each with its one-line remedy: + + - `DELETE /api/v1/sharing/rules/:idOrName` on a declared rule — FROM `204`, the seeded row deleted (and written again by the next boot), TO `403 PERMISSION_DENIED`. Remedy: change or remove the rule in its declaration in the package; an organization's own rule is deleted as before. + - `sys_sharing_rule` — FROM one `managed_by: package` row per declared rule, TO none: the boot writes none and the ownership ceremony deletes the copies the seeder wrote (Setup's Sharing Rules object list, which lists rows, no longer shows declared rules). Remedy: read declared rules through `GET /api/v1/sharing/rules`, which lists them beside the organization's own; on an upgraded database run `os migrate organization-ownership --apply` with a fresh plan. + + The retirement note for the seeder is the ADR-0087 semantic entry `sharing-rule-declared-mirror-retired`. + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `@objectstack/plugin-sharing` no longer copies a stack's declared `sharingRules` into `sys_sharing_rule` at boot. ADR-0131 D2 retires that seeder (`bootstrapDeclaredSharingRules`). The sharing-rule service reads each declared rule from the registry whenever it reads the rule set: listing, getting, evaluating, the per-record hooks, the boot backfill and the orphaned-grant sweep. Rules an organization authors keep their `sys_sharing_rule` rows. + + - **A declared rule still grants, and stops granting when its declaration goes.** It is translated from the registry's copy exactly as the seeder translated it: the CEL `condition` is lowered to a `criteria` filter, and `sharedWith` becomes the recipient. A condition that does not lower is not served, and never as a match-all. It says so in one WARN line per rule per process. A declared rule's id is its NAME, and the grants it materialises carry the name as `sys_record_share.source_id`. Remove the declaration and the next boot's orphaned-grant sweep revokes those grants. + - **Scopes follow the posture, as the seeder's passes did.** Under `single` a declared rule is served once, organization-less, so every organization reads it. Under a wall it is served once per organization — every organization, read page by page. An organization created after boot is served from its creation on. If the organizations cannot be read at boot, no declared rule is served until the next boot, and the boot's orphaned-grant sweep revokes none of their grants meanwhile: a failed read is never a revocation. + - **A stored row of the same name answers for it.** Under `single` that is a row of that name in any organization, and under a wall that organization's own row. Such a row is an organization's own rule, a seeded copy an administrator customized, or a seeded copy the ceremony has not deleted yet. The declared rule is not served beside it, the same as while the seeder ran: it never overwrote an admin-authored or customized row. + - **`DELETE /api/v1/sharing/rules/:idOrName` refuses a declared rule** with `403 PERMISSION_DENIED`. It has no row: it is changed or removed in its declaration. Before this release a platform administrator could delete the seeded row, and the next boot wrote it again. An organization's own rule is deleted as before. + - **Setup's Sharing Rules object list no longer shows declared rules.** It lists `sys_sharing_rule` rows. `GET /api/v1/sharing/rules` lists both. + + `@objectstack/cli` — **`os migrate organization-ownership --apply` deletes the declared-rule mirrors.** The `declared-rule-mirrors` rows on `sys_sharing_rule` are the seeder's uncustomized copies (`managed_by` `package` or `platform`, `customized` not true). They now block certification until the ceremony's `mirror-deletion` step removes them. It works in the ruled order. It renames every grant naming a mirror by row id (`sys_record_share.source_id`, `source = 'rule'`) to the rule's name and verifies that no grant names a mirror any more. Only then does it delete the mirrors. Under a wall it deletes the organization-stamped copies too. + - A mirror whose rule no installed package declares, and that grants still name, blocks with nothing written. The blocker names the rows and the remedy. A mirror no grant names is deleted. + - The step also runs on a database whose ceremony already completed while an earlier release still seeded the rows, where they were advisory. Re-run `--apply` with a fresh plan. + - `DECLARED_SHARING_RULE_SEEDER_RUNS` is now `false`. A customized copy and an organization's own rule stay. Under `single` they take the Default Organization, as before. + + `@objectstack/lint` — the sharing-rule enforceability verdicts and `os explain` say what now happens to a declared rule whose condition does not lower. It is "never served", not "never seeded", because it is never written to `sys_sharing_rule`. The rule ids, severities, paths and what each rule accepts or refuses are unchanged. +- a959493: `os migrate meta --write` writes the chain's mechanical changes into the authored source files, in place, at every site it can prove + + Clause-②: yes (widening) + + - A new flag on the authored-source mode: `os migrate meta --from N --write`. Without it nothing changes: the dry run, its report and its `--json` payload are what they were, and `--out` still writes its snapshot. + - What it writes: each mechanical change the chain applied (`applied`), at a site it traces to one object or array literal in one project file — through `define*` calls and the `.create(…)` factories `@objectstack/spec` exports, module-level `const` bindings, relative imports and re-exports, and `Object.values()` over a namespace import — when the loaded value matches that literal and nothing else references the bindings on the way. Only that site's bytes change: a renamed key keeps its value and its comments, a removed key takes its own line(s), and every other byte (comments, formatting, key order) stays as it was. + - What it refuses, each change listed with the reason (`--json`: `write.manual[].kind`): `computed`, `helper`, `spread`, `shared`, `outside-project`, `mismatch`, `injected`, `unspellable`, `layout` and `unattributed`; and `entangled`, because a conversion's edits are written whole or not at all. + - What it never writes: the semantic changes (`todos`), which stay listed exactly as before, and a site a conversion declines, for which no mechanical change exists. + - After writing it re-runs the chain over the written sources. Unless the re-run applies exactly the changes it left, it restores every file it wrote and exits 1. + - `--json` gains a `write` key, only with `--write`: `status`, `files`, `written`, `manual`, `unexplained` and `verification`. + - `--write` is exclusive with `--stored`; `--stored --apply` is unchanged. +- cdf3777: The v18 organization-ownership ceremony now completes on a long-lived 17.x database, and `os dev` runs it for you on a local development database (ADR-0131, amendment 2026-10-16). + + **`os dev` upgrades a v17-shape development database in place.** When the boot refuses a local SQLite development database that is still in the v17 organization-ownership shape, `os dev` first copies the database and its `telemetry` sibling (with `-wal` / `-shm`) into `/backups/organization-ownership-/`. It then runs `os migrate organization-ownership --dev` (the plan), applies that plan, and starts the server again. The plan, the apply report and the ceremony's log are kept beside the copy. If a step blocks, `os dev` stops and prints the blocker, where the copy is, and how to resume. `os serve` / `os start`, and any database that is not a local SQLite file, keep the manual ceremony and the boot refusal unchanged. + + **What the ceremony decides for you is printed as a warning before it writes anything.** The plan carries a `warnings` list. The plan prints it, `--apply` prints it again before its first write, and the apply report keeps it. + + **The `telemetry` sibling is covered.** A development boot (or an `OS_TELEMETRY_DB` deployment) keeps `sys_audit_log`, `sys_job_run`, `sys_notification` and the other lifecycle objects in a sibling database. The ceremony now plans, attributes and drops columns for each object in the database it lives in, reading anchors across databases. Before, the boot refusal read those tables in the sibling while the ceremony only read the primary, so an upgraded database could still never boot. A copy of such a table that an older boot left in the primary is listed under `staleCopies`, warned about, and left untouched; it no longer blocks the column drops. + + **The shipped permission sets an install seeded before 2026-08-15 are adopted.** Those installs store the eight shipped sets (`organization_admin`, `member_default`, …) as `managed_by: 'admin'` rows. The authored-conversion step used to refuse them with "rename the authored row". The ceremony now re-owns them as the platform seeds they are and deletes them as mirrors once every grant names its set, so the shipped definitions apply. The warning names each row; a Setup edit to one of them is not carried over, and the pre-upgrade backup keeps it. + + **`os migrate organization-ownership --dev`** composes the stack as `os serve --dev` does: the development auth secret's fallback (so the security and audit plugins), the development `.env*` files, and the `telemetry` sibling. A development boot's refusal now names the command with `--dev`, and every refusal now names an apply the ceremony accepts: `--out PLAN` first, then `--apply --plan-file PLAN --backup-taken`. A bare `--apply` is refused without a reviewed plan file. Before, running the ceremony from a shell without `NODE_ENV=development` composed neither plugin, and its apply blocked on the shipped permission sets and on `sys_audit_log`. + + `OrganizationOwnershipBootGuardPlugin` (`@objectstack/runtime`) takes `{ dev }`; `organizationOwnershipRefusalMessage` and `assertOrganizationOwnershipMigrated` take the same option. + +### Patch Changes + +- 5b14b7f: fix(cli, runtime): the 18.0 operator messages say that 18.0 supports fresh `single` installs only + + Clause-②: no + + - **The v18 boot refusal on a v17-shaped database** now says first that 18.0 does not upgrade a v17 database: keep it on a 17.x release until the 18.x release that carries the upgrade path. Before, it named only `os migrate organization-ownership`, which refuses in 18.0 as well, so the operator reached the answer in two hops. + - **The fix list for an unrecognized `OS_TENANCY_POSTURE`** (printed by `os serve` and `os doctor`) still lists `group` and `isolated`, and now says beside each that 18.0 refuses it at boot. + - **`os doctor` reports a walled posture.** Under `group` or `isolated` it prints a `Tenancy posture` warning: 18.0 supports `single` only, so the next `os serve` (and so `os dev` and `os start`) refuses the posture at boot. The row is a warning, so doctor's exit code does not change. Before, doctor printed nothing about a valid posture. + + Nothing else changes. The refusals themselves, their codes and their exit codes are as before. +- 959ee31: **Shipped in 18.0.0-next.5**, whose CHANGELOG missed this entry; listed here one prerelease late. + + fix(cli): `os doctor` tells a tool missing from PATH apart from a tool on PATH that failed when it ran + + Clause-②: no + + - **What was wrong.** `os doctor` runs `pnpm -v`, `tsc -v` and `git --version`, and reported every failure as "Not found". A `pnpm` that is installed but cannot start was reported `✗ pnpm Not found` with `Install pnpm: npm install -g pnpm@10.28.1`. One example is a Corepack-managed `pnpm` that cannot download the version it resolves: offline, behind a proxy, or with the registry answering 429. Reinstalling pnpm does not fix that. + - **What changed.** When a probe fails, doctor checks whether the tool is on the PATH the probe ran with. This is the same lookup the shell does: `/bin/sh` walks `PATH`; on Windows, `cmd.exe` tries each `PATHEXT` extension in the current directory and then in each `PATH` entry. If the tool is not there, the row is unchanged. If it is there, the row's message is the tool's own first error line, such as Corepack's `Error: Server answered with HTTP 429 when performing the request to …`. The fix then matches the cause. For a Corepack download failure, it says to check the network or proxy, or to pin pnpm with a `"packageManager"` field in `package.json`. For any other failure, it says to run the command and read its error. + - **Unchanged.** Each row keeps its status: `pnpm` is `error`, `TypeScript` and `Git` are `warning`. So doctor's exit code does not move. A tool that answers is reported as before. No package export or flag changes, and the report keeps its layout. +- fbafb85: feat(plugin-email,plugin-auth): identity mail — verification, password reset, magic link, the email-change notice, an account invitation — is recorded in `sys_identity_email`, a deployment-level sibling of `sys_email` with no organization column + + Clause-②: yes (widening) + + ADR-0131 D7, ruling of record 6257510973 (letter Q2 A). Expand-first: this release adds the new log and its write path, and keeps reading every row and job an 18.0 install left behind. It moves and deletes nothing. + + - **The new object.** `sys_identity_email` (`@objectstack/platform-objects`, registered by `EmailServicePlugin`) has `sys_email`'s fields, so the row shape is the same, and `systemFields: { tenant: false }`, so it has no organization column. It is append-only, readable over `get` / `list`, and gated by `manage_platform_settings` (like `sys_job`), because its rows carry password-reset and sign-in links. + - **The contract.** `SendEmailInput` and `SendTemplateInput` (`@objectstack/spec/contracts`) gain `identityMail?: boolean`. With `true`, the mail service writes the row to `sys_identity_email`. Without it, the row goes to `sys_email`, as before. `identityMail: true` together with an `organizationId` is refused as `VALIDATION_FAILED` and recorded as a rejected row in `sys_identity_email`. The organization is not written. + - **One pipeline, both logs.** The queue worker, the outbox drain hook, the boot outbox sweep, attachment reclamation and the internal-header readback read a row from the log it lives in. A delivery or reclaim job for a `sys_identity_email` row carries `object: 'sys_identity_email'` beside `rowId`. A job with no `object` reads `sys_email`, which covers every job an 18.0 process queued. A job naming anything else is refused at `error` and touches no row. `EmailPersistence.insert` / `update` / `readHeadersJson` take the log as an optional last argument. A custom implementation that ignores it keeps every row in its one store. `@objectstack/plugin-email` exports `TENANT_EMAIL_OBJECT`, `IDENTITY_EMAIL_OBJECT`, `EMAIL_LOG_OBJECTS`, `isEmailLogObject` and `EmailLogObject`. + - **plugin-auth.** The five identity sends say `identityMail: true` and name no organization, under every posture. Before, they carried the Default Organization under `single` and nothing under a wall. The organization invitation (`auth.invitation`) is tenant mail. It still carries its organization and still lands in `sys_email`. + - **What a reader of `sys_email` sees.** + - **Before.** Identity mail was a `sys_email` row: stamped with the Default Organization under `single`, organization-less under a wall. + - **Now.** New identity mail is a `sys_identity_email` row. Rows already in `sys_email` stay there and are still delivered, swept and reclaimed. + - **Remedy.** A report, export or query that looks for verification or reset mail reads both logs. For example: `ql.find('sys_identity_email', { where: { related_object: 'sys_user', related_id: userId } })` beside the same query on `sys_email`. + - **Inventory.** `os migrate organization-ownership`'s inventory lists `sys_identity_email` as deployment-level (column-drop fate, nothing to attribute). Moving the identity rows already in `sys_email` is an 18.x upgrade-ceremony step, not part of this release. +- f89d29e: fix(cli): `os migrate apply --allow-destructive`'s organization-ownership refusal says what 18.0 supports for a v17 database, and names no step of the command 18.0 refuses + + Clause-②: no + + - **`os migrate apply --allow-destructive` on a v17-shaped database.** When the run would drop `organization_id` from a table the organization-ownership ceremony owns, and the ceremony has not verified on the database, the refusal now says that 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, whose ceremony drops these columns itself. The v18 boot refusal and `os migrate organization-ownership`'s own refusal say the same. Before, it told the operator to run `os migrate organization-ownership --out PLAN` and then `--apply --plan-file PLAN`, which 18.0 refuses on every database (`NOT_IMPLEMENTED`). The `--json` refusal's `detail` no longer names that command either: it reads "No run of the organization-ownership ceremony is recorded on this database.", or names the last unverified run with its date and blocking count. + - **`os migrate organization-ownership --help`** shows what the command answers in 18.0: exit `1` with `NOT_IMPLEMENTED`, nothing read or written, and the same refusal as JSON. Before, it showed six plan and apply examples that 18.0 refuses. + - **`os dev` on a v17-shaped development database.** When it does not upgrade the database in place (the ceremony's plan is refused, or the database is not a local SQLite file), it now ends with the same position: keep a v17 development database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there. Before, it told the developer to run `os migrate organization-ownership --dev` and then the same command with `--apply`. + + Nothing else changes. The refusals still hold back the whole run, and their message code (`refused_organization_ownership_unmigrated`), `tables`, `marker`, exit code `1` and every flag are as before. +- d112087: docs: the suggested-binding acceptance is deployment-level and takes effect + + Clause-②: no + + Text only. These are the published sentences that describe what confirming a package's `isDefault` suggestion does. They now match the behaviour `@objectstack/plugin-security` ships in this release. + + - `@objectstack/spec`: `ISecurityService.listAudienceBindingSuggestions`, `confirmAudienceBindingSuggestion` and `dismissAudienceBindingSuggestion` keep "writes the binding" for the confirm. The text now also says what that means: a `manage_metadata` holder accepts once for the deployment, `everyone`'s definition is derived again, and a dismiss on an accepted suggestion revokes it. The package-level `isDefault` note keeps "an admin confirms in Setup" and adds who that admin is. + - `@objectstack/client`: the `security.suggestedBindings.confirm` and `.dismiss` TSDoc. + - `@objectstack/rest`: the route summaries of the confirm and dismiss routes. Paths and envelopes are unchanged. + - `@objectstack/cli`: the ownership-inventory citation for `sys_audience_binding_suggestion` now says the record is deployment-level and owned by the Default Organization. +- 1eae21f: fix(cli): `os verify --dashboard-windows` describes the measures it proves: counts, sums and derived measures + + Clause-②: no (the flag, its default and what it accepts are unchanged; only its help text moves) + + The `--dashboard-windows` flag description ended "(date and datetime fields; count measures)". The date-window family in `@objectstack/verify` now also proves sums and derived measures over counts or sums, so it reads FROM "(date and datetime fields; count measures)" TO "(date and datetime fields; count, sum and derived measures)". Nothing else about the flag changes. +- 6a06e3b: A flow VALUE slot now refuses the run-user template token `{$User.}` as well, and every CEL expression in a flow sees **`current_user`**, the run's user. `{$User.Id}` in a `create_record` / `update_record` `fields` value or an `assignment` value is refused at `objectstack validate`, at `registerFlow` and by the executor, naming `current_user.id` and, for a flow that can run without a user, its guard; every other `$User` path is refused saying it never resolved. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The template dialect is retired from the value slots, one dialect per slot, with a remedy for every spelling. The run user was the one path spelling kept, because the flow CEL scope bound no user: `current_user.id` passed `objectstack validate` and `registerFlow`, then failed the run with `Unknown variable: current_user`. The scope now binds it, so the token can be refused with a remedy that evaluates. + + **What `current_user` is.** In every flow CEL expression — a start-node `condition`, an edge `condition`, a `decision` condition, a screen field's `visibleWhen`, an `assignment` or `fields` value envelope — `current_user` is the run's user, built through `createEvalUser` from what the run context holds: `id` (the run's `userId`), `positions`, `organizationId` (the run's `tenantId`) and the derived `isPlatformAdmin`. No run carries an email or a name, so neither is bound. A run with no user — a schedule, a record change made by a system write — sees `null`, never a stand-in user: `current_user.id` then fails the run loudly, and `current_user != null` can guard it. A `runAs: 'system'` run sees the user that triggered it, as `{$User.Id}` did. A flow variable named `current_user` is shadowed by the binding, as one named `vars` is by the variables namespace; both are read as `vars["current_user"]` / `vars["vars"]`, and the refusal of a `{vars.…}` or `{current_user.…}` path token prints that spelling. + + **The expression remedy.** A refused `{…}` arithmetic token's CEL spelling now reads every variable path in it the way a lone path token's does — `{int * 2}` is `vars["int"] * 2`, `{items.0 * 2}` is `items[0] * 2` — instead of rewriting only its divisors, which printed envelopes that did not evaluate. + + **Still accepted, unchanged.** A CEL value envelope and every literal. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{$User.Id}'` | `{ dialect: 'cel', source: 'current_user.id' }` | nothing when the run has a user | + | `'{$User.Id}'`, in a flow that can run without a user | `{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }` — or skip the node with `current_user != null` as a start condition or a `decision` | the guarded form writes `null` where the template wrote nothing, so on `update_record` it clears a stored value the template left alone | + | `'{$User.Email}'`, `'{$User.Name}'`, any other `$User` path | a `get_record` on `sys_user` with `filter: { id: { dialect: 'cel', source: 'current_user.id' } }` and `outputVariable: 'me'`, then `{ dialect: 'cel', source: 'me.email' }` | these never resolved in any shipped run: they wrote nothing | + | `'Owner: {$User.Id}'` | `{ dialect: 'cel', source: "'Owner: ' + current_user.id" }` | in a user-less run, write the hole as `(current_user != null ? current_user.id : '')` | + | a text slot's `assignments: { by: '{$User.Id}' }`, then `'By {{ by }}'` | `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `'By {{ by }}'` | nothing when the run has a user; where the flow can run without one, compute `current_user != null ? current_user.id : null`, which renders nothing where the template did | + + **The one-line fix: write `current_user.id` where you wrote `{$User.Id}`, and guard it where the flow can run without a user.** + + **Who is affected, measured.** This repository's two authored value-slot sites are migrated in this change: the `examples/app-todo` quick-add screen flow (a screen flow always has a user, so the bare read) and the `os explain flow` catalog example, an `update_record` under a record-after-create trigger, which a system write fires with no user; it now gates on `current_user != null` in its start condition, so the stored value is left alone there as before. `examples/app-showcase`'s `{$User.Id}`, a `notify` `recipients` entry, is migrated to `current_user` with the `notify` value slots on this line. Other repositories and deployed metadata were not measured here. + + ### The kit + + - **The binding.** `AutomationEngine.celScope` (`@objectstack/service-automation`); `evaluateCondition` and `evaluateValueEnvelope` take the run context as an optional last argument, which every engine site and the `assignment`, `decision`, `create_record` and `update_record` executors pass. Without one, `current_user` is `null`. + - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`); the text-slot judge's run-user remedy (`textSlotTemplateRefusal`) now computes the id with the CEL envelope. + - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the run-user paths), `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` (their run-user remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. + - **`@objectstack/cli`.** `os explain flow`'s example writes `current_user.id` and gates on `current_user != null`. + - **`@objectstack/lint`.** `flow-bare-dollar-reference`'s hint for a bare `$name.path` in a value slot names the CEL envelope the value-slot refusal writes for it — `current_user.id` and its guard for `$User.Id`, the variable the reference names for any other — where it named `{source.id}` and `{$User.Id}`, both refused there. A text slot's hint keeps the `{{ }}` hole, and the hint at a node's literal, or at any other position, names the value itself. + - **`@objectstack/service-automation`'s README.** Its *Expressions* section states the value-slot envelope and `current_user`. +- bafb58b: `os serve` (and `objectstack start`, which runs it) now says at boot when an app's branding logo or favicon will not be served. Before, the runtime assets route was skipped without a word when its directory was absent, so an artifact booted outside its project directory drew a broken logo and favicon and nothing in the boot output said why. + + Clause-②: no + + - Once the boot settles, every loaded app whose `branding.logo` or `branding.favicon` is a root path under `/runtime/assets/` that the route will not serve gets one warning line per file. The line names the apps and keys that use the file, the directory searched, and whether that directory came from `OS_RUNTIME_ASSETS_DIR` or the `assets/` default under the working directory. When the directory does not exist, the line says so and says nothing under `/runtime/assets/` is mounted for this run. + - The apps read are the ones the console is served, through the same metadata protocol read that `GET /api/v1/meta/app` answers from. Config boots and artifact boots are both covered. + - Whether a file is servable is decided by the route's own filename resolution, so the warning and the route cannot disagree. Absolute URLs, protocol-relative URLs, data URIs, relative paths and other root paths are not checked. + - The line goes through the kernel logger at `warn`. It shows in the banner's *Boot diagnostics* block, streams live at `--log-level debug` or `info`, and is hidden at `error` or `silent` like every other boot warning. + - ⛔ What `/runtime/assets/*` serves does not change. No route is added or removed, and the artifact still carries no asset files: ship the `assets/` directory beside it, or point `OS_RUNTIME_ASSETS_DIR` at the files. +- 4935c66: The startup banner prints one line per warning class, each warning appears once, and a localhost boot no longer warns that OAuth is unencrypted. + + Clause-②: no + + - **One line per class.** Flows that declare a trigger but are not bound are grouped by trigger type and reason, with the flows listed: `⚠ 8 flows declare a 'schedule' trigger but are NOT bound — disabled by deployment policy — … (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy), so no time trigger arms …: flow_a, flow_b, …`. Before, the full reason (up to ~650 characters) printed once per flow. The banner now shows the reason's first sentence; `--log-level debug` still streams each flow's full reason. A real binding failure, or a missing trigger, keeps its own line, worded as before. + - **Printed once.** *Boot diagnostics* no longer repeats the automation plugin's per-flow `… is NOT bound` and shadowed-flow warnings, which the banner's `Flows:` list already shows. Its header counts them instead: `(8 more already listed above)`. Every other boot warning replays exactly as before. A boot that fails before the banner still replays all of them. + - **OAuth on loopback.** `OAuth is served UNENCRYPTED: …` is logged at `info` when the issuer's host is loopback (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`), so it is not shown at the default `warn` level. It stays `warn` on a private or link-local issuer. The sentence and the transport rule are unchanged. + - ⛔ Nothing you author changes. Which flows bind, the scheduled-work switch, the transport rule, the service-automation warning an embedded host reads, and every public key, export and parameter are unchanged. +- 54ace18: fix(cli): `os migrate meta --out FILE` writes its snapshot on a range that crosses no step (#22116) + + Clause-②: no + + `os migrate meta --from 18 --to 18 --out FILE` exited 0, printed no snapshot line and wrote no `FILE`. The same command with `--json` wrote `FILE`. The human report returned early on a run with nothing to migrate, and that return came before the `--out` write. An operator or a CI step that keeps `FILE` as the record of the run then found no file, or read an earlier run's file as this one's. + + - The human mode now writes `FILE` and prints the line that names it on every run. On a run with nothing to migrate, the line comes after the range answer, before `--write`'s outcome and the data migrations, the same order as on every other run. + - The bytes are the ones `--json` writes for the same run: the stack the chain returned, which for a range with no step is the stack as loaded. + - The fix sits in the branch both "nothing to migrate" answers share. A range with steps that applies and lists nothing takes the same branch, but no range reaches it on this build, because every major carries semantic notices and the chain lists them all. + - Unchanged: the `--json` mode, `--write`, `--stored` and the chain. A range with steps writes `FILE` exactly as before. +- 5accac4: fix(cli): the "Console dist not found" boot warning names the remedy that works where the server runs (#22155) + + When the Console package the server resolves has no built `dist/`, the boot warning used to say `install @object-ui/console (already built) or run pnpm --filter @object-ui/console build in the objectui workspace`, wherever it ran. The first remedy works nowhere, because the resolver never looks for an installed `@object-ui/console`. The second needs an objectui checkout, which a fresh clone of the framework repository does not have; there the Console is built by a root script. + + The warning now names one remedy. It picks it by asking whether the nearest `package.json` above the resolved console package declares an `objectui:build` script: + + - **In the framework repository** (the root manifest declares it), the warning gives the dist path, says that `pnpm build` does not produce it, and names `pnpm objectui:build` with the directory to run it in. + - **Anywhere else**, such as a project that installed `@objectstack/cli`, the warning gives the dist path and says the Console ships prebuilt in `@objectstack/console`, a dependency of `@objectstack/cli`. The remedy is to reinstall that package at the CLI's version. + + Only the text changes. The Console mounts, or is skipped, exactly as before. +- ea4aa5c: `os dev --help` and `os start --help` name `OS_PORT` for `--port`, and `os init --no-install` names the package manager it resolved in its Next steps. + + Clause-②: no + + - **`os dev --port`.** The help text read `Server port (overrides $PORT)`, though `os dev` reads `OS_PORT` first and `PORT` only as its legacy alias. It now reads `Server port (overrides $OS_PORT; $PORT is the legacy alias)`. Which variables are read, and in what order, is unchanged. + - **`os start --port`.** The same drift: the help text read `Port to listen on (overrides $PORT, default 3000)`, though `os start` reads `OS_PORT` first as well. It now reads `Port to listen on, default 3000 (overrides $OS_PORT; $PORT is the legacy alias)`. The port behaviour is unchanged. + - **`os init --no-install`.** The package manager was resolved only when an install ran, so a `--no-install` run always printed `npm install` and `npx objectstack …`, even with `--package-manager pnpm` or when invoked through pnpm. It is now resolved before the install step, the same way as before (the `--package-manager` flag, then the invoking package manager, then npm), so `os init my-app --no-install --package-manager pnpm` prints `pnpm install` and `pnpm exec objectstack …`. A run with no flag, invoked through npm or `npx`, still prints `npm install`: the scaffold supports npm, yarn and bun as well as pnpm. +- 11e6fc5: *Boot diagnostics* puts the one record you have to act on first, with its fix, counts it apart from the informational ones, and no longer prints stack traces at the default log level. + + Clause-②: no + + - **The record that needs you comes first, with its fix.** A script action declared with no handler (a button wired to nothing) prints first in *Boot diagnostics*, highlighted, naming each action, with its fix on the next line. A blank project with one such action shows: + + ```text + ⚠ Boot diagnostics — 1 needs your attention · 2 informational: + ⚠ [action-governance] declared script actions with NO handler — a button wired to nothing (ADR-0078): support_desk_ticket:resolve_ticket + fix: add a `body`, or register a handler under the declared `target` + ``` + + Every other record prints once, dim, as before, and the header counts it as informational. When nothing needs your attention the header reads `ℹ Boot diagnostics — 4 informational:`. This record class is the only one highlighted so far. + - **No stack traces at the default level.** A record that carries an error's stack trace prints with its message and without the trace. The block's last line says how many traces were withheld. At `--log-level debug` the boot streams live, and the same record prints there with its stack. + - ⛔ Nothing you author changes, and no warning's log level changes. The package that logs a warning sets its level. +- 31cd210: A new project's `pnpm-workspace.yaml` is now a short settings file: one comment line above each block instead of 57 to 66 lines of version history and measurements. `npm create objectstack`, `objectstack init` and a standalone `objectstack create` now write the same 21-line file. + + Clause-②: no + + - The settings are unchanged: `packages: []`, the same `onlyBuiltDependencies` and `allowBuilds` lists, and the same `peerDependencyRules.allowedVersions` entries, in the same order. A project installs exactly as before. + - Each block keeps one line that says why it is there: which pnpm versions read each build-approval key, and that the peer rules only silence a warning that was measured harmless. + - The measurements behind each setting now live in the `@objectstack/cli` source next to the values they explain, so they are kept up to date there instead of in every project. + - Already-scaffolded projects are not touched. Their longer comments can be deleted or kept; the settings are identical. +- b9e6e5c: fix(cli): `os doctor` no longer tells a freshly scaffolded project that it is running in production (#22163) + + Clause-②: no + + - **What changed.** With `NODE_ENV` unset, in a directory whose `objectstack.config.ts` boots as itself, the `NODE_ENV` row now reads `✓ Not set — os dev runs this project as development; os start forces production`, with no fix text. That covers a config alone, and a config beside its own compiled `dist/objectstack.json`, which the first `os dev` writes. Before, the row read `⚠ Not set — this environment is being treated as production` and, under `--verbose`, told the operator to set `NODE_ENV=development`, which `os dev` already sets. + - **What did not change.** In every other directory, the `⚠ … treated as production` warning and its fix text are the same as before. That includes a config beside `OS_ARTIFACT_URL` or beside an `OS_ARTIFACT_PATH` naming another artifact, an artifact with no config, and a directory with neither. `doctor` reads the two variables the way `os dev` does: from the process environment over the `.env*` files loaded for development. + - **Nothing else moves.** The `Environment files` row still reports the `.env*` cascade for `node_env=production`. The `/discovery` `environment` default is unchanged, and so is the way `os serve`, `os start` and `os dev` resolve the mode. A `NODE_ENV` that is set still prints no row, and `doctor`'s exit code is unchanged. +- 83e7ae9: `os build`: a multi-package artifact's flat `src/docs/` is now carried by the package that owns the artifact's manifest, so its boot no longer warns about them + + Clause-②: no + + A multi-package artifact (one that carries `packages[]`, ADR-0130 D4) keeps its metadata in its package bodies only. `os build` still wrote the stack's own flat `src/docs/*.md` to the artifact's top level. At every boot, the metadata service registered them under the artifact's `manifest.id` and logged `carries N top-level metadata item(s) that none of its N package bodies declare`. The warning told the author to rebuild the artifact, which did not help: in an ADR-0130 layout the app package's source directory (for example `src/sales/`) is not named after the package, so no docs directory the build reads belongs to it. + + - **What changes.** When exactly one `packages[]` entry has the artifact's `manifest.id` as its id, `os build` writes the flat docs onto that entry's body, after any docs from that package's own `src//docs/`. The artifact's top level no longer carries them. `composeStacks(…, { manifest: 'preserve' })` always produces this case, because the artifact's `manifest` is one of its inputs' manifests. + - **What stays the same.** The docs are served under the same package id, the doc count is the same, and books resolve the same tree. The doc lint still checks their names against `manifest.namespace`, and the `Collecting package docs` step line prints the same count. + - **What a running instance now reports differently.** These docs are now part of the owning package's installed record (`GET /api/v1/packages`) and are protected like that package's other docs (`lock: 'full'`, resettable), the same as the flat docs of a single-package artifact. Before, they were registered outside any package record and read back as freely editable. + - **When nothing moves.** If no entry has the manifest id, if several do, or if the artifact declares no `manifest.id`, the flat docs stay on the top level as before. A single-package artifact (no `packages[]`) is byte-identical to before. + - **To pick it up.** Rebuild with `os build`. An artifact that was already built keeps its shape, and the warning, until it is rebuilt. +- dc4a5c6: fix(cli): `os serve --no-server` with `OS_MIGRATE_AND_EXIT=1` now creates every table the same config's server boot registers, `sys_import_job` included + + Clause-②: no + + - **What was wrong.** `serve` composed the REST API plugin only with the HTTP server on. That plugin's `init()` registers `sys_import_job`, the object the async-import routes write to, and schema sync creates tables only for the objects registered in that boot. So a migrate-and-exit run with `--no-server` created one table fewer than the server boot it prepares for. A deployment that migrated "kernel only" and then served with schema sync off had no import-job table for the async-import routes to write to. + - **What changes.** `serve` composes the REST API plugin on every boot, as it already composes the storage, settings, sharing and auth plugins. With `--no-server` its object registers. With no HTTP server in the boot, its `start()` mounts no route and prints one `warn` that the server is absent, as those plugins do. On `examples/app-todo` the `--no-server` run now creates 70 tables, the same set as the server boot. + - **What does not change.** `--no-server` still adds no HTTP server plugin and no dispatcher. A server boot composes the same plugins in the same order as before. The no-auth boot refusal still applies only with the server on. + - **One composition behaves differently.** A config that puts its own HTTP server plugin in `plugins` and runs `--no-server` now gets the REST routes on that server, as it already got the settings and auth routes. Anonymous data access stays denied there: an unauthenticated `GET /api/v1/data/OBJECT` answers `401`. + - **If you worked around it** by dropping `--no-server` from your migration step, either form now provisions the same schema. +- 9f0de32: fix(cli): `os migrate meta --from N --write` rewrites the declared protocol range the load refuses (#22219) + + Clause-②: no + + The load refuses a manifest whose `engines.protocol` excludes the runtime's protocol major, and the refusal names `objectstack migrate meta --from N` as the command that resolves it. `--write` wrote the chain's mechanical changes and left `engines: { protocol: '^N' }` as it was, so an author who followed the refusal, wrote, and reloaded got the same refusal back. A manifest with nothing to convert was not written at all. + + - `--write` now also rewrites the declared range when the load would still refuse the migrated source under it, in the scaffold's spelling: `'^16'` → `'^17'` on a protocol-17 runtime. It rides the same plan, write and re-check as the chain's changes: a range literal the command cannot trace to one site is left with the reason, and a re-check that disagrees restores it with every other file. + - A manifest with nothing to convert gets the range edit alone. + - The major is `--to`, capped at the protocol this runtime implements. `--to` defaults to the highest major this build carries a step for, which can run ahead of the runtime: on a protocol-17 build, `--from 16` replays 16 → 18 and writes `'^17'`. + - The range is rewritten under the key the load read it from: `engines.protocol`, else `engines.platform`, else the legacy `engine.objectstack` (written as `'^17.0.0'`, the only form that key accepts). + - Left alone: a range the load already admits, an absent or unrecognised range, a range at or above the target major, and a range below `--from` (the report names the `--from` that moves it). + - The dry run (no `--write`) names the range edit `--write` would make. With `--json`, `write.range` reports the edit: `status`, `path`, `from`, `to`, and `file` / `line` when written or `kind` / `reason` when left. +- 86ae119: fix(cli): with no built Console, `/_console/` says the Console is not built and names the remedy, instead of answering a bare 404 (#22225) + + Clause-②: no + + When the Console package `os serve` resolves has no built `dist/`, the server printed the "Console dist not found" boot warning and mounted nothing. `GET /_console/` then answered the router's `ENDPOINT_NOT_FOUND` and `GET /` an empty 404, which look exactly like a wrong URL. + + Now, on that boot, the server mounts the Console's not-built answer on the routes the Console would own: + + - **`GET /_console/` and every path under it** answer `503` with a short plain-text body. It says the Console is not built, then gives the remedy the boot warning names: `pnpm objectui:build` in a framework checkout, or reinstalling `@objectstack/cli`'s `@objectstack/console` dependency elsewhere. The same function picks the remedy for both. The HTTP form leaves out the absolute paths on the host, which only the terminal shows. + - **`GET /` and `GET /_console`** redirect to `/_console/`, as they do when the Console is built. + + A built Console is served exactly as before, and `--no-ui` / `--no-console` still mount nothing. The `--ui` flag's help text and the README row now name `@objectstack/console`, the package the resolver reads, instead of `@object-ui/console`. +- a55fdc4: The Console served at `/_console/` now tells the browser how long it may keep each file, so a self-hosted console load stops downloading the whole SPA again on every visit. + + Clause-②: no + + - **Content-hashed build outputs** (a file directly under `assets/` named `name-hash.ext`, the hash being Vite's 8-character one) are sent with `cache-control: public, max-age=31536000, immutable`. A rebuild that changes the bytes emits a new name, so the browser never needs to ask again. Before this change, every non-HTML file was sent with a `content-type` and nothing else, so the browser could reuse none of the roughly 2,450 chunks. + - **Every other non-HTML file** gets `cache-control: public, max-age=300` and an `etag` computed from the file's bytes, and a matching `If-None-Match` is answered with `304` and no body. This covers the two stable-named maplibre files objectui copies into `assets/` (`maplibre-gl-worker.mjs`, `maplibre-gl-shared.mjs`) and the root files such as `favicon.svg` and `manifest.json`. These names can carry different bytes in the next console release, so they are never marked `immutable`. The `etag` is computed from the bytes, not the file's modification time, because a console dist extracted with `tar` gives every file the same fixed mtime. + - **HTML is unchanged**: the shell, a direct `index.html` and the SPA fallback are still rewritten per request and carry no lifetime. +- 6d2da32: `os validate`, `os build`, `os lint` and `os i18n check` now judge a multi-package app by its package bodies. A multi-package app that passed with a required capability that has no installable provider in this edition, or with a missing translation, now fails, as the same app shipped as one package always did. + + Clause-②: no + + An app composed with `composeStacks([a, b], { manifest: 'preserve' })` carries its metadata, its `translations` and its `requires` only inside each package's body, and nothing a package owns at its top level. These readers looked at the top level alone, so on such an app they found nothing to judge and answered clean: + + - **The capability preflight** (`os validate`, `os build`) now reads each package body's `requires` when the top level declares none. A capability with no installable provider in this edition fails the run with exit 1, and each finding names its package: `package 'com.acme.service' — Capability "ai" resolves to …`. The advisory for an installable but absent provider (for example `hierarchy-security`) comes back the same way. A top-level `requires` is read exactly as before, so a single-package app prints the same text it always did. + - **Translation coverage** (`os lint`, `os i18n check`) now expects the keys of every package, so a missing translation in any package is reported. Under `os lint --strict` it fails the run again. + - **`os i18n extract`** now extracts every package's keys, where it extracted none. + - **The undeclared-authoring-key warnings** that `os validate --json` and `os build --json` carry in `warnings` now cover items inside package bodies too. + + A `packages` value that is present but is not an array is now refused by `os validate`, `os i18n check` and `os i18n extract` with `INVALID_ARTIFACT_PACKAGES`, as the other readers already refuse it. `os i18n check` and `os i18n extract` used to accept such a config with exit 0 and zero keys; `os validate` refused it from the schema parse instead. + + Apart from that refusal, no accepted input, exported symbol or `--json` field changes. A single-package app's output is unchanged. +- 3054516: fix(cli): in the source checkout `os dev` serves, `os doctor`'s `Environment files` row names `os serve` / `os start` as the loaders of the `node_env=production` cascade it reports (#22249) + + Clause-②: no + + - **What changed.** With an `objectstack.config.ts` that boots as itself (alone, or beside its own compiled `dist/objectstack.json`), the `Environment files` row now says whose `.env*` cascade it resolved. A freshly scaffolded project with no `.env*` file reads ``✓ Environment files No .env* files here (node_env=production, the cascade `os serve` / `os start` load) — environment read from this process only — no environment input set``. When files are loaded, the row reads ``.env, … (node_env=production), the cascade `os serve` / `os start` load`` where it used to end ``the cascade `os serve` loads``. Before, the no-file form named no loader at all. That left `node_env=production` sitting directly above the `NODE_ENV` row that says `os dev` runs the project as development. + - **What did not change.** Doctor still resolves the cascade exactly as `os serve` does, for `NODE_ENV || 'production'`. So it reads the same `.env*` files and every env-derived verdict, the tenancy-posture refusal among them, is unchanged in every posture. Outside the source checkout, the row is unchanged byte for byte. That covers an artifact-only project, a config beside `OS_ARTIFACT_URL` or beside an `OS_ARTIFACT_PATH` naming another artifact, and a directory with neither. Doctor's exit code is unchanged. +- abd2545: The startup banner's `Flows:` section prints flows left unbound by the deployment's scheduled-work switch as information, not as a warning. + + Clause-②: no + + - **Information, not a warning.** Flows that declare a time trigger but are not bound because package-authored scheduled work is off on this deployment (`OS_AUTOMATION_SCHEDULED_WORK_ENABLED` unset — the default) now print dim, under `ℹ`: `ℹ 8 flows declare a 'schedule' trigger but are NOT bound — disabled by deployment policy — … (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy), so no time trigger arms …: flow_a, flow_b, …`. The line's text, its flow list and its place among the other lines are unchanged. Before, it printed as a yellow `⚠`, though nothing about those flows needs fixing. + - **Every other reason keeps `⚠`.** A binding failure, a missing trigger, an unknown target object and a shadowed flow name still print as yellow warnings. The class is recognised only by the exact sentence the automation engine records for the deployment switch, so a reason that merely mentions the policy stays a warning. A host that turns scheduled work off with its own reason sentence also keeps `⚠`. + - ⛔ Nothing you author changes. Which flows bind, the scheduled-work switch, the level `@objectstack/service-automation` logs at, *Boot diagnostics* and every public key, export and parameter are unchanged. +- d8d0711: fix(cli): `os migrate meta` lists and writes a conversion the load already applies inside a composed project's package body (#22256) + + Clause-②: no + + - **What was wrong.** In a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, each package body is assembled from the stack its input's `defineStack` call returns. That call runs the load-time ADR-0087 conversions, both when the schema accepts the input and when `os migrate meta` produces a refused input again in `strict: false` mode. So a spelling the load still converts inside a package body, such as `datasources[].driver: 'mongo'`, was never listed in `applied`, and `--write` never wrote it. Every later load kept printing `converted at load … Update the source to the canonical shape`, and the run exited 0. + - **What changed.** `os migrate meta` now produces every input of the composition again from what its author wrote, with the load-time conversions skipped, before the inputs are composed. Composition then assembles each package body from the authored source. The conversion is listed under the body's path (for example `packages[0].manifest.datasources[0].driver`), `--write` writes it into the file that authored that input, and the next load converts nothing. This holds whether the input's `defineStack` call accepted its argument or was refused. + - **`@objectstack/spec`.** `defineStack` reads one internal parameter for this, which only the CLI passes: an undeclared, symbol-keyed entry on its options, honoured only together with `strict: false`, that skips the conversion pass. It is not part of `DefineStackOptions` and nothing new is exported. A strict call ignores it, and every call without it behaves exactly as before. + - **What did not change.** A project with nothing to convert gives the same `--json` summary and the same `--write` result and written bytes as before. This was measured on the four example apps, one of them composed, at `--from 16` and `--from 17`. Every other command still reads the stacks `defineStack` builds and the composition they make. + - **The `--out` snapshot of a composed project.** Each package body in it is now the body as written, with the chain's changes applied. That is what the snapshot of a one-package stack already is. It no longer carries values the schema's parse fills in and the source never wrote, such as default keys. +- 1cb0edb: fix(cli): `os migrate meta` lists and writes a conversion the load already applies, on a stack the current schema accepts (#22256) + + Clause-②: no + + - **What was wrong.** When the current schema accepts a stack, `defineStack` runs its load-time ADR-0087 conversions while the config loads. `os migrate meta` then replayed its chain over a stack that was already converted. So a spelling the load still converts, such as `datasources[].driver: 'mongo'`, was never listed in `applied`, and `--write` never wrote it. Every later load kept printing `converted at load … Update the source to the canonical shape`, the notice that sends the author to this command, and the run exited 0. + - **What changed.** The chain now starts from the argument the stack's `defineStack` call was given. It already did this for a stack the schema refuses. The conversion is listed, `--write` writes it into the source, and the next load converts nothing. + - **What did not change.** A stack with nothing to convert gives the same `--json` summary and the same `--write` result as before. This was measured on the four example apps at `--from 16` and `--from 17`. Every other command still reads the stack `defineStack` builds. + - **The `--out` snapshot.** It is now the stack as written, with the chain's changes applied. That is what it already was for a stack the schema refuses. It no longer carries values the schema's parse fills in and the source never wrote, such as default keys and actions merged into their objects. +- 28bff18: The CLI reads a multi-package app's package-owned keys (`requires`, `tiers`, `analyticsCubes`, `flows`, `objects` and the rest) out of its package bodies, as it reads a one-package app's top level. + + Clause-②: no + + A `composeStacks([…], { manifest: 'preserve' })` config carries each of these keys only inside the body of the package that declared it. Its top level carries none of them. These readers looked at the top level only, so they read nothing on such an app, and did so silently: + + - **`os serve` capability providers.** A config boot with no compiled artifact did not mount the provider a package declares in `requires` (`automation`, say). The boot logged `Optional service not present` and the features that need the provider were absent. `os dev`, `os start`, and `os serve` beside a built `dist/objectstack.json` already mounted it, because they boot the artifact. + - **`os serve` tiers.** A package's own `tiers` were ignored on `os serve`, `os dev` and `os start`. The preset's tiers were used instead. + - **`os serve` analytics cubes.** The always-on analytics provider started with none of the cubes a package declares in `analyticsCubes`, on every boot of such an app. + - **`os serve` flow line.** The ready banner's line `N flow(s) declared but the automation engine is not enabled` never counted a package's flows, so it did not appear. + - **`os migrate plan` / `apply`.** A host plugin that hard-depends on a provider a package's `requires` supplies could not be ordered. Both commands exited 1 with `Dependency '…' not found for plugin '…'`. + - **`os generate`.** After writing a flow, it warned that the config does not require `automation` and `triggers` even when a package declares both. `os generate types`, `client` and `migration` emitted none of a package's objects. + - **`os doctor`, `os diff`, `os migrate meta`.** `os doctor` skipped every metadata check and reported a healthy environment. `os diff` reported no change for an object added to a package. `os migrate meta` did not list the data migration a package's file fields need. + + Each now reads by one rule: the top-level value when the stack carries one, otherwise every package body's. This is the rule `os validate` and `os build` already use for `requires`. A stack with no `packages[]` reads exactly as before. + + **Two behaviours narrow.** In each, a two-package app is now refused where the same declarations in one `defineStack` were always refused: + + - A capability a package declares in `requires` with no installed provider (`requires: ['ai']` in the open edition, say) now stops an `os serve` boot of the config, with the same `Capability "…"` error. `os dev` and `os start` already stopped there. + - A package's `tiers` now apply. A package that declares `tiers` without `auth` gets a stack with no auth, which `os serve` refuses to boot with its data API, as it always has for one package. To keep the preset's tiers, remove `tiers` from the package, or list every tier it needs. +- 59fb299: fix(cli): `os migrate meta` runs on a project built with `composeStacks`, and converts the retired spellings inside its package bodies (#22289) + + Clause-②: no + + - **What changed.** On a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, `os migrate meta --from N` used to exit 1 with `STACK_PROVENANCE_MISSING`, telling the author to wrap each input in `defineStack`, as soon as any input carried a spelling the current schema refuses. Every input already was wrapped. The command now loads such a project: an input whose `defineStack` call the current schema refuses is produced again by `defineStack(input, { strict: false })` with the load-time conversions skipped (#22256) before it is handed to `composeStacks`, and composition runs as it does at build time. + - **The package bodies are migrated.** A composed artifact keeps each definition under the package that owns it (`packages[i].manifest`), and the migration chain used to reach only the stack's own top level. It now also runs over each package body, and lists each change under the body's path, such as `packages[0].manifest.objects[0].fields.starts_at.defaultValue`. `--write` writes a change in package i into the file that authored input i only when input i and every input before it in the `composeStacks([…])` list is a stack literal (a `defineStack({ … })` call on an object literal, written in place or reached through a `const` or an import) that writes its own `manifest` and no `packages`. Only then is package i that one input's body. Otherwise `--write` lists the change with the reason, as it does for any site it cannot trace. + - **What did not change.** A one-package project loads, migrates and writes exactly as before. An input that was never built by `defineStack` (a plain object, or a spread of a built stack) is still refused with `STACK_PROVENANCE_MISSING`. +- 7098aca: `bootStack` mounts the always-on capability slate `objectstack serve` mounts for every app, and builds each provider from the app's configuration the way `serve` builds it + + Clause-②: yes (narrowing: a configuration whose mail or SMS settings, or the OS_EMAIL_* / OS_SMS_* environment, name a transport that cannot deliver, now fails bootStack where it booted; widening: bootStack mounts the always-on slate objectstack serve mounts for every app and hands each provider the app's configuration, so the app's analytics cubes now reach the registry, where it mounted fewer and built them with defaults) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — the composition `serve` mounts, built the way `serve` builds it.** For one configuration, `bootStack(config, opts)` already mounted the providers the app's `requires` names and the plugins in its own `plugins` array. It now also: + + - **Mounts the always-on slate** `serve` mounts for every app, whether or not `requires` names it: `queue`, `job`, `cache`, `settings`, `email`, `storage`, `sms`, `sharing`, `messaging`, `analytics` and `package-registry`. Before, a slate provider was mounted only where a mounted plugin hard-depended on it, so an app's tests ran without the inbox delivery, the mail service, the file storage and the package registry its users' server has. + - **Builds each provider from the app's configuration**, by the same rule `serve` reads: the analytics service gets the app's `analyticsCubes` (the top level, then each package body's), the email service the app's `email` block with `OS_EMAIL_*` over it, the SMS service the `sms` block with `OS_SMS_*` over it, and the storage service the `OS_STORAGE_LOCAL_ROOT` root (`.objectstack/data/uploads` under the working directory by default). Before, each was built with its own defaults: no cubes, no mail configuration. So the app's cubes now reach the analytics registry; a cube the service refuses (one over an object the API does not serve) is warned and skipped by the service, under `bootStack` as under `serve`, and no boot fails on it. + - A caller's `extraPlugins`, `security` and `analytics` instances still take precedence by identity, and are not handed the configuration. A suite that needs a provider configured its own way (a temporary storage root, a mail transport) passes its instance in `extraPlugins`. + - Not mounted, because they are decisions about a server process rather than about the configuration: `serve`'s MCP endpoint (`OS_MCP_SERVER_ENABLED`) and its pinyin search (`OS_SEARCH_PINYIN_ENABLED`). A suite that exercises either passes the provider in `extraPlugins`. + + **What now fails that booted before (the narrowing).** A provider `bootStack` constructs and cannot build fails the boot, naming the capability token and the package — whether the app declared the token or the slate appended it. `serve` logs a slate provider it cannot build and boots on; a test boot does not, so an app's tests never pass on a composition its server does not run. In practice: + + - a configuration whose `email` or `sms` settings, or the `OS_EMAIL_*` / `OS_SMS_*` environment of the test process, name a transport that cannot deliver (`provider: 'smtp'` with no host, `resend` / `postmark` with no API key, an unknown provider tag) is refused with the reader's own remedy. Set `OS_EMAIL_PROVIDER=log` / `OS_SMS_PROVIDER=log` in the test environment if it is not meant to send mail or SMS. + + **`@objectstack/core`** exports the rule both boots read: `resolveServedCapabilities` (given the tokens a stack declares, which capability tokens a served boot mounts providers for — those, `email` for a declared `auth`, the host's own defaults, the always-on slate unless the preset is `minimal`, and `job` / `queue` ahead of what schedules background work) and `resolveCapabilityArgument` (what each provider is constructed with), with their `ServedCapabilities`, `CapabilityArgumentInput` and `CapabilityArgument` types; and `resolveStorageCapabilityArg`, `resolveStorageLocalRootEnv` and `StorageCapabilityArg`, moved here from the CLI. + + **`@objectstack/plugin-email`** exports `resolveEmailCapabilityArg`, `resolveDeploymentAppName` and `EmailCapabilityArg`, and **`@objectstack/service-sms`** exports `resolveSmsCapabilityArg` and `SmsCapabilityArg` — the readers that build each provider from the deployment's configuration, moved from the CLI to sit beside the transport vocabulary they refuse against. `resolveCapabilityArgument` reads each off the provider module it is handed. + + **`@objectstack/cli`**: `serve` reads both rules from `@objectstack/core` and composes exactly what it composed before. `os migrate plan` / `apply`'s declaration boot reads the same token rule for the providers it composes (the same set as before; a declared `auth` now places `email` right after the declared tokens, as `serve` does). `os verify` boots through `bootStack`, so it now mounts the always-on slate and builds the providers from the app's configuration. The moved functions stay importable from the `serve` command module. +- 97610a5: `bootStack` composes what `objectstack serve` composes from the same configuration: the providers the app's `requires` names, and the plugins in the app's own `plugins` array + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — one composition rule.** For one configuration, `bootStack(config, opts)` now mounts what `objectstack serve` mounts from it, so an app's tests boot the composition its users get: + + - **The providers the app's `requires` names**, by `serve`'s own reader and table (top-level `requires`, otherwise each package body's), plus the always-on providers a mounted plugin hard-depends on. An app no longer lists them in `extraPlugins` by hand. + - **The plugins in the app's own `plugins` array**, by `serve`'s rule for an entry: an instance is mounted as written, a plain bundle is wrapped into `AppPlugin`, a package name is loaded from the app's root (`hostRoot`). + - **The caller wins by identity.** An `extraPlugins` instance takes precedence over a `requires` provider it is (exact `name` or class name) and over an app plugin with the same `name`; that app plugin is not mounted. `security` and `analytics` instances take precedence over an app plugin of the same `name` the same way. + - **`hostRoot` is the app's root** in the two places `serve` uses the config's directory: the automation service's `packageRoot` (where a declarative connector's package-relative file ref is read) — now also when `automation: true` asks for the service — and the root a string `plugins` entry is resolved from. A suite that does not run from the app's directory passes it. + - **Offline CI.** An app whose `plugins` array wires the marketplace-facing `@objectstack/cloud-connection` plugins gets them mounted, pointed at `OS_CLOUD_URL` (by default the public catalog). Set `OS_CLOUD_URL=off` in the test environment, before the configuration module is imported, to keep the suite offline. + + **What now fails that booted before (the narrowing).** + + - **A second live boot of the same configuration object is refused** with `code: 'RESOURCE_CONFLICT'`, `status: 409`, and so is a copy (`{ ...config }`) that carries an app-plugin instance a live boot mounted: the instances in a `plugins` array are module-level, and two kernels must not share them. Live means until `stop()` resolves. Remedy: `stop()` the first stack before booting again; or share one boot with `bootStackOnce(config, opts)`; or, to keep two stacks of one app live at once, boot the second on a configuration built again (call its builder once more, or import a fresh module instance of it). A `{ ...config }` spread is not a configuration of its own: a live boot keeps references into the configuration's nested definitions. + - **An app `plugins` entry that cannot be loaded or registered fails the boot**, naming the entry (`plugins[i]`) and its remedy. `serve` logs such an entry and boots on; a test boot does not, so a plugin the app declares is never silently absent from its tests. + - **A provider or app plugin that refuses to start fails the boot** where the fixed plugin set never mounted it — for example a declarative connector whose package-relative file ref does not resolve from `hostRoot`. + + **`@objectstack/core`** exports the pieces both boots read: `CAPABILITY_PROVIDERS`, `CapabilitySpec`, `CapabilityIdentities` and `providesCapability` (the `requires` token → provider table and its exact identity match); `stackDeclaredCapabilities`, `resolveStackCollection`, `declaredPackageEntries`, `stackPackageBodies` and `collectFromPackageBodies` (the package-owned collection reader); and `materializeStackPlugin` with `StackPluginLoaders` (what a `plugins` entry becomes). + + **`@objectstack/cli`**: `os verify` boots the app anchored at the directory holding its config (`hostRoot`), as `serve` anchors it, so `os verify --app path/to/objectstack.config.ts` run from another directory reads the app's package-relative files (a declarative connector's spec, a string `plugins` entry) and resolves `--multi-tenant`'s organizations package from the app, not from the working directory. `serve` itself does not change: `Serve.CAPABILITY_PROVIDERS` and `Serve.providesCapability` are handles over the `@objectstack/core` declarations, the stack-collection readers are re-exported from there, and `serve`'s `plugins` loop reads the entry rule from there. +- 26bf56f: `os serve `: a multi-package config now serves its flat `src/docs/` pages, under the package that owns the stack's manifest + + Clause-②: no + + A config boot (`os serve objectstack.config.ts`, with or without `--dev`, and no compiled artifact) of a stack that carries `packages[]`, such as one `composeStacks([…], { manifest: 'preserve' })` returns, did not serve the stack's own `src/docs/*.md` pages. The boot put them on the config's top level, and a config boot registers that config's package bodies, never its top level. `GET /api/v1/meta/doc` did not list them, and nothing warned. `os build` of the same project, booted as an artifact, served them. + + - **What changes.** The config boot places the flat pages where `os build` places them: on the body of the one `packages[]` entry whose id is the stack's `manifest.id`, after that package's own `src//docs/` pages. `GET /api/v1/meta/doc` now lists them under that package, as an artifact boot of the same project does. + - **What stays the same.** A single-package config (no `packages[]`) serves its flat pages under its one package, as before. Each package's own `src//docs/` pages are served under that package, as before. Artifact boots (`os dev`, `os start`, `os serve` with `dist/objectstack.json`) do not change. + - **When nothing moves.** If no entry has the manifest id, if several do, or if the stack declares no `manifest.id`, the flat pages stay on the top level, as `os build` keeps them. A config boot of such a stack still does not list them. + - **A name shared by an inline doc and a flat page.** `os build` refuses this (`docs/duplicate-name`); a config boot does not lint docs. On a single-package config the flat page still replaces the inline doc of the same name. On a multi-package config the flat page is now served under the owning package, and an inline doc on that package's body with the same name is served with the flat page's content. +- 4638625: fix(cli): `os dev` prints the server's ready banner whole, then the MCP connect block whole, instead of interleaving the two (#22410) + + Clause-②: no + + - **What a terminal showed.** `os dev` is two processes writing one terminal. The parent prints the MCP connect block (`🤖 MCP server — connect a coding agent:` with its Endpoint, Skill, Connect and Disable rows) when the `serve` child sends `objectstack:listening`, and the child sent that message before it printed its ready banner. Measured under a pty on the Build-with-Claude-Code tutorial project, 6 of 7 boots printed the block above or inside the banner. In 2 of them, banner lines landed between the block's rows, in one case the `➜ Console:` and `➜ MCP:` rows. A setup boot before those seven printed the `🔑 Dev admin` credential lines and the whole plugin section between `Skill` and `Connect`. + - **What changed.** The child now sends `objectstack:listening` after its ready banner and boot diagnostics have printed. The parent prints the block on that message, so it always comes last, under the banner's `Press Ctrl+C to stop` row. The order follows from the sequence itself, with no timer: the banner is written to the terminal before the message is sent. After the change, every measured boot printed the banner whole and then the block whole. + - **What did not change.** The message is still `{ type: 'objectstack:listening', port, url }`, carrying the port actually bound. The runtime state file is still written before either announcement, and `objectstack:seed-settled` still follows `objectstack:listening`. A program that spawns `os serve` with an IPC channel receives `objectstack:listening` slightly later than before: after the banner has printed instead of before. +- d6c3791: fix(cli): a dev server that refuses a drifted Console answers `503` at `/_console/` naming the rebuild, instead of a bare `404` (#22420) + + Clause-②: no + + A refused Console mount answers `503` naming the remedy instead of the router's bare `404` on the same routes, as the not-built position already does. No accepted input, key or export changes. + + `os dev` refuses to mount a Console whose built `dist/.objectui-sha` differs from the repository's `.objectui-sha` pin. The terminal printed the refusal, but nothing was mounted, so `GET /_console/` and `GET /` answered `404 ENDPOINT_NOT_FOUND`, the same answer as a wrong URL. That boot now mounts the same routes as the not-built answer: + + - `GET /_console/` and every path under it answer `503` (`text/plain`, `no-store`). The body is the refusal's own text: both objectui SHAs, why the stale build is refused, `pnpm objectui:build`, and the `OS_ALLOW_CONSOLE_DRIFT=1` escape hatch. The pin file is named without its absolute path. + - `GET /` and `GET /_console` redirect to `/_console/`, as they do when the Console is served. + + The same answer now covers an install where `@objectstack/console` does not resolve at all. That boot used to print nothing and mount nothing. It now prints ``Console package `@objectstack/console` not found``, followed by the reinstall sentence an install with no `dist/` already gets. `/_console/` answers `503` saying the Console is not installed. + + `--no-ui` and `--no-console` still mount nothing, so those routes answer `404`. +- d2e7d6c: fix(cli): a hook that destructures a renamed key off `ctx` is no longer refused as not-lowerable (#22459) + + Clause-②: no + + - **What changed.** The free-identifier scan behind `objectstack lint`'s `hook-body/not-lowerable` rule and `objectstack build`'s hook lowering counted a renamed destructuring key as a free identifier. For `const { event, input, previous: prev } = ctx`, it reported `previous`, a property read off `ctx`, although the only name the pattern binds is `prev`. So `objectstack lint` failed the hook with the advice "reach them through `ctx`", which the hook already did, and `objectstack build` shipped it as a bundled closure instead of a metadata-only body. A non-computed destructuring key is now a key, as the key of `{ key: value }` already was. Nested patterns (`{ a: { b: c } } = ctx`), patterns inside array patterns, the parameters of a callback inside the handler, and `catch` clauses are covered by the same rule. + - **What is still refused.** A destructuring default or a computed key that names something out of scope is still free: `const { key: alias = FALLBACK } = ctx` and `const { [KEY]: v } = ctx` report `FALLBACK` and `KEY`, as before. A genuinely free `previous` that is not read off `ctx` is still refused. + - **What to do.** Nothing. A hook written this way now lowers to a body on the next `objectstack build`, and `objectstack lint` stops reporting it. The un-renamed spelling (`const prev = ctx.previous`) keeps working as before. +- 86f53a4: fix(cli): `os migrate plan` / `apply` / `unmapped-columns` examine what `os serve` mounts around the stack, so the retired `sys_account.issuer` is finally a drop (#22506) + + `os migrate plan` and `os migrate apply` only examine the objects their own boot registers, and that boot stopped at the stack: its config, its metadata and the platform floor. Nothing `os serve` mounts around a stack was there. On an app declaring `requires: ['auth']`, the plan examined 9 of the 68 tables the serving boot had created. `sys_account` read as an undeclared platform table, so its retired `issuer` column (and that column's unique index) was never a destructive drop. `os migrate apply --allow-destructive` dropped nothing, while the boot's drift line and `os migrate account-issuer` kept prescribing exactly that command. + + The two commands now also compose, each through the rule `os serve` itself reads, and so does `os migrate unmapped-columns`, which reads the plan's own `unmapped_column` findings and therefore needs the plan's object set: + + - the auth family behind `serve`'s auth gate: plugin-auth's identity objects, the security plugin and the audit plugin, when the stack mounts no `AuthPlugin` of its own, the `auth` tier is on, and an auth secret resolves; + - the provider of every capability `serve` mounts: the stack's `requires` and the always-on slate, as `os serve` composes it with no `--preset`; + - the REST API plugin; + - every `plugins` entry by `serve`'s rule for one: a package name is loaded from the app, and a plain bundle is wrapped as an app; + - the pinyin-search decision from the config's locales, so the `__search` companion columns a boot provisions are no longer listed as destructive drops on a config-only project. + + `os migrate unmapped-columns --object sys_account` (or any platform object the plan now declares) resolves the object and reads its unmapped columns, where it used to answer `OBJECT_NOT_FOUND`. + + Each piece is composed for its declarations only. Its `init()` runs, and its `start()` and lifecycle hooks do not, so no dispatcher, scheduler or seed runs inside a dry run. + + **What an operator sees.** On the shape that looped, `os migrate plan` lists `sys_account.issuer` and `uniq_sys_account_issuer_account_id` as destructive drops. `os migrate apply --allow-destructive` drops both, `os migrate account-issuer` reads zero, and the next boot prints no drift line for them. When no auth secret is set and the boot is not a development one, the plan says it did not compose the auth family, and how to compose it: run with the deployment's `OS_AUTH_SECRET` exported. `os migrate` reads no `.env` file. + + **driver-sql.** The deferred-DDL preview answers a rotation-declared object (`lifecycle.storage.strategy: 'rotation'`, such as `sys_activity`) from the rotator's facts: when the current shard and the read view exist, nothing is pending. It used to list `create_table` for such an object on every plan, and `apply` then created nothing. + + **Known limit.** A development boot, or one with `OS_TELEMETRY_DB` set, keeps lifecycle-classed objects in a sibling `telemetry` database. The one-shot migration boot does not provision that database, so it examines those objects against the primary one. +- 0fea05f: `os serve `: a multi-package config now serves the top-level metadata no package declares, under the stack's `manifest.id`, and the boot says so, as an artifact boot of the same project already did + + Clause-②: yes + + A config boot (`os serve objectstack.config.ts`, with or without `--dev`, and no compiled artifact) of a stack that carries `packages[]` registers each package body and never the stack's top level. A top-level item that no package body declares was served by no door, and the boot said nothing. Three shapes reached it: flat `src/docs/*.md` pages when the stack's `manifest.id` names no `packages[]` entry, inline `docs` spread onto a composed stack's top level, and any other top-level collection (objects, views, …) that no package carries. `os build` of the same project, booted as an artifact, registered those items under the stack's `manifest.id` and warned. + + - **What changes.** The config boot now runs the same residual rule as the artifact boot, `MetadataPlugin.registerUnclaimedTopLevel`. Every top-level item no package body declares is registered under the stack's `manifest.id`, and the boot prints one `warn` line with the count: `config stack '' carries N top-level metadata item(s) that none of its M package bodies declare`, followed by the remedy. `GET /api/v1/meta/` lists those items under that id, as on an artifact boot of the same project. This includes the flat `src/docs/` pages of a stack whose `manifest.id` names no package, which a config boot did not list before. This entry supersedes, in the same release, the note in the `os serve ` flat `src/docs/` entry that "A config boot of such a stack still does not list them": it now does, under the stack's `manifest.id`, with the warning. + - **A new refusal, in one corner.** A residual view container is judged like any other: a top-level `defineView` container that no package declares, and whose own `name` is not the object it binds to, now refuses the config boot with ``Invalid `views:` container from manifest '': the container's own `name` is …``. On the previous release that config booted, because its top level was never read; an artifact boot of the same project already refused it. `os validate` and `os build` refuse the same container first, in the same words, so the refusal is never a boot-time surprise. A divergent container that a package body declares and the top level repeats is refused once, as that package's. **The one-line fix:** drop the container's `name`, or set it to the object it binds to. + - **The remedy the line names.** Declare each such item inside the `packages[]` entry of the package that owns it. A flat `src/docs/` page moves under `src//docs/`, where `` is the package id or its last dot-separated segment. + - **What widens, in `@objectstack/metadata`.** Its root entry now exports `unclaimedTopLevel(stack)`, the pure residual decision both boot doors and `os validate` read, with its two types `UnclaimedTopLevel` and `UnclaimedTopLevelItem`, and `MetadataPlugin` gains the public static `registerUnclaimedTopLevel(sink, stack, source)`, the one residual rule both boot doors call. + - **What stays the same.** An artifact boot registers the same items and prints the same line, byte for byte. A single-package config (no `packages[]`) has no residual and is unchanged. A composed stack whose top level repeats its package bodies, as `composeStacks([…], { manifest: 'preserve' })` writes it, registers nothing extra and prints nothing. + - **What the residual is, on both doors.** It is metadata: a residual object is listed on `GET /api/v1/meta/object` under the stack's `manifest.id`, and `GET /api/v1/data/` still answers `404`. That was already true on an artifact boot, and the config boot now gives the same answer. +- fd2768a: fix(cli): `os migrate plan` / `apply` plan each object in the database it lives in, the `telemetry` sibling included (#22579) + + A development `os serve` boot on a file-backed SQLite database, and any boot with `OS_TELEMETRY_DB=`, keeps lifecycle-classed system data (audit, telemetry and event objects such as `sys_audit_log`, `sys_activity`, `sys_metadata_audit`, `sys_job_run`, `sys_notification`) in a sibling `telemetry` database. The one-shot boot `os migrate` runs never opened that sibling, so every one of those objects resolved to the primary database: after a development boot, `os migrate plan` listed each as a table to create, and `os migrate apply` created each in the primary — empty tables beside the ones the served boot uses. + + The migrate boot now provisions the sibling exactly when the serving boot would, through the same helper and under the same rule: `--dev` or `NODE_ENV=development` on a file-backed SQLite primary, or `OS_TELEMETRY_DB=`, and never with `OS_TELEMETRY_DB=0`. `plan` and `apply` diff and apply every object against the database it lives in, and print the sibling under the database line (`Telemetry database: …`); in `--json` it is the new `telemetryDatabase` field, present only when a sibling is planned. `apply`'s confirmation names both databases. A `plan` against a sibling that does not exist yet lists its tables to create and creates no file. `os migrate unmapped-columns` reads a lifecycle-classed object from the sibling, and names it as the database. + + A deployment with no sibling — production without `OS_TELEMETRY_DB`, or `OS_TELEMETRY_DB=0` — is unchanged. Run the migration with the `NODE_ENV` the deployment is served with: without `NODE_ENV=development` the plan describes a production `os serve`. Tables an earlier `os migrate apply` created in the primary for these objects are not removed. + + This supersedes the "Known limit" in this release's `os migrate plan` / `apply` composition entry: the migration boot now provisions the `telemetry` database. + + `os serve` loads the project's `.env*` files through the same function `os migrate` does; which files it reads, and in which mode, is unchanged. +- 7c7e750: fix(cli): `os migrate plan`'s unmanaged-tables sweep runs whenever the plan's object set mirrors what `os serve` registers, covers every database the plan diffs, and states only a reason that holds when it does not run (#22580) + + On a project with a compiled artifact and no `objectstack.config.ts`, `os migrate plan` composes what `os serve` registers, platform included. The sweep for platform-prefixed tables that no object declares was still withheld there (`unmanagedTables.status: "unreadable"` in `--json`), with a reason claiming the plan's object set was only the artifact plus the platform floor. On a project with neither a config nor a compiled artifact, the same untrue reason was given. + + The plan's composition now records whether its object set mirrors the served boot, and why when it does not. The sweep runs exactly when it does, the compiled-artifact project included. When it does not run, the `detail` names the composition's own reason: the host config that did not load, or a project with neither a config nor a compiled artifact. + + The sweep also reads every database the plan diffs. Where the serving boot keeps lifecycle-classed objects in the `telemetry` sibling database, a table stranded there is now reported too, where before the sweep answered `read` over the primary database alone. A sibling whose catalog cannot be read makes the whole sweep `unreadable`, and the `detail` names that database. The `--json` shape of `unmanagedTables` is unchanged. `physicalTables` counts the tables of every database swept, and the human line says "in the database(s) this plan covers". +- 0ec4268: fix(cli): `os migrate` reads the project's `.env` files as `os serve` does, and names the database it opened and who named it (#22581) + + `os serve`, `os start` and `os dev` load the project's `.env*` files before they read any variable. `os migrate` read only the process environment. So a project keeping `OS_DATABASE_URL` in `.env` served one database and migrated another: `os migrate plan` reported `.objectstack/data/objectstack.db` while `os serve` opened the `.env` database. An `OS_AUTH_SECRET` kept in `.env` left the auth family (`sys_user`, `sys_account`, …) out of the plan. + + Every command that boots the one-shot schema stack now loads the same files first, through the same `dotenv-flow` call, with `os serve`'s mode rule: `NODE_ENV`, else `production`, and `development` for `os migrate security-catalog-overlays --dev`, which composes as `os serve --dev`. That covers every `os migrate` subcommand, and also `os secret rewrap`, `os secret orphans` and `os storage orphans`. A variable exported in the shell still wins over `.env`, as it does for `os serve`. The SQLite occupancy check that `os migrate apply` and the other write commands run before booting reads the same files, so it checks the database the command is about to migrate. + + This supersedes two phrasings in this release's `os migrate plan` / `apply` auth-family entry: `os migrate` now does read the project's `.env` files, and the deployment's `OS_AUTH_SECRET` composes the auth family whether it is exported or kept in `.env`. + + `os migrate plan` and `os migrate apply` print the database with its source: `--database-url`, a variable from the process environment, a variable from a named `.env` file, the config's default datasource, or the default. In `--json` the source is the new `databaseSource` field (`{ kind: 'flag' | 'process-env' | 'env-file' | 'config-datasource' | 'default', variable?, file?, datasource? }`). `apply` now carries `database` and `databaseSource` on every payload after the boot, including `in_sync`, `nothing_safe_to_apply` and `confirmation_required`. +- f59a73c: fix(cli): `os package install` no longer reports a hot install when the runtime answers `hotLoaded: false` (#22729) + + Clause-②: no + + - **What was wrong.** `os package install` printed "Package installed into the running kernel" for any `200` from `POST /api/v1/marketplace/install-local`. On the endpoint's lenient path (a catalog manifest whose hot-register throws), the runtime writes the package to its ledger and answers `200` with `hotLoaded: false` and the register error's message as `hotLoadError`: the package loads at the runtime's next restart, and the running kernel does not hold it. The command read neither key. + - **What it prints now.** On `hotLoaded: false` it prints a warning that the package is installed but the running kernel could not load it, and that it loads at the runtime's next restart, followed by a `Hot-load error:` line carrying `hotLoadError` as the runtime answered it. When `hotLoadError` is missing, blank or not a string, no reason line is printed. The exit code stays `0`: the install itself succeeded. + - **What did not change.** A hot-loaded install (`hotLoaded: true`) prints exactly as before, and so does an answer without `hotLoaded` (a runtime that predates the key). The command has no `--json` output, and none is added. +- eb20c33: fix(cli): the nine rule ids the CLI produces print one verdict sentence each, `os explain ` carries their reasoning, and the undeclared-handlers boot line is one line + + Clause-②: no + + - **One verdict sentence of at most 200 characters.** Measured on every finding the CLI's suites fire (message length; before → after): + - `os lint`'s hook-body rules: `hook-body/not-lowerable` 565–661 → 165–178, `hook-body/bundled-fallback` 324–947 → 109–171, `hook-body/unparseable` 469 → 157, `hook-body/extraction-failed` 331 → 140. Over every refusal the extractor can throw (14 forbidden constructs and 7 parameter refusals), `hook-body/bundled-fallback` runs 296–1,011 → 113–199: the verdict quotes the extractor's own verdict clause, cut to fit and marked `…` when cut. + - package docs (`os build`, `os validate`, `os lint`): `docs/uncollected-directory` 433–558 → 124–183, `docs/nav-target` 263–299 → 147–184, `docs/frontmatter-tags` 233–285 → 135–195, `docs/duplicate-name` 330 → 167 (its within-package arm is unchanged at 101). A doc finding no longer repeats its path, which every printer already leads with; lists of files, packages and directories name the first three and count the rest. + - `picklist-reference-unverified` (the `os validate` / `os build` notice): 240–245 → 173–175, naming at most two outside dependencies and counting the rest. + - **A `fix:` line for the hook-body rules.** Their remedy moves out of the message into the finding's `fix`, at most 200 characters: `os lint` prints it as `→ fix:` under `--fix`, and `os lint --json` now carries `fix` on these four ids' issues. The remedy is still chosen per case: inline a module-scope name, or keep a check on a host-only global such as `Intl` in a string handler ref or a validation rule. A doc finding carries no separate fix, so its sentence ends with the remedy; the picklist notice's `hint` is unchanged. + - **`os explain ` carries the rest.** Each of the nine ids gains a `RULE_EXPLANATIONS` entry in `@objectstack/lint`, the one table `os explain` reads, and `os lint`'s `rule:` line now points at it. They hold what the messages stopped saying: why a bundled handler is a change of deployment shape and how to bundle on purpose, what the sandbox cannot run and where `os build` prints the full refusal, why an instrument limit is not a verdict, where package docs are read from and why the collector never guesses, why a doc name may appear once, what a doc navigation item can open, how doc tags are read, and why a reference into a dependency outside the stack is not judged. + - **The `[action-governance]` boot line for registered handlers with no declaration** (`os dev`, `os serve`, and any host that boots ObjectQL) is one line of 192 characters, where it was 628: it names the three sources it read, states the 404 as expected rather than measured, and says to declare each with `defineAction`. Its structured `count` / `handlers` tail is unchanged, and the dead-button line beside it is unchanged. + - **Unchanged:** every rule id, severity and `path`, the boot lines' level and payload, and what each check accepts or refuses. A tool that matched on this message text should match on `rule` and `path` instead. +- 40bc51f: fix(cli): `objectstack build` reads a lowered hook or action body off the handler's parse, so a `}` in a string, a statement-level inner arrow or a `function` handler with a pattern parameter no longer ships a body that is not the handler's + + Clause-②: no + + When `objectstack build` lowers an inline handler to a metadata `body`, it ships the handler's statements as `body.source`. It found those statements by counting `{` / `}` and `(` / `)` through the handler's text, with no idea of what a string, a template literal, a regular expression or a comment is, and it took the first parenthesised `=>` anywhere in the text as the handler's header. So ordinary handler code shipped a body that is not the handler's, and the build still exited 0. Measured through a real `objectstack build`: + + - **A `}` inside a string, a template literal, a regex or a comment** cut the body off at that brace. `ctx.input.note = 'a}b'` shipped as `ctx.input.note = "a`, a `SyntaxError` on the body's first run. + - **A statement-level inner arrow** in a `function` handler (`const f = (a) => a + '!'`) was read as the handler, and the body was built around it, which is also a `SyntaxError`. With a block-bodied inner arrow, the inner arrow's own statements shipped: a body that runs and does none of the handler's work. + - **A `function` handler whose parameter is a pattern** (`async function ({ input }) { … }`) shipped the pattern as its body. After the parameter rebind that is `var { input } = ctx; input`. It throws nothing and stores nothing, where the in-process handler stores the row. + - **A `{` inside a string** left the count unbalanced, so the handler was refused as unparseable and bundled into the runtime module, with a warning telling the author to rewrite a handler that had nothing wrong with it. + + The only signal for the first three was `os lint`'s `hook-body-source-unparseable` warning on the lowered body. It pointed the author at a syntax error their handler does not have, and the pattern-parameter case raised no signal at all. + + The body is now read off the TypeScript parse of the handler: the statements between its block's own braces, or the expression of an implicit-return arrow. This is the same parse the parameter rebind and the free-identifier check already read. Every shape above now lowers to the handler's own body and runs as the handler does. The `{`-in-a-string handler is lowered instead of bundled. `hook-body-source-unparseable` no longer fires for any of them. + + This applies to both faces, a hook's `handler` and an action's `target`. The action face still gets no parameter rebind. + + **Unchanged.** A handler the old count read correctly lowers to the same bytes, comments and layout included. This was measured over 21,262 function texts from `packages/cli`, `examples` and `packages/qa`, and over the callables the `app-crm` and `app-todo` configs load. No refusal message is reworded. The hook face's separate "could not read the parameter list" refusal is gone: the body and the parameter list are now read off one parse, so a handler that parse cannot read meets the existing "could not parse the body" refusal instead. It is the same kind (`unparseable`) with the same remedy, and no measured handler reached the old one. `extractHookBody`'s published signature and refusal kinds are the same. No spec key, export, type or stored shape moves. +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. +- c32bd82: fix(cli): `objectstack build` ships every `functions` name under its own name, bound to its own code, and every hook and action ref resolves to its own code (#22886) + + Clause-②: no + + The runtime resolves a hook's `handler`, an action's `target` and a `functions` key against one namespace, filled from the runtime module (`objectstack-runtime.*.mjs`). The build and the module used to name its entries twice, and the two disagreed: + + - **A `functions` key that shared its function with a hook or an action was lost.** `functions: { score: tidy }` beside a hook whose handler is `tidy` shipped as `functions: { tidy_hook: 'tidy_hook' }`, and two keys bound to one function shipped as one. + - **A `functions` key equal to a hook's name, or to an action's `OBJECT_ACTION` ref, was re-keyed and its hook ran the wrong code.** The key shipped as `NAME__2`, a bare string with no callable after load, while the hook's ref resolved to the `functions` entry's code. + - **An action's handler never reached the runtime module**, collision or not, so a script action whose handler could not be lowered to a `body` shipped with a ref that resolved to nothing. + - **A `functions` map exported by name** beside the default export shipped as bare strings: the module read the default export only. + + Now an authored `functions` name (a map key, or an array entry's `name`) is always its own ref: it ships under that name and runs its own code. A hook or action ref is internal, so on a collision it is the one that takes a numeric suffix (`hooks[i].handler: 'NAME__2'`); the hook's `name` is unchanged. A hook or action bound to the same function as a `functions` entry re-uses that entry's name. The runtime module is generated from the build's own ref → callable map, locating each callable in the loaded config by identity, so it serves every ref the artifact names. A stack with no collision ships the same artifact refs as before. + + No action is needed: rebuild to pick up the fix. An artifact built before this release keeps the old names until it is rebuilt. +- 85b4ac6: fix(cli): plugin scaffolds declare `vitest ~4.0.0`, so `npm install` works on npm 10 + + Clause-②: no + + `objectstack init -t plugin` and `objectstack create plugin` now declare `vitest ~4.0.0` instead of `^4.0.0`. With `^4.0.0`, a fresh `npm install` of the emitted project crashed on npm 10, the npm that Node 22 bundles, with `Cannot read properties of null (reading 'edgesOut')`. npm 11 was not affected. + + The cause is upstream. vitest 4.1.x accepts vite 8, and vite 8's optional devtools peers lead back to a `vitest@*` peer that npm 10 cannot resolve against an older vitest at the root. vitest 4.0.x accepts only vite 6 or 7, which carry no such peer. A scaffolded project now installs vitest 4.0.18 with vite 7.3.7 on npm 10 and npm 11. + + Projects that were already scaffolded are unchanged. If one of them fails to install on npm 10, change its `vitest` devDependency to `~4.0.0`, or install with npm 11. +- 7f2f081: `os serve` of a host config serves the stack's declared `apis` endpoints, and a composition that still cannot serve them says so once + + Clause-②: no + + A host config is a stack whose `plugins` holds plugin instances: `examples/app-showcase` and every `create-objectstack` scaffold. On `os serve objectstack.config.ts` (and on `os start`, whose `serve` child boots the same way) such a config never got `@objectstack/metadata`'s `MetadataPlugin`, so its `metadata` service was the kernel's in-memory fallback, which has no endpoint matcher. Every declared `apis` path answered `404 ENDPOINT_NOT_FOUND` there, while `os dev` of the same stack matched it. Measured on the showcase with no session: `GET /api/v1/apps/showcase/tasks` answered 404 and now answers `401 UNAUTHENTICATED`; an undeclared path under the same namespace still answers 404. + + `os serve` now composes a `MetadataPlugin` with no artifact on a host-config boot that has none (when `os dev`'s artifact door does not compose and `plugins` holds no `MetadataPlugin` of its own). What an upgrading host author meets on that boot, measured on the showcase before and after: + + - `GET /api/v1/meta/TYPE` answers byte-for-byte the same for every type it served before (22 types read, including `app`, `view`, `page`, `dashboard`, `flow`, `position` and `permission`). + - `GET /api/v1/openapi.json` now documents the declared endpoints the boot serves; before, it listed none of them. + - No new database table: the `sys_metadata` storage tables were already provisioned, and no row is written at boot. + - The door writes nothing into the project directory at boot. Its metadata repository is rooted at the served app's directory, not at the directory the command was run from. + - The door reads none of the stack and primes nothing from the filesystem (`bootstrap: 'lazy'`), so `positions` / `permissions` / `capabilities` / `sharingRules` keep exactly one registrar. + - The dev-only `GET /api/v1/dev/metadata-events` stays unmounted unless `NODE_ENV=development`. Under `os serve --dev` with no compiled artifact it is now mounted, as it is on every other config's dev boot. + - With a remote cluster driver, the metadata service's cross-node cache invalidation (`metadata.changed`) now attaches; the boot warning that it was disabled is gone. + - Boot time and memory did not measurably move (median of 7 boots each: 8.47 s → 8.41 s to listening, 461 MB → 459 MB resident). + + A composition that still has no endpoint matcher (for example a kernel composed without `MetadataPlugin`) now logs one warning at boot per app that declares `apis`, with the count, naming the remedy: compose `@objectstack/metadata`'s `MetadataPlugin`. +- 84c4fff: `os migrate plan` / `apply` cover `sys_packages`, and the boot honours `OS_SKIP_SCHEMA_SYNC` for it + + Clause-②: no + + `sys_packages`, the package registry's table, is created by `PackageServicePlugin.start()` with its own DDL, because it keys on `(id, version)` and a declared object cannot carry that key. So it was never on the managed migration path: `os migrate plan` did not list it and `os migrate apply` did not create it. A deployment that sets `OS_SKIP_SCHEMA_SYNC=1` ("Use after running migrations manually") still had it created at boot, and had no documented way to create it otherwise. + + - **The migration path covers it.** `PackageServicePlugin.init()` declares the table as its `package.storage` kernel service: a read-only catalog probe (SQLite, PostgreSQL and MySQL each ask their own catalog) and the boot's own create statements. `os migrate plan` lists `sys_packages` as `create_table` when it is absent, and `os migrate apply` creates it, with the same statements the boot runs, so the table and its `idx_packages_latest` index come out exactly as a boot creates them. Whether the flag is set does not matter to either command. Where the table's presence cannot be read (a driver that names no SQL dialect), the run says it did not examine the table, and neither lists nor creates it. + - **Then the boot honours the flag.** With the flag set, `PackageServicePlugin` issues no DDL and no existence probe at boot, and logs one `info` line naming `sys_packages` and the flag, and pointing at `os migrate apply`. The `package` service is still registered. The value read is the one `ObjectQLPlugin` resolved (the `skipSchemaSync` option, else the environment variable), as `plugin-audit` and `service-messaging` read it. On a flag deployment, run `os migrate apply` before the first boot that needs the table. + - **What is unchanged.** With the flag unset, the boot creates the table exactly as before. An existing `sys_packages` table is not altered by either command; the plan lists nothing for it. No type or export changes. +- 8c10776: fix(metadata-protocol): a stored-metadata migration records the user who ran it, or nobody, never a label + + Clause-②: no + + - **What was wrong.** The stored-metadata migration pass (`os migrate meta --stored`, `POST /api/v1/meta/_migrate-stored`, the runtime dispatcher's `POST /metadata/_migrate-stored`, and `migrateStoredMetadata()` called in process) recorded a label as the writer of every row it rewrote: `'os migrate meta --stored'`, `' (POST /api/v1/meta/_migrate-stored)'`, `'POST /api/v1/meta/_migrate-stored'`, or `'migrate-stored'`. The writer lands in `sys_metadata_history.recorded_by` and in the row's `updated_by`. Both columns are references to `sys_user`, so the label pointed at no user. Nothing refused it, because metadata writes run as the system. + - **What it does now.** The pass records the id of the user who ran it, exactly as every other metadata write does. A REST or runtime call by a signed-in user with `manage_metadata` records that user's id. A run with no user records `null`, which means "system-initiated". That covers the CLI, which opens the database directly with no session, and a system caller. The pass still names itself on each history row's `source` (`migrate-stored`), as it did before. + - **What an operator sees.** History rows and rewritten rows written by a migration from this release on name the person who ran it, or nobody. They never name a command or a route. Rows a migration rewrote before this release keep the label they were given. + - **Contract text.** `MetadataItem.authoredBy`'s description now gives the same rule as `PutOptions.actor`: the acting user's id, or `null` for a system-initiated write, never a label such as `"system"` or `"cli"`. The schema accepts the same values as before. +- eeb95f7: fix(spec,objectql)!: a permission set whose body `packageId` disagrees with the package that ships it is refused at `defineStack` / `os validate` / `os build` and at package registration, and every reader takes a set's owner from its binding only (ADR-0086 D3) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing on a published authoring surface), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0086 D3 declares `PermissionSetSchema.packageId` as the owning package of a package-shipped set, absent for an environment-authored one. The owner every reader uses is the binding: the package that ships the set or that it is saved into, served as `_packageId`. The metadata save, restore and publish doors already refuse a body `packageId` that disagrees with it. Package source and package registration did not: a set whose body named another package was registered under the package that shipped it, with no refusal and no warning, so an author, an AI build above all, never learned that the owner it wrote was not the owner it got. + + **What is refused, and where.** One judge, `judgePermissionSetOwnerBinding` (new export of `@objectstack/spec/security`), serves every door: + + - **The stack schema** (`ObjectStackDefinitionSchema`), so `defineStack`, `objectstack validate`, `objectstack build`, the lint scorer and an artifact load all refuse a set whose body `packageId` is not the id of the package that ships it: the stack's `manifest.id`, each `packages[]` body's own `id`, and `manifest.id` for a top-level set no body declares. A stack with no `manifest` ships its sets from no package, so any key there is refused, the empty string included. The issue sits at the set's `packageId` (`permissions.0.packageId`); `defineStack` throws `STACK_SCHEMA_INVALID` (422) carrying it. + - **Package registration** (`ObjectQL.registerApp`, the manifest and a nested plugin, for a manifest no schema parse judged) throws `400 VALIDATION_ERROR` with `issues[]` at `packageId` before the set is filed, the envelope the same seam throws for a view container whose `name` disagrees with its key. + + The message prescribes first, names both values, and offers both remedies: drop the key, or ship the set from the package it names. No error code is minted. + + **Readers.** `@objectstack/plugin-security`'s packaged-set lock (`classifyPackagedPermissionSet`) and `@objectstack/cli`'s permission-set name-collision report take a set's package from its binding only. A set filed into no package whose body named one is no longer locked as that package's, and a stack with no `manifest` declares no owner for the collision report. + + **Unchanged.** A body `packageId` equal to the package that ships the set is accepted, and an absent one is accepted everywhere. A top-level copy of a set a `packages[]` body declares (an artifact built with both halves) is judged on its body, not against the artifact's own id. The metadata save, restore and publish doors answer in the same words as before (`@objectstack/metadata-protocol` now calls the shared judge). Stored `sys_metadata` rows are not rewritten. + + ## FROM → TO + + | door | FROM | TO | + |:--|:--|:--| + | `defineStack` / `objectstack validate` / `objectstack build` | `defineStack({ manifest: { id: 'com.acme.crm', … }, permissions: [{ name: 'crm_member', packageId: 'com.acme.other', … }] })` accepted; the set was owned by `com.acme.crm` | refused at `permissions.0.packageId`, `STACK_SCHEMA_INVALID` 422 | + | `ObjectQL.registerApp` | the same manifest registered the set under `com.acme.crm`, silently | `400 VALIDATION_ERROR`, `issues[]` at `packageId`, nothing filed | + | an artifact built before this change | loaded, the set owned by the shipping package | refused at load; rebuild it without the key | + + **Remedy, in one line.** Drop `packageId` from the permission set in your stack source (the package that ships a set is its owner), or move the set into the stack of the package it names, then build again. +- 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. +- 8684079: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + fix(metadata-protocol,metadata): on a database the organization-ownership ceremony has not migrated, the paged family reads, `DatabaseLoader`'s count and the package rollback agree with the environment-only reads; the ceremony's apply is named with `--plan-file` (ADR-0131 D6/D7) + + Clause-②: no + + Every change below matters only where legacy organization rows still sit in the `sys_metadata` family, the database `os serve` refuses to start on until `os migrate organization-ownership` has verified. On a migrated or fresh database every answer is unchanged. + + - **`@objectstack/metadata-protocol`: a paged read leaves the legacy rows out before the page is cut.** `auditMetaItem` (`limit`), `listCommits` (`limit`), `revertStoredPackage`'s first-active-row probe and `SysMetadataRepository.list({ limit })` used to filter a legacy organization's rows out after the limit. A page they sat in came back short, and `revertStoredPackage` answered `RESOURCE_CONFLICT` (409) "has never been published" for a package the environment had published when its first active row was an organization's. When a page holds a legacy row, the read is now repeated with those rows excluded in the query. + - **`@objectstack/metadata-protocol`: `rollbackToPackageCommit` refuses a legacy organization's commit by name.** It answered `COMMIT_NOT_FOUND` (404) for a commit that is there. It now gives `revertCommit`'s refusal: `NOT_OVERRIDABLE` (403), the same sentence, naming the commit and its organization. + - **`@objectstack/metadata`: `DatabaseLoader` counts what it reads.** `exists()` answered true for an address only a legacy organization row held, while `load()` answered null, and `queryHistory`'s `total` counted legacy history rows no page serves. Neither counts them now. + - **`@objectstack/metadata-protocol`: internal cleanup.** The organization plumbing that only ever carried the environment (`null`) is removed from the list merge and the served-row resolution. No export changes. + - **`@objectstack/spec`: wording.** The four ADR-0087 entries `18.sys-metadata{,-audit,-commit,-history}-organization-column-retired` now name the ceremony's reviewed apply, `os migrate organization-ownership --out PLAN` and then `--apply --plan-file PLAN`, which `--plan-file`'s own entry requires. No id, category, version or field the migration engine reads changes. + - **`@objectstack/cli`: wording.** `os migrate apply --allow-destructive`'s refusal on an unmigrated database now reads "a drop the organization-ownership ceremony owns, and the ceremony has not verified on this database". Its `--json` payload is 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. +- b485122: fix(cli): `os serve` refuses an `OS_DATABASE_URL` whose scheme no driver recognises, reads `pg://` and a scheme-less path as the runtime host does, and opens `sqlite://x.db` as `./x.db` + + Clause-②: no + + This is the `os serve` path a host config takes (one whose `plugins` hold plugin instances), and the one `OS_MODE=off|none|legacy` takes. `os dev` and `os start` reach it through their `serve` child. + + - **Before, an unrecognised scheme (`OS_DATABASE_URL=redis://127.0.0.1:6399/0`).** `os serve --dev` and `os dev` printed `✓ Server is ready` and the banner `Driver: SqlDriver(better-sqlite3) → :memory:`. The database the operator named was dropped without a word, and every write was lost at restart. In production (`os serve`, `os start`) the boot met the no-storage refusal, which tells the operator to set `OS_DATABASE_URL` even though they had set it. + - **After.** Both postures exit 1 before any plugin starts with `✗ Unsupported database URL scheme "redis://" (OS_DATABASE_URL / --database). Supported: …`. The message lists the recognised spellings and names only the scheme, never the URL, so credentials in the URL are not printed. The standalone runtime host (`os dev` / `os start` over a plain config, `os migrate`) already refused these schemes, so the two hosts now agree. A `.db` or `.wasm.db` ending no longer rescues an unrecognised scheme: `redis://h/app.db` and `redis://h/app.wasm.db` used to be handed to SQLite as a file path, and both are now refused. A `*.wasm.db` name still selects the WASM engine for a `file:` / `sqlite:` URL or a path with no scheme. + - **`pg://…` and a path with no scheme (`./data/app`, `/var/lib/objectstack/app`).** The runtime host already reads these as postgres and as a SQLite file. Before, this path booted `--dev` on `:memory:` for them and production met the no-storage refusal. Now `pg://` is a postgres datasource and a scheme-less path is that SQLite file, in dev and production. + - **An opaque scheme (two or more characters and a `:` with no `//`, such as `jdbc:postgresql://h/db` or `redis:h`).** Before, `--dev` booted on `:memory:` for these and production met the no-storage refusal. Now they get the same refusal, naming the scheme (`"jdbc:"`), in both postures. A one-letter drive is a path, not a scheme (`C:\data\app`, `C:/data/app`). A file name whose first segment contains a colon now reads as a scheme too: `backup:2026.db` was a SQLite file, and is now refused. Write `./backup:2026.db` or `file:backup:2026.db` for that file. + - **`sqlite://x.db`.** Before, this path stripped `sqlite:` and opened `//x.db`, a file at the filesystem root. The banner read `→ sqlite://x.db`. The runtime host already reads `sqlite:` and `sqlite://` as aliases of `file:` and opens `./x.db`. This path now uses that same rule (`normalizeDatabaseUrl` from `@objectstack/runtime`), so `sqlite://x.db` and `sqlite:x.db` open `./x.db` and `sqlite:///abs/x.db` opens `/abs/x.db`, on both hosts. `sqlite://x.wasm.db` likewise opens `./x.wasm.db` on the WASM engine. + + `postgres://`, `postgresql://`, `mysql://`, `mongodb://`, `libsql://`, `wasm-sqlite://`, `file:`, `:memory:` and `*.db` paths resolve as before. An unset or blank `OS_DATABASE_URL` keeps the dev SQLite default. An explicit `OS_DATABASE_DRIVER` still wins over the URL, which is then not classified. +- 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. +- abb6df0: Clause-②: no + + fix(cli): `os storage orphans`, the `os secret rewrap` dry run and the `os migrate summary-nulls` dry run say they write no row or table, and that opening a SQLite file can still switch its journal mode, instead of saying nothing was written + + **What was wrong.** Each of these runs 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, each run 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 "nothing was written". Nothing was lost. + + **What changed.** Each sentence below now claims no row or table. Where the run opened a SQLite file, the report's line is followed by the note the other read-only 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 line ends without it. + + - `os storage orphans` (`formatStrandedOrphanInventory`, `@objectstack/service-storage`). FROM: "This pass is READ-ONLY: nothing was written, tombstoned or deleted." TO: "This pass is READ-ONLY: no row or table was written, tombstoned or deleted." and the note. + - `os secret rewrap` (the dry run's note, `REWRAP_NOTES.dryRun`, also in `--json` `report.notes`). FROM: "Dry run: every attributed row was opened, re-sealed and verified in memory, and nothing was written. Re-run with --apply to write." TO: "… and no row or table was written. Re-run with --apply to write." The note already follows the dry run's closing line on a SQLite file, so this sentence carries none. + - `os migrate summary-nulls` (the dry run that found NULL roll-ups, `formatSummaryBackfillReport`, `@objectstack/objectql`). FROM: "Dry run — nothing was written. Re-run with --apply to backfill." TO: "Dry run — no row or table was written. Re-run with --apply to backfill." and the note. + + **Unchanged.** No exit code, flag, prompt, refusal or export signature changes, and no `--json` document gains a field; 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. +- f7a5ebe: fix(runtime,cli): `os start` and `os dev` refuse a SQLite database whose directory cannot be made, at once, instead of hanging + + Clause-②: no + + **What was wrong.** The standalone host (`createStandaloneStack`, which `os start`, `os dev`'s standalone boot and every embedder run) prepared the directory of a file-backed SQLite database with Node's recursive `mkdirSync`. Node's recursive mkdir never returns when a level answers `ENOENT` although its parent exists: for `OS_DATABASE_URL=file:/proc/os-x/ctl.db` it looped `mkdir("/proc/os-x")` and `mkdir("/proc")` without end, holding the event loop. `os start` printed `→ Starting server...` and hung with no timeout and no message. The `sqlite-wasm` arm (`wasm-sqlite://…`, or `OS_DATABASE_DRIVER=sqlite-wasm`) did the same. `os dev` hung the same way in its own process when its default database lived under a state directory that cannot be made (for example `OS_HOME=/proc/os-x`). + + **What an operator sees now.** The boot exits non-zero within its normal start-up time, naming the database file, the directory and the operating system's error: + + ```text + ✗ [StandaloneStack] SQLite database file '/proc/os-x/ctl.db' cannot be created: its directory '/proc/os-x' could not be made: ENOENT: no such file or directory, mkdir '/proc/os-x' + ``` + + `os dev` with an unusable state directory prints `✗ The default database '…/data/objectstack.db' cannot be created: its directory '…/data' could not be made: …`, followed by the remedy: point `OS_HOME` at a directory that can be created, or choose the database with `--database`. + + Nothing else changes. A creatable path is still made, every missing level of it. The read-only boot (`os migrate plan` over an absent database file) still creates no directory. The refusal names the path, never the database URL. +- 161aee1: fix(cli): an `os serve` boot that binds no HTTP server sends no `objectstack:listening` message and writes no runtime state file, and the banner's `MCP:` row prints only when the MCP route is mounted + + Clause-②: no + + - **Before.** `os serve --no-server`, with no transport composed, sent `{ type: 'objectstack:listening', port, url: 'http://localhost:PORT' }` over an `ipc` channel and wrote `runtime...json` with the same `port` and `url`. Nothing listened on that port: a `GET` to it was refused. The docs define `objectstack:listening` as "The HTTP server is bound". + - **After, for a supervisor of a boot that binds no HTTP server** (`--no-server` with no config-mounted transport, or a transport plugin that did not load). It receives no `objectstack:listening` message. It still receives `objectstack:seed-settled`, which is then the boot's only message, so a parent waiting for the seed to settle does not hang. No `runtime...json` is written, so "is my server running, and where?" gets no answer naming an address. The file's payload, where it is written, is unchanged. + - **After, with an HTTP server composed.** Unchanged: the state file is written first, then the banner prints, then `objectstack:listening` is sent with the port actually bound, and `objectstack:seed-settled` follows it. That includes a `--no-server` boot whose config mounts its own `HonoServerPlugin`. The banner, the IPC message and the state file read the same check, a registered `http.server` service on the booted kernel, so they agree on whether there is an address. + - **The banner's `MCP:` row.** It and its "connect an AI client" hint now also require the plugin that mounts `/api/v1/mcp`, the runtime dispatcher, to be registered on the kernel. `os serve` composes the dispatcher only under `--server`. So a `--no-server` boot whose config mounts its own `HonoServerPlugin` prints `API:` but no longer prints an `MCP:` address, which answered 404 there. +- bb14934: fix(cli): an `os serve` boot that composes no HTTP server now stays up until it is stopped, as its banner's `Press Ctrl+C to stop` says + + Clause-②: no + + - **Before.** `os serve --no-server --preset minimal`, in production with no storage driver (no `OS_DATABASE_URL`), printed the ready banner ending `Press Ctrl+C to stop` and then exited 0 by itself, about 50 ms later, with no signal and without the kernel shutting down. The same command stayed up whenever the storage driver kept a connection pool (`--dev --no-server`, or `--no-server` with `OS_DATABASE_URL` set). Measured, that pool's reaping interval was the only thing keeping those boots alive, so whether a kernel-only boot stayed up depended on the driver. + - **After, for an operator or supervisor of a boot with no HTTP server composed** (`--no-server` with no config-mounted transport, or a transport plugin that did not load). The process stays up after its banner, whatever the driver, until SIGINT, SIGTERM or SIGQUIT. The kernel's own signal handling then drains it and exits with the kernel's code, 0 on a clean shutdown, as on a server boot. No signal listener is added. + - **Why "stay up" and not "run and exit".** The banner says `Press Ctrl+C to stop`. The kernel is built to own the process: a signal drains it, then exits it. The platform's background loops never keep a process alive by themselves, so the host decides the lifetime. The run-and-exit kernel boot already exists, and is explicit: `OS_MIGRATE_AND_EXIT=1`. Every in-repo kernel-only boot that is meant to exit sets it, and every other one is stopped by a signal. + - **Unchanged.** A boot with an HTTP server composed, including a `--no-server` boot whose config mounts its own `HonoServerPlugin`. `OS_MIGRATE_AND_EXIT=1`, which still shuts the kernel down and exits 0 before any banner. The IPC messages and the runtime state file. + - **If you relied on the exit.** A script that ran `os serve --no-server` and waited for the process to end now has to set `OS_MIGRATE_AND_EXIT=1` (bootstrap once, then exit 0), or stop the process with a signal. +- 66d456d: fix(cli): `os serve`'s refusal of a `memory://` or `mingo://` `OS_DATABASE_URL` names the URL's scheme and never prints the URL, so a password or token in it no longer reaches the console + + Clause-②: no + + This is the `os serve` path a host config takes (one whose `plugins` hold plugin instances), and the one `OS_MODE=off|none|legacy` takes. `os dev` and `os start` reach it through their `serve` child. Both schemes name the retired in-memory (mingo) engine, and both accept anything after the `//`, a `user:password@` and a query string included. The refusal interpolated the whole URL, and the CLI prints it as it exits 1. + + With `OS_DATABASE_URL=memory://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN`: + + - FROM: `✗ OS_DATABASE_URL "memory://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN" selects the in-memory (mingo) engine, …` + - TO: `✗ An OS_DATABASE_URL with the "memory://" scheme selects the in-memory (mingo) engine, …` + + `mingo://` reads the same way, with `"mingo://"`. These are the runtime host's words for the same URL (`os dev` / `os start` over a plain config, and `os migrate`), so the two hosts print one sentence. The rest of the sentence, which names the SQLite replacements (`os dev --fresh`, and the URL `:memory:`), is unchanged. + + What does not change: both schemes are still refused, with the same error class and exit code, in dev and production; every other URL resolves as before, to the same driver. +- 34b740f: fix(runtime,cli): an upper-case `FILE:` database URL opens the file its lower-case spelling opens, `file://NAME` opens `NAME` on both hosts, and the URL-to-filename rule is exported as `sqliteFilenameFromUrl` + + Clause-②: yes (widening) + + - **`FILE:` is stripped case-insensitively.** The standalone host classified `FILE:./app.db` as SQLite case-insensitively but stripped the prefix case-sensitively, so the prefix stayed in the file name: `OS_DATABASE_URL=FILE:./app.db` created a directory literally named `FILE:.` and put the database inside it, and `File:///tmp/app.db` was refused as an unsupported `File://` scheme. Both now open the file the lower-case spelling opens (`./app.db`, `/tmp/app.db`), as the `sqlite-wasm` path already did. Lower-case `file:` URLs and bare paths are unchanged. + - **New export: `sqliteFilenameFromUrl(url, kind)`** from `@objectstack/runtime`, `kind` being `'sqlite'` or `'sqlite-wasm'`. It is the one rule that turns a SQLite database URL (after `normalizeDatabaseUrl`) into a file name: `file:` / `file://` stripped case-insensitively, plus `wasm-sqlite:` for the WASM kind. For `'sqlite'` it throws a plain `Error` on a URL that is not a file path, naming the URL's scheme and never the URL. Nothing is removed. + - **`os serve`'s legacy storage path (`OS_MODE=off`, `bootMode: 'off'`, or a host config) turns a SQLite URL into a file by that same rule**, so both hosts open one file per URL. It used to strip only a lower-case `file:`: `OS_DATABASE_URL=file://NAME` opened `//NAME` at the filesystem root, where the standalone host opens `./NAME`, and `FILE:./app.db` made a `FILE:.` directory there too. `file:///abs/app.db` opens `/abs/app.db`; `sqlite:` / `sqlite://` URLs are unchanged. + - **What that rule refuses, the legacy path now refuses too, and fatally**, with the standalone host's own sentence (it names the URL's scheme, never the URL): an explicit `OS_DATABASE_DRIVER=sqlite` over a server URL such as `postgres://user:password@host/db`, which used to create directories named after the URL, credentials included, and a `file:` URL that names no file, which used to declare a database with an empty file name. Fix: point `OS_DATABASE_URL` at a file (`file:./app.db`), or select the driver the URL is for. The undocumented `sql://` prefix that path also stripped under an explicit `sqlite` selection is refused the same way; write `file:` instead. A blank `OS_DATABASE_URL` under an explicit `sqlite` selection is read as absent (`:memory:`), as an absent one always was. +- a31ff2e: Clause-②: no + + `os init` (every template: `app`, `plugin`, `empty`) and `os create plugin` now emit + `"overrides": { "vitest": "~4.0.0" }` in the scaffolded `package.json`, so the project installs on + npm 10, the npm Node 22 bundles. Since vitest 4.1.12 shipped a peer set that contradicts itself, + `npm install` of a fresh `os init` project crashed on npm 10.9.x with + `Cannot read properties of null (reading 'edgesOut')`. The override routes every `vitest` edge in + the npm graph to the range the scaffold declares; npm 11 installs the same tree, and pnpm ignores + the field. + + The override equals `devDependencies.vitest` wherever the manifest declares vitest directly, + because npm refuses an override that differs from the direct dependency (`EOVERRIDE`). In an `app` + or `empty` project, add vitest at the same range (`npm install -D vitest@~4.0.0`); a bare + `npm install -D vitest` is refused with `EOVERRIDE` while the override is in place. Existing + projects can add the same `overrides` block by hand. `os create plugin --in-repo` is unchanged. +- 41e84c0: fix(cli): `os dev` (and `pnpm dev` on a scaffold) prints why a watched save was refused, not only `✗ compile failed` + + Clause-②: no + + When a saved file makes the compile refuse, the watcher now prints the compile's output under its failure line: the same lines `pnpm build` prints on that tree, without `--verbose`. + + - **Before.** On a fresh `os init my-app -t app` scaffold (namespace `my_app`), saving an object named `task` printed `↻ recompiling (2 files)...` and then a bare `✗ compile failed (3782ms)`. The reason never appeared. The compile writes its refusals to stdout, and the watcher discarded stdout and printed stderr only. + - **After.** The failure line is followed by the compile's stdout, then its stderr. For that save it shows `✗ Object 'task' is missing the package namespace prefix. Rename it to 'my_app_task' (namespace = 'my_app').` and the lines around it. + + What does not change: a successful recompile prints exactly what it printed before, and the compile's progress output stays hidden on success. `--verbose` still passes the compile's output straight through, so on a failure nothing is printed twice. A failed compile still writes no artifact and still triggers no restart. +- 98ba1bf: fix(cli): `os migrate organization-ownership --apply` refuses an `--out` it cannot write before it executes anything + + Clause-②: no + + **What was wrong.** The apply wrote its report file only after the ceremony had run, with an exclusive create. Handed an `--out` that already existed (a re-run with the same path), it executed every step (attribution, the id-to-name rewrite, mirror deletion, the column drops) and only then exited 1 with `apply_refused` / `report-file-unwritable`, the same `--json` code a refusal before any write prints. An operator read a completed ceremony as one that never ran. + + **What an operator now sees.** + + - An `--out` that cannot be written (an existing file, a directory that does not exist, no write permission) is refused before the database is opened: exit 1, `{"error":"apply_refused","reason":"report-file-unwritable","file":"FILE","code":"EEXIST",…}`, and nothing is executed. The existing file is left as it was. Re-run with `--out` naming a new path. + - The report file is reserved (created exclusively) before the boot, and the report is written into that same file at the end, so no other process can take the path in between. Until then the file holds a short JSON note (`"kind": "organization-ownership-apply-reservation"`) saying a run reserved it. A run that is refused, fails a step or cannot boot removes it again; a run killed before its report leaves that note behind, never an empty file. + - A report that still cannot be kept after the ceremony executed (a write error, or the reserved file removed while the run went on) gets its own code: exit 1, `{"error":"apply_report_unwritable","reason":"report-write-failed","executed":true,"report":{…}}`, with the full report in the payload. The text output still says "The ceremony ran". Re-run `--apply` with a new `--out` to write the report; finished steps are not repeated. + - Without `--out`, the default `organization-ownership-apply-TIMESTAMP.json` is stamped with the moment the run reserves it (before the boot), no longer with the moment the report was generated. + + The plan run (without `--apply`) is unchanged: it changes nothing in the database, so writing its file last was never a hazard. +- 570c7a2: fix(cli): `os serve` no longer hangs after binding its port when its runtime state file's directory can never be made + + Clause-②: no + + This covers the runtime state file that the entry `fix(cli,runtime): a state, output or cache directory that can never be made is refused at once, naming the path and the operating system's code, instead of hanging` lists as `Not covered yet`. + + **What was wrong.** Once listening, `os serve` writes its runtime state file, `runtime...json`, into `OS_HOME` before it prints the ready banner. It made that directory with Node's recursive `mkdir`, which never returns when a level answers `ENOENT` although its parent exists, as `/proc` and `/sys` answer for a name they do not have. Under such an `OS_HOME`, with the database outside it, the server bound its port and then looped in its own process, holding the event loop: no `Press Ctrl+C to stop`, no listening message to an `os dev` parent, and no request answered. + + **What an operator sees now.** The directory is made with the same bounded walk as the other `OS_HOME` paths, so a directory that can never be made is refused at once. The state file has always been best-effort: the boot goes on without it and prints its banner, as it already did when the file could not be written for any other reason. + + Nothing else changes. Every state file written before is still written, in the same place, before the banner; a home that can be made is still made, every missing level of it. +- 2e15620: fix(cli,runtime): a state, output or cache directory that can never be made is refused at once, naming the path and the operating system's code, instead of hanging + + Clause-②: no + + **What was wrong.** These commands and runtime paths created their directories with Node's recursive `mkdir`. Node's recursive mkdir never returns when a level answers `ENOENT` although its parent exists — what `/proc` and `/sys` answer for a name they do not have. It looped `mkdir("/proc/x")` and `mkdir("/proc")` without end, holding the event loop, so the command printed nothing more and no timeout could fire: + + - `os start` with `--home` / `OS_HOME` on such a path hung after its header. + - `os serve` hung the same way on the empty-boot artifact stub under `OS_HOME`, and on the cache of an `http(s):` `OS_ARTIFACT_URL` under `OS_HOME`. + - `os create --dir`, `os init `, `os compile --output`, `os generate` (`--dir`, and `--output` for types, client and migration), `os i18n extract --out` and `os plugin build --out` hung on an output path there. + - `os login` and `os cloud login` never finished writing credentials under a `HOME` there, and the package enable / disable state the packages routes write under `OS_HOME` held the event loop of the host serving them. + + **What an operator sees now.** The command exits non-zero at once with the operating system's own error, which names the level that cannot be made: + + ```text + ✗ Cannot create home directory at /proc/os-x: ENOENT: no such file or directory, mkdir '/proc/os-x' + ``` + + Not covered yet: the runtime state file `os serve` writes into `OS_HOME` once it is listening. + + Nothing else changes. Every directory these paths made before is still made, every missing level of it; every path that was refused before is still refused, with the operating system's code. The one difference is the path that was never answered at all. +- a33f3d5: Clause-②: no + + fix(cli): `os migrate plan`, a refused `os migrate summary-nulls --recompute-undefined-on-empty` name and every `os secret rewrap` run say what they vouch for, and that opening a SQLite file can still switch its journal mode, instead of saying nothing was written + + **What was wrong.** Each of these runs 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, each run 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 "a plan writes nothing", "nothing was written" or "a finished run writes nothing". Nothing was lost. + + **What changed.** Each sentence below now names what it vouches for. On a SQLite file, its line is followed by the note the other read-only 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 line ends without it. + + - `os migrate plan`, the unmanaged-table section. FROM: "They are reported for information only — nothing here drops them, and a plan writes nothing." TO: "… and a plan writes no row or table." and the note. + - `os migrate plan`, the declaration boot's note when a plugin's write was refused. FROM: "Refused N write(s) during the declaration boot — a plan writes nothing: …" TO: "… — a plan writes no row or table: …", and the note at the end of that note's line. + - `os migrate plan`, the same note when a raw command was forwarded. FROM: "If those commands only read, nothing was written; if one wrote, it landed." TO: "If those commands only read, no row or table was written; if one wrote, it landed.", and the note at the end of that note's line. + - `os migrate summary-nulls --recompute-undefined-on-empty` naming a roll-up the run cannot find (`backfillSummaryNulls`, `@objectstack/objectql`), in either mode. FROM: "Refused before any row was read; nothing was written." TO: "Refused before the walk read or wrote any row." and the note. Under `--apply` the boot before it can also create a table a never-started database lacks, so the sentence speaks for the walk only. + - `os secret rewrap`, the "Re-running is safe" note, in both modes and in `--json` `report.notes`. FROM: "… and a finished run writes nothing." TO: "… and a finished run writes no sys_secret row." Under `--apply` the note follows it; a dry run's closing line already carries it. A finished `--apply` run on a database that has never been started also creates its tables, so the sentence names the rows the re-wrap writes. + + **Unchanged.** No exit code, flag, prompt, refusal or export signature changes, and no `--json` document gains a field: the `--json` documents carry the changed sentences, and the note is printed in the text output only. What the driver does on connect is unchanged. +- c4111c6: fix(cli,driver-turso): `os migrate plan` and `os migrate apply` on a Turso REMOTE database no longer offer `--allow-destructive` for a change that database cannot apply + + Clause-②: yes (widening) + + On a primary reached over the Turso REMOTE transport (`libsql://…`), `os migrate plan` listed a NOT NULL tightening with the remedy `Run "os migrate apply --allow-destructive".` and closed with `(add --allow-destructive for drops / tightenings)`. That transport runs no SQLite table rebuild, so the apply then named the tightening as not applied and exited 1. The plan now says so before the apply: each change the database cannot apply (a tightened or relaxed NOT NULL, a dropped DEFAULT) is still listed, with its finding and `"os migrate apply" cannot apply it on this database: it needs the SQLite table rebuild, which the Turso REMOTE transport does not run.` in place of the remedy. The footer offers `--allow-destructive` only for the destructive changes the database can apply (a column drop, an index change) and says how many listed changes remain drift after the apply. The listing `os migrate apply` prints before its prompt reads the same way. Its counts of held-back destructive changes, before the prompt and after the run, offer a re-run with `--allow-destructive` only for the ones that re-run could apply, and name the rest as drift it cannot apply either. On every other database both commands read exactly as before, and their `--json` documents are unchanged on every database. + + `@objectstack/driver-turso`: `TursoDriver.driftApplyLimit(opType)` is new. It returns why the driver's `applyMigrationEntries` cannot perform a drift op of that type on this face, or `null` when it can. It answers from the same per-op declaration the REMOTE apply door dispatches by, and returns `null` for every op on the local and embedded-replica faces. A driver that does not implement it is read as applying every op the differ means it to. + + `@objectstack/driver-sql`: `driftFindingWithoutRemedy(entry)` is a new export. It returns a drift entry's finding without the `os migrate apply --allow-destructive` sentence that a destructive column entry's message ends with, and returns every other message unchanged; the messages themselves keep their exact text. +- 7d6ec91: Clause-②: no + + fix(cli): `os start` with the stdio MCP transport answers the `initialize` an MCP client writes when it spawns the server, and boots the configured app + + - **What was wrong.** An MCP client writes `initialize` to the server's stdin as soon as it spawns it. `os start` hands its stdin down to the processes it starts, and the CLI's argument parser fills an omitted positional argument from a pending stdin line. So the client's `initialize` became a config path. In a project with no `dist/objectstack.json`, the automatic compile step failed with `Config file not found: …/{"jsonrpc":"2.0",…}` and `os start` exited 1. In a project with a compiled artifact, the server printed `No objectstack.config.ts found — booting from artifact`, booted without the config's own plugins, and never answered `initialize`. + - **What changed.** The automatic compile step now gets no stdin, and `os start` names the config path when it starts `os serve` (the same `objectstack.config.ts` in the working directory that `os serve` used by default). The client's first message stays in the pipe for the stdio transport, and the config is loaded. + - **Unchanged:** the flags and environment variables of `os start`, `os serve` and `os compile`, and which config and artifact a boot uses. +- 27d7c2a: `os serve --no-server` no longer reports an ordering fault for the datasource admin routes or for the unknown-hostname guard. A boot that composes no HTTP server used to print, in its boot diagnostics, `WARN [datasource-admin] route registration failed: [Kernel] Service 'http.server' not found (… a composition/ordering fault; registration order is not a contract.)`, although nothing was at fault. With `OS_ROOT_DOMAIN` set, the same boot also printed `WARN [unknown-hostname-guard] install failed` with the same kernel text. Each now prints its own degrade line, beside the same boot's other "not mounted" lines: `WARN [datasource-admin] http.server unavailable; REST routes not installed`, and `WARN [unknown-hostname-guard] http.server unavailable; guard not installed`. + + What still warns: on a boot that does compose an HTTP server, a route registration that throws still prints `[datasource-admin] route registration failed` with the error, and a guard install that throws still prints `[unknown-hostname-guard] install failed` with the error. A composed boot mounts `/api/v1/datasources` and installs the hostname guard as before, including under `--no-server` when the config mounts its own `HonoServerPlugin`. Both plugins now read only the canonical `http.server` service. Their old fallback to the deprecated `http-server` alias never ran: on a boot without `http.server` the canonical read threw first. So every boot that mounted these routes or installed the guard before still does. +- d3d0644: fix(cli): `os migrate plan` no longer lists `sys_packages` among the tables "declared by no object in this plan" after `os migrate apply` has created it + + Clause-②: no + + `sys_packages` is the package registry's table. It is not an object: `PackageServicePlugin` creates it with its own DDL, and `os migrate apply` creates it from the registry's `package.storage` declaration. The plan's unmanaged-table sweep built its known set from the planned drivers' managed tables and the declared objects only, so on every applied deployment the plan reported the live registry table as if it were stranded. + + - **Now:** the sweep also counts the tables the boot's composition declares outside the object set. It reads that name from the same `package.storage` declaration the plan creates the table from. It reads it on every boot, whether or not the table exists yet, the skip-schema-sync boot of a Turso remote primary included. On `examples/app-crm` after `os migrate apply --yes`, the plan lists only a genuinely stranded `sys_*` table. + - **Still reported:** a `sys_packages` table in the database of a deployment that composes no package registry. Nothing there declares it. + - **Refused in one more case:** a `package.storage` service whose shape this CLI does not read now fails `plan`, `apply` and `organization-ownership` on a skip-schema-sync boot too, with the error those commands already gave on every other boot: `@objectstack/cli` and `@objectstack/service-package` must come from the same release. + - **Unchanged:** the `--json` keys, the section's wording, and what `apply` creates. +- d702b18: fix(cli,runtime): a dangling symlink standing as the SQLite database's directory is refused at once by `os dev` and `os serve`, naming the database file and the directory, instead of failing the boot later in a plugin's start + + Clause-②: no + + **What was wrong.** `os dev` (for its default database under `OS_HOME/data`) and the standalone host behind `os serve` and `os start` (for a `file:` or `wasm-sqlite://` database URL) make the database file's directory before the driver opens it. Both accepted any `EEXIST` answer while making a missing level. A symbolic link that points nowhere answers `EEXIST` to `mkdir`, so it was taken for a directory, and the boot went on until the driver could not open the file. `os dev` then exited 1 with `Plugin com.objectstack.auth failed to start - rollback complete: The database refused to run this query for object 'sys_organization'`, and `os serve` with `Plugin package-service failed to start - rollback complete: The database refused to run a raw statement`. Neither message named the file, the directory or the link. + + **What an operator sees now.** Both make the directory with the same bounded walk as the other `OS_HOME` paths, which accepts `EEXIST` only when a directory stands there. A dangling link as the directory is refused before the boot starts: `os dev` prints `The default database '…/data/objectstack.db' cannot be created: its directory '…/data' could not be made: EEXIST: …` and exits 1, and `os serve` prints `[StandaloneStack] SQLite database file '…' cannot be created: its directory '…' could not be made: EEXIST: …` and exits 1. Remove the link, or point it at a directory that exists. A dangling link higher up the path was already refused at once, with `ENOENT` at the level below the link; it is now refused with `EEXIST` at the link itself. + + Nothing else changes. A directory that can be made is still made, every missing level of it; a directory that exists, or a symbolic link to one, is used as it stands; and a path the operating system refuses for any other reason (`ENOENT`, `ENOTDIR`, a regular file holding the directory's name) is refused with the same code and text as before. +- 7715d04: `os serve` says once when it cannot write its runtime state file, naming the file and the operating system's error, and goes on booting + + Clause-②: no + + - **Before:** when `runtime...json` could not be written — an `OS_HOME` under `/proc`, or one below a regular file (`ENOTDIR`) — the write's catch printed nothing. The boot printed its ready banner, an `os dev` parent got its `objectstack:listening` message, and a supervisor that then read the file got `ENOENT` with no reason anywhere. + - **Now:** one warning on stderr, among the other boot warnings and before the banner, names the file it could not write and carries the operating system's error verbatim (for example `ENOTDIR: not a directory, stat '…'`). It says the server runs on without the file and that `OS_HOME` must point at a directory the process can write. + - **Unchanged:** the file stays best-effort, so the boot never fails on it; its name, its payload and its removal on exit; the banner and the IPC message; stdout, which stays the stdio MCP transport's alone. +- f963d8e: fix(cli): `os migrate plan` and `os migrate apply` examine the objects a `datasourceMapping` rule routes to a declared datasource, the declared default included, in that datasource, as `os serve` connects it (#24120) + + Clause-②: no + + A project whose primary store is a declared datasource routed as the default (`datasources: [{ name: 'qa_main', … }]` with `datasourceMapping: [{ default: true, datasource: 'qa_main' }]`) got a plan that examined nothing. The plan resolved `qa_main`'s database as its primary, but it registered no driver under the name `qa_main`, so every object the rule routes there, platform objects included, was bound to no driver. The plan printed `Examined 0 managed table(s)` and a PARTIAL plan. Meanwhile `os dev` on the same project reported drift in that database and named `os migrate` as the remedy. + + - **Now:** `plan` and `apply` connect the project's declared **managed** datasources the way `os serve` connects them: through the same `datasource-connection` service, under the declared name, for the datasources a mapping rule routes objects to, an object binds to, or that set `autoConnect`. Each datasource that objects live in is diffed and applied beside the primary. A composition note names each one and its database, in both the human output and the `--json` output. On a `datasourceMapping`-default project, the plan's drift is the drift `os dev` reports there. + - **Not connected:** a datasource whose schema the remote owns (`schemaMode: 'external'` or `'validate-only'`). Its objects are federated, and a plan has no table on it. + - **Refused now:** a routed managed datasource that cannot connect fails `plan`, `apply` and `unmapped-columns` with the connect error `os serve` gives for it. It is no longer skipped with a PARTIAL plan. `OS_ALLOW_DRIVER_CONNECT_FAILURE` degrades it here as it does there. + - **`os migrate unmapped-columns` follows:** it filters the plan's own findings, so it composes the same datasources. On such a project it reads an object's unmapped columns from the declared datasource. Before, it refused with "not in the set os migrate plan diffs: it is bound to no driver". + - **Unchanged:** a project that declares no datasource gets the plan it got before. `os migrate security-catalog-overlays` and `organization-ownership` boot as before. The unmanaged-table sweep's `physicalTables` counts each database once, although the primary and a declared default resolved from it are two connections to one file. +- b7f29dd: Clause-②: no + + fix(cli): `os serve` with the stdio MCP transport and no config argument answers the `initialize` an MCP client writes when it spawns the server, and boots the configured app + + - **What was wrong.** An MCP client writes `initialize` to the server's stdin as soon as it spawns it. The CLI's argument parser fills an omitted positional argument from a pending stdin line, and `os serve`'s config path is an optional positional. So, started without a config path, `os serve` took the client's `initialize` as its config path. In a project with a compiled `dist/objectstack.json`, it printed `No objectstack.config.ts found — booting from artifact`, booted without the config's own plugins, and never answered `initialize`. `os start` already named the path when it started `os serve`; this is the same defect for `os serve` started directly. + - **What changed.** `os serve` no longer reads its config path from stdin. Without the argument it uses `objectstack.config.ts` in the working directory, as its help says, and the client's first message stays in the pipe for the stdio transport. Reading the config path from stdin was never documented, and no script, test or doc in this repository relied on it; to serve another config, pass its path as the argument. + - **Unchanged:** the flags and environment variables of `os serve`, and which config and artifact a boot uses when the path is given or when nothing waits on stdin. +- a8adf66: Clause-②: no + + fix(cli): on a first run, `os start` writes its compile step's progress to stderr, so stdout carries only the stdio MCP transport's JSON-RPC frames + + - **What was wrong.** With `OS_MCP_STDIO_ENABLED=true`, stdout is the stdio MCP transport's protocol channel, and an MCP client parses every line it reads there as JSON-RPC. `os start` already writes its own banner and progress to stderr, but in a project with no `dist/objectstack.json` it compiles the config first, and that compile step wrote its progress (`◆ Compile`, `✓ Build complete`, `Artifact: …`, 24 lines for a one-object app) to stdout, ahead of the answer to the client's `initialize`. + - **What changed.** The automatic compile step's output now goes to stderr, next to the rest of `os start`'s own output, with or without the stdio transport. Nothing is dropped: the same lines still print, on stderr. + - **Unchanged:** the flags and environment variables of `os start` and `os compile`, when `os start` compiles, the artifact it writes, and `os compile`'s own output when you run it directly. +- 51ad8e4: Clause-②: yes (widening) + + fix(cli,runtime): the 17 → 18 organization-ownership upgrade of a single-posture database that holds no organization leaves no row without one, the boot the ceremony prescribes writes nothing before it is refused, and a ceremony that did not verify is never served (ADR-0131 D3, D10) + + A 17.x single-posture install whose seed ran before any organization existed holds rows of organization-owned objects with `organization_id` NULL and an empty `sys_organization`. Upgrading it took this path: the ceremony's attribution checkpointed itself done at zero rows; the boot its rewrite step asks for created the Default Organization and let the inline seed stamp some rows before it refused; the second `--apply` did not run attribution again, dropped the columns and recorded discrepancies with `verified_at` NULL; and `os start` then served that database. + + - **The ceremony creates the Default Organization (`@objectstack/cli`).** Under the `single` posture, on a database that holds no organization and rows no anchor names, `os migrate organization-ownership` plans those rows as the Default Organization's and says so in a plan warning (`defaultOrganizationCreatedByApply: true` in the plan file). `--apply` creates it before its first write, through plugin-auth's boot invariant (the row a single-posture boot creates), and gives it every such row. A run that cannot create it — a boot without the auth family that declares `sys_organization` — blocks at attribution, naming why, instead of attributing nothing and certifying. Nothing is created under a walled posture, or when the database holds organizations. + - **The ceremony names the Default Organization as the boot does (`@objectstack/cli`).** Under `single` it is the organization with `slug = 'default'`, else the sole organization whatever its slug — the boot invariant's and the tenancy service's definition (ADR-0093 D3). Before, the plan required `slug = 'default'`, so a database whose only organization had another slug kept its unanchored rows NULL through the ceremony while v18 derives every owner from that organization. Several organizations, none of them `default`, still name none, and their rows are reported. In the plan file (the command's own output, not a published schema) `defaultOrganization.slug` is now that organization's own slug, no longer always `'default'`. + - **A checkpointed attribution runs again (`@objectstack/cli`)** whenever the plan derives an owner for rows still NULL: a row the operator made attributable since, or one an earlier run found no Default Organization for. It writes only where `organization_id` is NULL, so no owner already written is derived again. A database left by an earlier release's ceremony in that state is completed by re-running `--apply`. + - **The boot guard judges before any plugin writes (`@objectstack/runtime`, `@objectstack/plugin-auth`).** `OrganizationOwnershipBootGuardPlugin` judges in its own `start()`, after the engine's schema sync (the column the rewrite step waits for is still added) and before every plugin that writes rows at start: `AppPlugin` (the inline seed), `AuthPlugin` and the identity-objects plugin (the Default Organization) name it (`com.objectstack.organization-ownership-boot-guard`) in `optionalDependencies`. A refused boot leaves every row as it found it. + - **A ceremony that did not verify is refused (`@objectstack/runtime`).** With no table left in the v17 shape, a boot whose `adr-0131-organization-ownership` marker exists and is not verified — the post-check found rows the ceremony has not carried — is refused with `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`, naming the re-run. A database with no marker (a fresh one) boots as before. + - **The refusal now arrives as a `cause` (`@objectstack/runtime`).** A failed `start()` is reported by the kernel as its own error ("Plugin … failed to start"), with the plugin's error as its `cause`. A host that composes the guard and inspects the boot error asks `isOrganizationOwnershipBootRefusal` of each `cause` down the chain: asked of the kernel's own error it now answers `false`. `assertOrganizationOwnershipMigrated(engine)` still throws the refusal itself. `os serve` and `os dev` read it from the chain. + - **One new export (`@objectstack/plugin-auth`, `minor`).** `ensureDefaultOrganizationExists(ql, options?)` — the Default Organization's boot invariant, find-or-create under `single` — for the ceremony, so the organization it creates is the row a single-posture boot creates, from one producer. A host composing a kernel still never calls it: `AuthPlugin` and the identity-objects plugin keep the invariant on every boot. No other package entry changes. + + Unchanged: the ceremony's steps, fates and refusals otherwise; a walled posture's unattributable rows (reported, never assigned); the boot of a fresh or verified database; and every plugin's order on a kernel that does not compose the guard. Repairing a database whose organization-scoped seed load already inserted twins of NULL-organization rows is an operator's data repair. +- 538f923: fix: an activity row is written only when an organization is derivable, never with a NULL organization, and the v18 ownership ceremony deletes the existing NULL activity rows it cannot attribute (ADR-0131 D9) + + Clause-②: no + + - **`@objectstack/plugin-audit`.** The audit writer's activity mirror (`sys_activity`) takes its organization from the record, then from the acting session, then from the `tenancy` service's Default Organization under `single`. When none of them names one, the mirror now writes no activity row at all. Before, it wrote a row with `organization_id` NULL. Under a wall that row was readable by no organization member. The change is still recorded: the compliance ledger row (`sys_audit_log`) is written as before, with its `tenant_id` attribution. Under `single` the Default Organization is always derivable, so nothing changes there. A record that names its organization keeps its activity row, with that organization. + - **`@objectstack/cli`.** `os migrate organization-ownership` still attributes a NULL `sys_activity` row when its record carries an organization. On a walled database (any posture but `single`), the mirror-deletion step now deletes the NULL rows no anchor attributes, such as activity about a sign-up's `sys_user`. Before, those rows were reported as unattributable and held certification. They are 14-day telemetry that the mirror no longer writes, and `sys_audit_log` keeps each change. The plan counts them as the departure `underivable-activity`, and warns before the first write that `--apply` deletes them. The apply report lists the count as the table's `deleted`. Under `single` the Default Organization takes these rows, as before, and nothing is deleted. +- bef7bdc: fix(cli): `os migrate organization-ownership` plans a v17 database that still holds the retired `sys_scim_provider` table, listing it as fate 4 instead of refusing it (ADR-0131 D10) + + Clause-②: no + + `sys_scim_provider` was retired from the platform before the ceremony's inventory was taken, so the inventory had no row for it. Its ADR-0087 entry (`scim-provider-object-retired`) leaves the table in place, untouched, by ruling, so every database that booted a release from before the retirement still holds it, `organization_id` column included. The plan refused such a database with `uninventoried-platform-table` (`sys_scim_provider carries organization_id but the ADR-0131 D10 inventory gives it no fate`), and so did `--apply`, so the ceremony could not run there at all and `os dev` / `os serve` stayed refused. + + - **Now** the inventory lists `sys_scim_provider` with fate 4 (`report`), under a new census population, `retired-before-census`. The plan reports its rows and its NULL-organization rows by id. It never attributes them, not even to the Default Organization under `single`, and never deletes them. + - **NOT NULL:** the table is exempt, so the plan reports it as not receiving NOT NULL. The object is retired, no declaration anchors tenancy on the column, and a SCIM connection scoped to no organization was a legitimate row. + - The plan's `inventoryDigest` changes with the new row. A plan file written by an earlier CLI no longer matches, so `--apply --plan-file` refuses it with `plan-digest-mismatch`. Re-run `os migrate organization-ownership --out PLAN`, review it, and apply that. +- Updated dependencies [5a871e4] +- Updated dependencies [5a871e4] +- Updated dependencies [f8ef144] +- Updated dependencies [5b14b7f] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [666283a] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [f72ce93] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [3ae0b77] +- Updated dependencies [913fd6d] +- Updated dependencies [913fd6d] +- Updated dependencies [8eac321] +- Updated dependencies [de43524] +- Updated dependencies [a5a781b] +- Updated dependencies [cbe6d87] +- Updated dependencies [04669a4] +- Updated dependencies [f7eb860] +- Updated dependencies [92ce3af] +- Updated dependencies [b61353f] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [4840720] +- Updated dependencies [23dae89] +- Updated dependencies [c395405] +- Updated dependencies [29527f2] +- Updated dependencies [7c7741d] +- Updated dependencies [1e8b628] +- Updated dependencies [4ff18d3] +- Updated dependencies [3566c08] +- Updated dependencies [9f12d06] +- Updated dependencies [65a8eeb] +- Updated dependencies [5018fc3] +- Updated dependencies [31e0735] +- Updated dependencies [bfc1539] +- Updated dependencies [3b8e2b1] +- Updated dependencies [14ccb1d] +- Updated dependencies [8fd3944] +- Updated dependencies [7879391] +- Updated dependencies [a789148] +- Updated dependencies [7c509c2] +- Updated dependencies [d61ed6a] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [959c209] +- Updated dependencies [c28f317] +- Updated dependencies [17e4425] +- Updated dependencies [7d7943d] +- Updated dependencies [aa71c4d] +- Updated dependencies [19bb55e] +- Updated dependencies [19bb55e] +- Updated dependencies [ba8af87] +- Updated dependencies [6a49efb] +- Updated dependencies [99801d8] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [5c9bfb0] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [e7e6544] +- Updated dependencies [e7e6544] +- Updated dependencies [cd3d391] +- Updated dependencies [149294c] +- Updated dependencies [990341a] +- Updated dependencies [9646991] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [2fbe1fd] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [d112087] +- Updated dependencies [e078506] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [8c105e3] +- Updated dependencies [e02833c] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [9847f87] +- Updated dependencies [78652c0] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [19805d8] +- Updated dependencies [1eae21f] +- Updated dependencies [54f062b] +- Updated dependencies [06dbc4e] +- Updated dependencies [9f4b652] +- Updated dependencies [965a1bf] +- Updated dependencies [eab7236] +- Updated dependencies [f93bf12] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [a7a48b7] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [1abfc58] +- Updated dependencies [8a399b2] +- Updated dependencies [db87a02] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [6befe19] +- Updated dependencies [8caa131] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [f0022c4] +- Updated dependencies [1fb274e] +- Updated dependencies [1fb274e] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [f85a83b] +- Updated dependencies [2015c54] +- Updated dependencies [0db5ad5] +- Updated dependencies [8601526] +- Updated dependencies [73a0a6b] +- Updated dependencies [f2a45db] +- Updated dependencies [aa9447c] +- Updated dependencies [3d91885] +- Updated dependencies [8fc50b7] +- Updated dependencies [93125ae] +- Updated dependencies [29678f2] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [ae97841] +- Updated dependencies [56bf27a] +- Updated dependencies [e67ba80] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [4935c66] +- Updated dependencies [c6fe02d] +- Updated dependencies [c6fe02d] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [6ff6ed6] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [6c17a50] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [98998ca] +- Updated dependencies [3aa0c7b] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [5f98778] +- Updated dependencies [036bf1d] +- Updated dependencies [bb4f5cc] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [d8c7d38] +- Updated dependencies [2e11d90] +- Updated dependencies [7dbb89d] +- Updated dependencies [6a7eca6] +- Updated dependencies [63d5db2] +- Updated dependencies [a9bcbc6] +- Updated dependencies [9fbabd7] +- Updated dependencies [33ae4bf] +- Updated dependencies [ebbb8f9] +- Updated dependencies [07ee6f6] +- Updated dependencies [61bea24] +- Updated dependencies [762db99] +- Updated dependencies [d1c226d] +- Updated dependencies [996aa86] +- Updated dependencies [23419ba] +- Updated dependencies [54d32e5] +- Updated dependencies [c911ab6] +- Updated dependencies [e375315] +- Updated dependencies [cbbd96a] +- Updated dependencies [b6805ac] +- Updated dependencies [9c74d40] +- Updated dependencies [2e11d90] +- Updated dependencies [31cd210] +- Updated dependencies [7518ee3] +- Updated dependencies [4049cac] +- Updated dependencies [7ebbc01] +- Updated dependencies [0e9371f] +- Updated dependencies [0b7ba4d] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [ffb31fc] +- Updated dependencies [0b997ea] +- Updated dependencies [d1dbe70] +- Updated dependencies [3513ac7] +- Updated dependencies [35afb15] +- Updated dependencies [81bd9fa] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [0ee2d15] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [79c35d4] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [b1f7a7a] +- Updated dependencies [345d3f3] +- Updated dependencies [f2626c7] +- Updated dependencies [e175479] +- Updated dependencies [59d993c] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [3f80f17] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [fbb065f] +- Updated dependencies [27f0d83] +- Updated dependencies [a8f24b0] +- Updated dependencies [37c7114] +- Updated dependencies [b4ce5e2] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [ca135dc] +- Updated dependencies [e36ee53] +- Updated dependencies [1884948] +- Updated dependencies [3599fef] +- Updated dependencies [8a995b8] +- Updated dependencies [54c3ce1] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [43fc500] +- Updated dependencies [746637e] +- Updated dependencies [00bee27] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [3403be8] +- Updated dependencies [46692c1] +- Updated dependencies [f66c440] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [3ca71b6] +- Updated dependencies [94379f4] +- Updated dependencies [166a94f] +- Updated dependencies [dd986d8] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [7806a14] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [ce3d0ad] +- Updated dependencies [ce3d0ad] +- Updated dependencies [e148ca9] +- Updated dependencies [557ae7c] +- Updated dependencies [f782f17] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [5495331] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [86f53a4] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [d25b187] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d1d42ec] +- Updated dependencies [d303b3e] +- Updated dependencies [018b077] +- Updated dependencies [0fea05f] +- Updated dependencies [e194ab4] +- Updated dependencies [86bf9ed] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [25be876] +- Updated dependencies [ed1de8c] +- Updated dependencies [b98e984] +- Updated dependencies [76bc1e0] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [18d9990] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [874a38d] +- Updated dependencies [3d0eeef] +- Updated dependencies [cb3bb93] +- Updated dependencies [83b8b80] +- Updated dependencies [1b99388] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [a6f3a13] +- Updated dependencies [f53f14b] +- Updated dependencies [e8c6666] +- Updated dependencies [a800912] +- Updated dependencies [4b7c630] +- Updated dependencies [87cd458] +- Updated dependencies [86da194] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [156ddfa] +- Updated dependencies [6981246] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [c63028e] +- Updated dependencies [243dd3c] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [5da2c0f] +- Updated dependencies [3b5475a] +- Updated dependencies [7399080] +- Updated dependencies [e04759d] +- Updated dependencies [0f77ff5] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [9f15939] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [a360cee] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [9859e4e] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [896a434] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [0984817] +- Updated dependencies [322ff1e] +- Updated dependencies [680a86b] +- Updated dependencies [d952358] +- Updated dependencies [7b0a9c9] +- Updated dependencies [8bd0fcd] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [75b6d6a] +- Updated dependencies [c4e7fa5] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [f313fbc] +- Updated dependencies [9814035] +- Updated dependencies [f9c7588] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [c11b758] +- Updated dependencies [f9ebb7d] +- Updated dependencies [bb0db13] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [eed637c] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [3d830b7] +- Updated dependencies [777eed2] +- Updated dependencies [8258181] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [3fdb92d] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [e4ad677] +- Updated dependencies [6a40a09] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [117817e] +- Updated dependencies [4663801] +- Updated dependencies [8e4a230] +- Updated dependencies [3071d3e] +- Updated dependencies [d294951] +- Updated dependencies [6a6a4d3] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [84c4fff] +- Updated dependencies [32fa194] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [0a29bec] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [0aef788] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [646303e] +- Updated dependencies [e9bc60b] +- Updated dependencies [52ff308] +- Updated dependencies [d1f082d] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [8024e12] +- Updated dependencies [c4b84ad] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [0511341] +- Updated dependencies [a3d47c2] +- Updated dependencies [de24373] +- Updated dependencies [21c8644] +- Updated dependencies [dfb3ba2] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [aa5bfdf] +- Updated dependencies [d4aaeaf] +- Updated dependencies [5d63c37] +- Updated dependencies [dc93781] +- Updated dependencies [4cc3ff9] +- Updated dependencies [20949e7] +- Updated dependencies [3d6392f] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [c6139f2] +- Updated dependencies [3490bf5] +- Updated dependencies [920bc12] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [6f4c518] +- Updated dependencies [08b38cb] +- Updated dependencies [bae5841] +- Updated dependencies [87d0619] +- Updated dependencies [917b1a0] +- Updated dependencies [d9d7edd] +- Updated dependencies [78c13bd] +- Updated dependencies [9543e9c] +- Updated dependencies [fba3712] +- Updated dependencies [4e0838d] +- Updated dependencies [d45d0e9] +- Updated dependencies [cb9841a] +- Updated dependencies [b60eb78] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [0d34066] +- Updated dependencies [0c3db0f] +- Updated dependencies [8d3dd6f] +- Updated dependencies [8ed3786] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [01d5034] +- Updated dependencies [f24574a] +- Updated dependencies [e790cfc] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [09ef77c] +- Updated dependencies [54af6c0] +- Updated dependencies [5d3018d] +- Updated dependencies [404fc47] +- Updated dependencies [be16feb] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [94924e0] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [24e064f] +- Updated dependencies [0f05907] +- Updated dependencies [47d569e] +- Updated dependencies [4f1e435] +- Updated dependencies [2de9c03] +- Updated dependencies [5b68524] +- Updated dependencies [5e725b8] +- Updated dependencies [05e3b8e] +- Updated dependencies [6d96721] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [c245986] +- Updated dependencies [28e294f] +- Updated dependencies [775ed65] +- Updated dependencies [0be162c] +- Updated dependencies [02a691a] +- Updated dependencies [e4ec6ec] +- 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 [b9dbc3d] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- Updated dependencies [1692cd1] +- Updated dependencies [0d784da] +- Updated dependencies [be50633] +- 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 [a35f7a6] +- 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 [1eac7ad] +- Updated dependencies [70d4c06] +- Updated dependencies [989aa48] +- Updated dependencies [b1cfa3d] +- Updated dependencies [56147a8] +- Updated dependencies [23e689d] +- Updated dependencies [a869ea6] +- Updated dependencies [7b64a3d] +- Updated dependencies [9fcea20] +- Updated dependencies [0fb55fc] +- Updated dependencies [19c554d] +- Updated dependencies [5bb9a83] +- Updated dependencies [8226755] +- Updated dependencies [cdc42eb] +- Updated dependencies [7ffc578] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [30b0fc2] +- Updated dependencies [ee0e115] +- Updated dependencies [a6d0715] +- Updated dependencies [c2bb302] +- Updated dependencies [e67ac4b] +- Updated dependencies [4bd7215] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [7dc7d22] +- Updated dependencies [f7cd580] +- Updated dependencies [c9810dd] +- Updated dependencies [9f81260] +- Updated dependencies [e611e2b] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [b513343] +- Updated dependencies [954bf72] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- 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 [ae0ca66] +- 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 [23d0bc7] +- Updated dependencies [14aff67] +- Updated dependencies [7428da0] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [b55be42] +- Updated dependencies [abb6df0] +- Updated dependencies [5b6c257] +- Updated dependencies [0b1179f] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [74b4362] +- Updated dependencies [f7a5ebe] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9d4fb3c] +- Updated dependencies [c5cf546] +- Updated dependencies [9cb9d95] +- Updated dependencies [cf07168] +- Updated dependencies [d795719] +- Updated dependencies [f846cc2] +- Updated dependencies [34b740f] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [94114c1] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [0cfd0c4] +- Updated dependencies [ae4dfaf] +- Updated dependencies [099cc9d] +- Updated dependencies [77efc66] +- Updated dependencies [747c8e9] +- Updated dependencies [6422da5] +- Updated dependencies [6422da5] +- Updated dependencies [2034975] +- Updated dependencies [61e5ee9] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [67e8792] +- Updated dependencies [2e15620] +- Updated dependencies [6d1660d] +- Updated dependencies [0cb45e9] +- Updated dependencies [0b69564] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [b74434c] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [605aa01] +- Updated dependencies [a33f3d5] +- Updated dependencies [6e18016] +- Updated dependencies [d050a8f] +- Updated dependencies [9e797a8] +- Updated dependencies [b25d98a] +- Updated dependencies [75ced06] +- Updated dependencies [75ced06] +- Updated dependencies [5fb8d15] +- Updated dependencies [c4111c6] +- Updated dependencies [2054a08] +- Updated dependencies [5c2ac83] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [03ac4ec] +- Updated dependencies [33228c5] +- Updated dependencies [5e97907] +- Updated dependencies [d731b89] +- Updated dependencies [19955fb] +- Updated dependencies [905f57b] +- Updated dependencies [aefab7c] +- Updated dependencies [66d2757] +- Updated dependencies [c7168ac] +- Updated dependencies [44fe210] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b75129f] +- Updated dependencies [b0a1df0] +- Updated dependencies [6e43b7b] +- Updated dependencies [6dede7a] +- Updated dependencies [727091c] +- Updated dependencies [077b75f] +- Updated dependencies [62f959d] +- Updated dependencies [c1c0a90] +- Updated dependencies [ab3ba2c] +- Updated dependencies [8692a08] +- Updated dependencies [d6bd98a] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [62c4441] +- Updated dependencies [f792290] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [544b8d7] +- Updated dependencies [2bcaefb] +- Updated dependencies [45e6001] +- Updated dependencies [f3174ef] +- Updated dependencies [e46ce7b] +- Updated dependencies [d702b18] +- Updated dependencies [a06f0d4] +- Updated dependencies [a06f0d4] +- Updated dependencies [a06f0d4] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [9975c26] +- Updated dependencies [2ac0616] +- Updated dependencies [2ac0616] +- Updated dependencies [2ac0616] +- Updated dependencies [265ef64] +- Updated dependencies [5a9a80b] +- Updated dependencies [1489952] +- Updated dependencies [04fd88a] +- Updated dependencies [aaaeeac] +- Updated dependencies [6973014] +- Updated dependencies [669296b] +- Updated dependencies [8eccf51] +- Updated dependencies [8a88901] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [aa31569] +- Updated dependencies [8259ea0] +- Updated dependencies [9040945] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [d537a31] +- Updated dependencies [d537a31] +- Updated dependencies [1bf5f6e] +- Updated dependencies [1bf5f6e] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [2bcd406] +- Updated dependencies [e74ec56] +- Updated dependencies [2bcd406] +- Updated dependencies [2bcd406] +- Updated dependencies [58a2faa] +- Updated dependencies [e74ec56] +- Updated dependencies [58a2faa] +- Updated dependencies [2bcd406] +- Updated dependencies [e74ec56] +- Updated dependencies [af284b0] +- Updated dependencies [cbfa5e4] +- Updated dependencies [14a8bd2] +- Updated dependencies [77e9f79] +- Updated dependencies [83dfbc0] +- Updated dependencies [9822b05] +- Updated dependencies [889bf12] +- Updated dependencies [7a8733f] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [e15897c] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [d79dc48] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [5da2e88] +- Updated dependencies [51ad8e4] +- Updated dependencies [f5e98a3] +- Updated dependencies [ec749fb] +- Updated dependencies [ec749fb] +- Updated dependencies [c4526a7] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [85be049] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [41c7405] +- Updated dependencies [370833a] +- Updated dependencies [2f569a9] +- Updated dependencies [69147ab] +- Updated dependencies [3a734e0] +- Updated dependencies [6868514] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [9112bcd] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [94e60c0] +- Updated dependencies [538f923] +- Updated dependencies [9524666] +- Updated dependencies [d65826f] +- Updated dependencies [741aa85] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf16a0d] +- Updated dependencies [cf540a7] +- Updated dependencies [cf540a7] +- Updated dependencies [cf540a7] +- Updated dependencies [d8e11b5] +- Updated dependencies [44a5011] +- Updated dependencies [1827571] +- Updated dependencies [adb7910] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [2a1e48a] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [71fa166] +- Updated dependencies [71fa166] +- Updated dependencies [4d12b23] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [e22defb] +- Updated dependencies [45bec43] +- Updated dependencies [45bec43] +- Updated dependencies [33a45cc] +- Updated dependencies [964a237] +- Updated dependencies [b7a688e] +- Updated dependencies [3b086e7] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [53e3638] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [a69a40a] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [e66f13c] +- Updated dependencies [140238a] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] +- Updated dependencies [cdf3777] + - @objectstack/core@18.0.0 + - @objectstack/metadata-protocol@18.0.0 + - @objectstack/objectql@18.0.0 + - @objectstack/runtime@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/service-automation@18.0.0 + - @objectstack/plugin-approvals@18.0.0 + - @objectstack/formula@18.0.0 + - @objectstack/lint@18.0.0 + - @objectstack/service-analytics@18.0.0 + - @objectstack/verify@18.0.0 + - @objectstack/plugin-audit@18.0.0 + - @objectstack/plugin-sharing@18.0.0 + - @objectstack/plugin-webhooks@18.0.0 + - @objectstack/service-settings@18.0.0 + - @objectstack/plugin-email@18.0.0 + - @objectstack/plugin-auth@18.0.0 + - @objectstack/metadata@18.0.0 + - @objectstack/service-storage@18.0.0 + - @objectstack/console@18.0.0 + - @objectstack/plugin-security@18.0.0 + - @objectstack/cloud-connection@18.0.0 + - @objectstack/driver-sql@18.0.0 + - @objectstack/rest@18.0.0 + - @objectstack/client@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/service-realtime@18.0.0 + - create-objectstack@18.0.0 + - @objectstack/service-messaging@18.0.0 + - @objectstack/service-datasource@18.0.0 + - @objectstack/types@18.0.0 + - @objectstack/plugin-hono-server@18.0.0 + - @objectstack/mcp@18.0.0 + - @objectstack/service-sms@18.0.0 + - @objectstack/trigger-api@18.0.0 + - @objectstack/service-mailbox@18.0.0 + - @objectstack/service-package@18.0.0 + - @objectstack/driver-turso@18.0.0 + - @objectstack/driver-mongodb@18.0.0 + - @objectstack/service-job@18.0.0 + - @objectstack/driver-sqlite-wasm@18.0.0 + - @objectstack/driver-memory@18.0.0 + - @objectstack/plugin-pinyin-search@18.0.0 + - @objectstack/service-cache@18.0.0 + - @objectstack/service-queue@18.0.0 + - @objectstack/trigger-record-change@18.0.0 + - @objectstack/trigger-schedule@18.0.0 + - @objectstack/account@18.0.0 + - @objectstack/setup@18.0.0 + - @objectstack/observability@18.0.0 + ## 18.0.0-next.5 ### Minor Changes diff --git a/packages/cli/package.json b/packages/cli/package.json index 25c5b29f0e2..bd216edb052 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "18.0.0-next.5", + "version": "18.0.0", "description": "Command Line Interface for ObjectStack Protocol", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/client-react/CHANGELOG.md b/packages/client-react/CHANGELOG.md index 632565be872..f0d9534f1a5 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,387 @@ # @objectstack/client-react +## 18.0.0 + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [4f1e435] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [a1285f2] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [361a1c4] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [6d1660d] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/client@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index 1279300e099..5d876b9295c 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "React hooks for ObjectStack Client SDK", "main": "dist/index.js", diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index 58333d0d728..8cf32cb7014 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,551 @@ # @objectstack/client +## 18.0.0 + +### Minor Changes + +- 6a3fe25: feat(spec,service-storage,client)!: one upload-scope list — the upload requests' `scope` and the SDK's `storage.upload` close to the new `UploadScope` enum, the `sys_file` scope select is built from it, and the upload doors answer any other scope with `400` naming the allowed values instead of `500 INTERNAL` (#22470) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published request contract and on the SDK method that sends it, shipped as `minor` under the launch-window convention for accept-set narrowings, plus one new export. + + The presigned and chunked upload requests declared `scope` an open string, while the stored file record (`sys_file.scope`, a closed select) only ever took `user`, `tenant`, `private`, `temp` and `attachments`. Any other value reached the `sys_file` insert, the data engine refused it as an invalid option, and the upload door answered that caller error as `500 INTERNAL`, with a message telling the operator to restore the data engine. + + ### What changes + + - **`UploadScopeSchema` / `UploadScope`** (`@objectstack/spec`, new, exported from `@objectstack/spec/api`): the upload-scope vocabulary, declared once — `user`, `tenant`, `private`, `temp`, `attachments`. It is a different list from `StorageScopeSchema`, which classifies a storage configuration and is not read by any upload. + - **`GetPresignedUrlRequestSchema.scope` and `InitiateChunkedUploadRequestSchema.scope`** read it. The default stays `user`. A literal outside the list fails `tsc`, and a parse refuses it on the `scope` key. + - **`client.storage.upload(file, scope)`** (`@objectstack/client`): the `scope` parameter is typed `UploadScope` instead of `string`, default `user` unchanged, so a scope outside the list fails `tsc` at the SDK call. `client.storage.getPresignedUrl` and `client.storage.initChunkedUpload` take the request types above and narrow with them. + - **The `sys_file` scope select** (`@objectstack/service-storage`) takes its options from the enum, in its order, with the same labels. The stored values do not change. + - **The presigned upload door and the chunked upload door** answer a scope outside the list — any string, a case variant, `null`, a number — with `400 INVALID_REQUEST`, naming the allowed values, before a file record, a session record, an upload URL or a backend upload exists. `public` is refused by the same gate and keeps its remedy (`acl: 'public_read'` on the stored file record). An omitted scope is the default `user`, as before. A real data-engine fault still answers `500`. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | an upload naming a scope outside the list (a key prefix such as `avatars`, a folder, a record path) | one of `user`, `tenant`, `private`, `temp`, `attachments`, or no `scope` for the default `user` | + | `client.storage.upload(file, scope)` called with a `scope` typed `string` (`@objectstack/client`) | pass one of the five, or type the value `UploadScope` (`import type { UploadScope } from '@objectstack/spec/api'`) | + | a caller passing a scope typed `string` into either upload request | type it `UploadScope` | + + **The one-line fix: send one of the five upload scopes, or none.** `attachments` is for a file whose referrers are record attachment rows (it is what orphan tombstoning reads); `temp` for a scratch file; otherwise `user` or `tenant`. + + **No stored file record moves**: no upload naming another scope ever succeeded, so no stored record carries one. + + ### The kit + + - **D3 entry `upload-request-scope-closed`** carries the judgement no rewrite can make: which scope a file that was sent under a free name really belongs under. No D2 conversion: no metadata type carries the upload request, so `os migrate meta` has no authored source to rewrite. + - **Liveness.** No ledger row: the liveness ledger walks metadata types, and no metadata type carries the upload request. +- 490cb6d: A write answer now carries the advisory validation-rule hits of that write as `warnings` + + Clause-②: yes (widening) + + A `severity: 'warning'` or `'info'` validation rule never blocks a write. Until now its hit was only logged on the server, so no client could show the person who saved what the rule says. A create, an update or a clone now answers the hits of that one write, and the validate-only preview reports the same entries. + + - **Write answers.** `CreateDataResponseSchema`, `UpdateDataResponseSchema` and `CloneDataResponseSchema` declare an optional `warnings`: one entry per advisory rule hit, carrying the rule's `name` as `rule`, its `severity`, the `field` it is about, the finding `code` and the author-written `message`. The key is absent when there is no hit, so an existing client's reading of the answer does not move. REST relays it in the response body unchanged. The write's status and record are unchanged, and an `error` rule still refuses the write. + - **One element, shared with the preview.** `ValidateDataIssueSchema` gains optional `rule` and `severity`, set only on an advisory rule hit. It is the element of the new `warnings`. `validateData` appends the advisory hits of the same evaluation to each accepted row's existing `results[].warnings`, after the value-shape findings the deployment admits. An import's dry run copies that array to the row verbatim, as before. + - **Engine.** `WriteObservabilityOptions` gains `onValidationAdvisory`, an in-process listener beside `onFieldsDropped`, with its event schema `ValidationAdvisoryEventSchema`. The engine calls it once per advisory hit on the write it was passed to: per row on `insert`, and on `update` by id and per matched row of a `multi` update. A nested write a hook or a flow makes inside that write carries its own options, so its hits never reach the outer write's listener or answer. `evaluateValidationRules` returns its advisory hits (an empty array when there are none) instead of `void`. + - **Unchanged.** Rule semantics, the per-write `warn` log line and the seed and boot load's one summary line per rule are all as before. No authorable key is added: `ValidationRuleSchema` is untouched. A rule whose condition could not be evaluated is not a hit. Its fault text is for the operator, so it stays in the server log only. + - **Client.** `CreateDataResult`, `UpdateDataResult` and `CloneDataResult` declare `warnings?: ValidateDataIssue[]`. + + **For authors.** Nothing to change in metadata. Write each advisory rule's `message` for the person saving the record, because a client can now show it to them. +- 4f1e435: feat(client): `meta.deleteItem` can name the package whose row it removes (`packageId`, sent as `?package=`), so a caller can follow the `409 RESOURCE_CONFLICT` remedy the reset door prescribes for an item two packages hold (#23293) + + Clause-②: yes (widening) + + **What is new.** `DeleteMetaItemOptions` gains `packageId?: string`. Both `deleteItem` declarations take it, the unscoped `ObjectStackClient.meta` and the environment-scoped `client.environment(id).meta`, and send it as `?package=` through the one query builder they share. The name, the query key and the omission rule are the ones `saveItem`'s `packageId` already uses: 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). The REST reset door `DELETE /api/v1/meta/:type/:name` reads `?package=` and refuses a delete that names no package of an item two or more packages hold, with `409 RESOURCE_CONFLICT`. The refusal names the packages and the remedy, which is to name one with `?package=`. 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.deleteItem('view', 'shared_grid'); + + // TO: removes com.acme.crm's row of the item and no other package's row + await client.meta.deleteItem('view', 'shared_grid', { packageId: 'com.acme.crm' }); + ``` + + **Unchanged.** A delete without `packageId` sends the same request as before: no query string unless `state: 'draft'` is set, and no header unless `ifMatch` is set. `packageId` combines with `ifMatch` and `state` (`?state=draft&package=`). `?dropStorage` is still not an option. +- 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. +- 1e001c7: feat(client): `packages.publishDrafts(id, { backfill })` sends the opt-in backfill of new fields' defaults + + Clause-②: yes (widening) + + - `opts.backfill?: Array<{ object, field }>` is sent in the publish body; the response's `backfillApplied` reports the rows written per field. A field the publish does not add, or one without a literal `defaultValue`, refuses the whole request with a `400` before anything is published. + +### Patch Changes + +- d112087: docs: the suggested-binding acceptance is deployment-level and takes effect + + Clause-②: no + + Text only. These are the published sentences that describe what confirming a package's `isDefault` suggestion does. They now match the behaviour `@objectstack/plugin-security` ships in this release. + + - `@objectstack/spec`: `ISecurityService.listAudienceBindingSuggestions`, `confirmAudienceBindingSuggestion` and `dismissAudienceBindingSuggestion` keep "writes the binding" for the confirm. The text now also says what that means: a `manage_metadata` holder accepts once for the deployment, `everyone`'s definition is derived again, and a dismiss on an accepted suggestion revokes it. The package-level `isDefault` note keeps "an admin confirms in Setup" and adds who that admin is. + - `@objectstack/client`: the `security.suggestedBindings.confirm` and `.dismiss` TSDoc. + - `@objectstack/rest`: the route summaries of the confirm and dismiss routes. Paths and envelopes are unchanged. + - `@objectstack/cli`: the ownership-inventory citation for `sys_audience_binding_suggestion` now says the record is deployment-level and owned by the Default Organization. +- 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. +- 6d1660d: Clause-②: no + + fix(client): `client.automation.getRuntimeStatus()` types its rows as the declared contract `FlowRuntimeState` (#23859) + + **What was wrong.** The method declared its answer as `{ flows: Array<{ name, enabled, bound }>; total }`. `GET /automation/_status` serves the automation engine's `getFlowRuntimeStates()` rows unchanged, and each row is the contract `FlowRuntimeState` from `@objectstack/spec/contracts`. The SDK's type therefore hid five optional fields the wire already carries: `status`, `triggerType`, `object`, `reason` (one sentence saying why an enabled, triggered flow is not armed) and `unboundCause` (which of the closed `FLOW_UNBOUND_CAUSES` that sentence describes). A caller that read `row.reason` or `row.unboundCause` got a type error and either cast past it or concluded the field did not exist. + + **What changes.** The return type is now `Promise<{ flows: FlowRuntimeState[]; total: number }>`. `name`, `enabled` and `bound` stay required, so existing callers compile unchanged. Read `row.unboundCause` to classify an unarmed flow, and render `row.reason` without parsing it. The request, the route and the values on the wire are unchanged. +- 66d2757: feat(spec)!: every installed-package row declares where it came from, a closed `source: 'code' | 'marketplace' | 'authored'`, and the `GET /packages` rows declare the `writable` verdict they already served (#23972) + + Clause-②: yes (narrowing — the served row gains two declared keys, and the record schema, the served-row union and the registry's installPackage signature each refuse what they accepted before) + + **BREAKING** — three published accept sets narrow. `InstalledPackageSchema` refuses a row without `source`, or with a value outside `code` / `marketplace` / `authored`. The served-row union `InstalledPackageAtEitherStageSchema` refuses a row without a boolean `writable`. `SchemaRegistry.installPackage` and `EngineSchemaRegistryView.installPackage` take `source` as their second argument and refuse a call without it. The migration is below. None of it is mechanically convertible, because only the door that registers a package knows where it came from. + + **What is new on the wire.** `GET /api/v1/packages` and `GET /api/v1/packages/:id` serve two declared keys on every row: + + - `source` names the package's origin. `code` means the deployment's own code registered it (a `defineStack()` host, a plugin's bundled manifest, a platform package). `marketplace` means the marketplace install-local plugin installed it, and that holds after a restart too, when the plugin rehydrates it from its ledger. `authored` means it is a database-authored package (a Studio base, a duplicate, an HTTP install), including after the restart that rehydrates it from `sys_packages`. The door that registers the package stamps the value. No reader derives it. + - `writable` is the server's own writability verdict (ADR-0070 D2), served on these rows since #14375 and now declared. It is computed per read and never stored, so it is declared on the served row (`InstalledPackageAtEitherStageSchema`) and not on the registry record. + + FROM → TO for a reader of these rows: + + | you did | do instead | + | --- | --- | + | treated `writable: true` as authored, and looked up each non-writable row in the marketplace's installed list (the control plane's, else install-local's) to tell marketplace from code | read `row.source` | + | read the undeclared `writable` | read the declared `writable` (unchanged value) | + + The two keys are separate statements: `source` says where the package came from, and `writable` says what this server lets an org do to it. + + **`InstalledPackageSchema` requires `source`.** It is `InstalledPackageSourceSchema`, exported from `@objectstack/spec/kernel`. A row without it, or with a value outside the closed set, is refused at the `source` key. + + **API change for code that registers packages (`@objectstack/objectql`).** The registry records the origin and never infers it: + + | you wrote | write instead | + | --- | --- | + | `registry.installPackage(manifest)` | `registry.installPackage(manifest, 'code')` (or `'marketplace'` / `'authored'`, whichever is where the package came from) | + | `registry.installPackage(manifest, settings, scope)` | `registry.installPackage(manifest, source, settings, scope)` | + | `ctx.getService('manifest').register(artifact)` for a marketplace install | `ctx.getService('manifest').register(artifact, { source: 'marketplace' })` | + + `SchemaRegistry.installPackage` throws a `TypeError` naming the closed set when `source` is missing or unknown, before anything is recorded. `ObjectQL.registerApp(body, scope?, source?)` and the `manifest` service record `'code'` when no source is given. They accept `'code'` or `'marketplace'` and refuse `'authored'`. The `IObjectQLEngine` registry contract (`@objectstack/spec/contracts`) declares the same `installPackage(manifest, source, settings?)`. + + **The one-line fix:** pass the package's origin as `installPackage`'s second argument. + + Readers: objectui's Setup → Packages Source column and its `?source=` filter (it switches from the two-read join to `source` in its own follow-up), and the client SDK's `packages.list()` / `packages.get()` row types. + + +- b7a688e: Clause-②: no + + fix(client,platform-objects): the `ai.models()` TSDoc and the translated permission-set `packageId` help say "this system", as the spec now does + + - **What changed.** Inside the running system, published copy names the system the reader is in as "this system", and a permission set made here, not shipped by a package, is "authored in this system". `@objectstack/spec` already says both (`AiModelsResponse.models`, the permission set's `packageId` help text); this follows it in two more places. + - The TSDoc of `client.ai.models()` now reads "Models this system offers in the chat model picker (cloud ADR-0028)". The citation is qualified as `cloud ADR-0028`: the AI service that serves this list is a cloud package, and this repository's ADR-0028 is about metadata naming. + - The zh-CN, ja-JP and es-ES values of the permission set's `packageId` help text in the Setup forms bundle now say 本系统 / このシステム / este sistema where they said 本环境 / この環境 / este entorno, matching the English text "Leave empty for a set authored in this system." + - **Unchanged.** Every method, route, type, form field, key and behaviour. No translation key is added or removed. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/client/package.json b/packages/client/package.json index 2802941679e..2a5eccbee3a 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "18.0.0-next.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Official Client SDK for ObjectStack Protocol", "main": "dist/index.js", diff --git a/packages/cloud-connection/CHANGELOG.md b/packages/cloud-connection/CHANGELOG.md index 1774be19897..6b92d2578f8 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,987 @@ # @objectstack/cloud-connection +## 18.0.0 + +### Minor Changes + +- 4806b18: Under a walled posture a package that carries security catalog items is the platform operator's: tenants are refused installing, upgrading, uninstalling or deleting it + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant (managed packages, Studio)."). + + **Why.** A package registers its positions, permission sets and capabilities environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack, with an organization owner granted `manage_metadata` inside their own organization (never the platform operator): + - the owner re-installed an operator-installed package at a new version through `install-local`, adding `manage_users` to a permission set another organization's member holds, and that member's next resolution carried `manage_users`; + - the owner's `install-local` uninstall of that package took the set away from that member; + - the owner's `DELETE /api/v1/packages/:id` of a runtime package the operator had saved a permission set into did the same. + + The existing name-conflict refusal stops only a package that shadows a name another holder has, never a write by the package that holds it. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `POST /api/v1/marketplace/install-local` (`@objectstack/cloud-connection`) of a package whose incoming version declares a position, permission set or capability, or, on an upgrade, whose installed version does: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the package is registered or the ledger written. The whole package is refused, since a package installs whole. The message names the posture, and `error.details.catalogItems` lists the items. + - `DELETE /api/v1/marketplace/install-local/:id` of a package whose installed version declares one: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the ledger is touched. + - `DELETE /api/v1/packages/:id` on the runtime dispatcher, of a writable package holding stored positions or permission sets: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is deleted. + + **Unchanged.** A package with no catalog item installs, upgrades and uninstalls as before, for the same callers. The platform operator does all of the above as before. Under `single` (and with no `tenancy` service) nothing changes. Disabling a package is unchanged: it was measured not to change what any organization resolves. + + **The remedy.** The platform operator installs, upgrades and removes catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-writes-retired`. Both doors ask the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), once per catalog type the package declares. `@objectstack/objectql` now exports `declaredSecurityCatalogNames`, the engine's own reading of what a manifest declares, so a package door judges the declarations the engine registers. +- c7d6037: A package declares how it may be installed — `installModes: ['managed', 'unmanaged']` — and `os package install --install-mode unmanaged` copies it once into the environment as the environment's own metadata (ADR-0131 D6) + + Clause-②: yes (widening: the new optional manifest key `installModes`; the new exports `INSTALL_MODES`, `InstallModeSchema`, `InstallMode`, `DEFAULT_INSTALL_MODES`, `resolveInstallModes`, `UNMANAGED_INSTALL_KEY_DISPOSITIONS`, `UNMANAGED_INSTALL_REFUSING_DISPOSITIONS`, `UnmanagedInstallKeyDisposition`, `unmanagedInstallValueCarries` and `unmanagedInstallPostureRefusal`; the install-local body field `installMode`; the CLI option `--install-mode`; the error codes `STACK_UNMANAGED_INSTALL_UNCOPYABLE` and `INSTALL_MODE_NOT_PERMITTED`) + + A package installs one of two ways. **Managed**: it is registered as code, stays the authority, upgrades, and nothing it ships is edited in place. **Unmanaged**: its metadata is copied once into this environment under a new writable base; the copy's items are this environment's own metadata, the next save edits them, and it follows no upgrade. This entry adds the manifest key that declares the modes a package permits, and the install door that honours it; the copy itself is the companion entry `15213-unmanaged-import-verb`. + + - **`installModes`** (`@objectstack/spec`, `ManifestSchema`). Optional, a non-empty list of `'managed'` / `'unmanaged'`. Absent means `['managed']`, read through one resolver, `resolveInstallModes(manifest)`; there is no parse-time default, so no existing manifest changes by a byte. A package may permit `unmanaged` alone. + - **Refused at authoring.** `defineStack` — so `os validate` and `os build` — refuses a package that permits `unmanaged` while it carries what the copy cannot write as environment metadata: a non-empty collection of a type the metadata-type registry declares runtime-uncreatable (`agents`, `apis`, `capabilities`, `jobs`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), or an extension of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`). The refusal is `STACK_UNMANAGED_INSTALL_UNCOPYABLE` (422), one `issues` entry per key. Fix: remove `'unmanaged'` from `manifest.installModes`, or move what cannot be copied out of the package. The install door judges the same body with the same key classification (`UNMANAGED_INSTALL_KEY_DISPOSITIONS`), so a package that passes `os validate` is not refused at install for a key nobody named. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local`). The body takes `installMode: 'managed' | 'unmanaged'`; absent means `managed`, so every existing caller is unchanged. Before anything is written, on the catalog and the air-gapped source alike: + - a mode the package's `installModes` does not permit answers `422 INSTALL_MODE_NOT_PERMITTED`, with `details.permitted`; + - `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value) answers `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture; + - a managed install of a package whose namespace a writable base holds — its unmanaged copy — answers `409 RESOURCE_CONFLICT`, naming that base: a managed install would otherwise take the namespace and stop serving the edited copies. + An unmanaged install writes no install-ledger entry, registers nothing under the package's id and does not load its sample data (the answer says so under `seeded`). The answer names the new base, the items copied and `installedFrom` (the source package id and version, for information only). The package's translation bundles are copied as `translation` items, so they reload at every boot with the environment's own translations. + - **`os package install --install-mode managed|unmanaged`** (`@objectstack/cli`). Without the option, nothing is sent and the runtime installs managed. An unmanaged answer prints the base, the item count and the source. A runtime that predates the install mode ignores the option and installs managed; the command then exits 1 and says so. The command's help now calls the catalog and the air-gapped artifact the two *sources*, so "mode" means one thing. + - **Copy semantics** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` copies `translations` (each locale of each bundle as one `translation` item named `_`, bundles merged in order), reads `installModes` without writing it onto the base, and no longer refuses an empty code container (`functions: {}`, `plugins: []`). `unmanagedInstallPostureRefusal` is exported, so the door and the import answer the walled posture with one sentence. + + Nothing existing is removed: every new key, field and option is optional, and an install that does not name a mode is managed, as before, with one stricter answer. That answer is the refusal above: a managed install of a package whose namespace a writable base holds now answers `409 RESOURCE_CONFLICT` before anything is written, where a catalog install of it answered `200`. +- 2d21b9c: An unmanaged install no longer copies a package's protection lock, in either spelling: every copied item is editable, and the install's answer names each lock it dropped with the package's reason (ADR-0131 D6, ADR-0010) + + Clause-②: yes (widening: the new response field `strippedLocks` on `ImportUnmanagedPackageResponseSchema`, relayed on the install-local answer for `installMode: 'unmanaged'`) + + An unmanaged install copies a package into this environment as the environment's own metadata. A package can lock an item in two spellings: the `_lock` envelope (`_lock`, with `_lockReason`, `_lockSource` and `_lockDocsUrl`), or the author-facing `protection: { lock, reason, docsUrl }` block that a managed install translates into it. Until now an item locked with `_lock` was copied with that lock, so the next save of the copy answered `403 ITEM_LOCKED`; an item locked with `protection` was copied with an inert block that bound nothing, so the vendor's lock vanished without a word. The package permitted the unmanaged install through its `installModes`, so it chose to let environments take editable copies; a lock whose source is the package does not survive the copy. + + - **The copy** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` writes every item without `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl`, and, on a type whose schema declares one, without its `protection` block. Its answer gains `strippedLocks`: one entry per lock that bound (`no-overlay`, `no-delete` or `full`), in the order the items were written — `{ type, name, lock, reason? }`, with `reason` the package's reason verbatim and absent when it gave none. Each entry is the lock a managed install of the same item would have bound: an item carrying both spellings is reported once, with the `protection` block's lock and reason, because that is the one a managed install binds. A package that locks nothing answers `strippedLocks: []`. + - **Refusals are unchanged.** A lock the spec's declarations refuse (an unknown `_lock` value, a reason that is not a string, a malformed `protection` block) is not stripped: the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back. So is a `protection` block on a type whose schema does not declare one (`action`, `hook`, `page`). A managed install is unchanged: the package stays the authority, and its locks bind. + - **The declaration** (`@objectstack/spec`). `ImportUnmanagedPackageResponseSchema` declares `strippedLocks`; `lock` takes the three binding values of `MetadataLockSchema`. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local` with `installMode: 'unmanaged'`). The answer carries the import's `strippedLocks` as the import states it. + - **`os package install --install-mode unmanaged`** (`@objectstack/cli`). Each dropped lock prints as one line under `Locks removed`, spelled `.: — `, so the installer reads why the vendor locked an item before editing it. An answer from a runtime that states no list prints no lock line. + + Nothing is removed or narrowed: the answer gains a field, and every refusal of the unmanaged install is unchanged. +- d9d7edd: fix(runtime,rest,cloud-connection)!: the declared-endpoint fallback, REST's direct-mounted publish and federation routes, and cloud-connection's session doors refuse a session the ADR-0069 authentication policy holds + + Clause-②: no (narrowing) — a session ADR-0069 holds out of protected resources is refused by doors that answered it; core's existing exports only, no published schema or export moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate. REST's `enforceAuth` and the runtime dispatcher already refuse such a session. Three groups of doors that resolve a session on their own never asked, so they served it as an ordinary signed-in caller: + + - **`@objectstack/runtime`**: the declared-endpoint fallback (app-declared `apis:` endpoints, served by the dispatcher plugin's fallback seam). Its policy chain now asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) before `authRequired` and before the endpoint's target runs, on every declared endpoint, an `authRequired: false` one included, because the target runs as the caller. While a gate feature is active the chain reads the session for this, and when an active gate cannot read the session the request is answered `503` (`SERVICE_UNAVAILABLE`), as REST answers it. No declared path is handed to the allow-list, so no endpoint can exempt itself through the prefix it sits under. + - **`@objectstack/rest`**: the direct-mounted package publish door (`registerPackageRoutes`) and the external-datasource federation family (`registerExternalDatasourceRoutes`). Each refuses on the `authGate` the REST resolver already puts on the execution context it hands them. + - **`@objectstack/cloud-connection`**: `CloudConnectionPlugin`'s seven session doors (bind, poll, unbind, install, and the three installed-state reads) and all five `MarketplaceInstallLocalPlugin` doors (install, list, uninstall, reseed, purge). Each plugin asks the gate once, where it resolves the session, and every door that resolves one refuses on it. + + - **Refused now:** a held session gets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message`, in each door's declared error envelope, before the door does anything else. This is the answer REST gives the same session. + - **Unchanged:** a session the policy does not hold is served exactly as before, an API-key caller is unaffected because it has no session to hold, and an anonymous caller gets the same `401`. `CloudConnectionPlugin`'s status read and `MarketplaceProxyPlugin`'s catalog proxy resolve no session and answer every caller alike, so they are unchanged. + + What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before these doors answer them, as on every other protected route. Nothing needs configuring: the posture comes from the kernel's `auth` service, as it does for REST and the dispatcher. +- 430e5f5: fix(security)!: under a walled posture every environment-metadata write is the platform operator's, whatever its type (ADR-0131 D12 items 7–8, D11) + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. ADR-0131 D12 item 7: a walled deployment is "one environment, one schema, one catalog … The metadata-authoring capability (`manage_metadata` / `studio.access`) is what headquarters IT holds and plant admins do not." Item 8: a plant "cannot create a position, a permission set, a capability, a template or any metadata … A plant that needs its own catalog is given its own environment." D11: the rules are identical in every posture; only whether the wall is enforced differs. + + **Why.** The environment definition ledger carries no organization column, so a write lands for every organization. Measured on booted `isolated` and `group` stacks before this change: an organization owner granted `manage_metadata` inside their own organization created an object, a view and a flow at `/api/v1/meta`, edited, published, rolled back and reset the operator's view, ran `POST /api/v1/meta/_migrate-stored`, passed all thirteen `/api/v1/packages` write doors, installed, upgraded and uninstalled packages through `install-local`, and created a flow at `/api/v1/automation`, each with `2xx`; another organization's owner read every result. The security catalog was refused at these doors already; every other type was not. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` of `/api/v1/meta/:type/:name` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for a type outside the security catalog (`object`, `view`, `flow`, `app`, `dashboard`, `translation` and every other type): **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher answers `403 PERMISSION_DENIED`). + - Every `/api/v1/packages` write on the runtime dispatcher — create, the manifest `PATCH`, enable, disable, publish, publish-drafts, discard-drafts, revert, rollback, `commits/:commitId/revert`, duplicate, adopt-orphans and delete — for a package carrying no position, permission set or capability: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is read or written. The refusal no longer reads the package's items. + - `POST /api/v1/marketplace/install-local` (install and upgrade) and `DELETE /api/v1/marketplace/install-local/:id` (uninstall) on `@objectstack/cloud-connection`, for a package carrying no catalog item: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`. The refusal's `error.details` no longer carries `catalogItems`; it carries `manifestId`. + - `POST /api/v1/automation`, `PUT /api/v1/automation/:name`, `DELETE /api/v1/automation/:name` and `POST /api/v1/automation/:name/clone`: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the automation service is touched. + - `POST /api/v1/meta/_migrate-stored`, the install-wide rewrite of the stored metadata, preview and apply alike: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` on `@objectstack/rest` and `403 PERMISSION_DENIED` on the runtime dispatcher, before a row is read. + - Every one of these refusals carries one sentence: it names the written type (`a package` at the package doors, `the environment's stored metadata` at the rewrite), the posture and "ADR-0131 D3, D12 items 7–8, D11", and points at an environment of the organization's own. The catalog refusals that shipped before keep their envelopes and now read the same sentence. + - The activation refusals under a wall (`POST /api/v1/automation/:name/toggle`, `POST /api/v1/actions/_activation/…`, `POST /api/v1/security/_activation/permission/…`) no longer recommend cloning a flow or a permission set, or authoring a sibling action, since those writes are the operator's under a wall too; they name the platform operator. + + **Unchanged.** Reads. Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows. The platform operator and the engine's own writes (seeders, installers, migrations) write as before. Under `single` (and with no `tenancy` service), a `manage_metadata` holder writes exactly as before. + + **The remedy.** The platform operator authors the environment's metadata, in Studio or through a managed package; an organization that needs metadata of its own is given an environment of its own. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-environment-metadata-writes-retired`. `walledCatalogWriteVerdict` (`@objectstack/metadata-core`) now judges every type alike: a custom door that writes environment metadata asks it after its capability gate, with no `isSecurityCatalogType` pre-filter. +- 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). +- 75ced06: fix(cloud-connection)!: the cloud-connection and install-local routes refuse a declared JSON body that does not parse with `400 INVALID_REQUEST`, instead of answering as if no body was sent + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `POST /api/v1/cloud-connection/bind/start`, `/bind/poll`, `/install` and `POST /api/v1/marketplace/install-local` read their bodies as `try { body = await c.req.json() } catch { body = {} }`, so a truncated body was answered as if the client had sent none: `/bind/poll` answered `400 "device_code is required"` for a body that carried `device_code` and lacked a closing brace. + + What changes for an integrator. A body sent as `application/json` (matched case-insensitively) that is not blank and does not parse: FROM the route's answer for an empty body, TO `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "The request body could not be parsed as JSON (it was sent as application/json). It was refused rather than read as an empty body: send a well-formed JSON document, or no body at all." } }`. Install-local refuses it after its sign-in and capability checks and before anything is fetched, registered or written. + + **Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body. + + **Unchanged.** An empty or whitespace-only JSON body is still no body, and every route answers it as before (for `/bind/poll`, `400 "device_code is required"`). A body that parses, and a body that does not declare JSON, are read as before. +- a237d7f: feat(cloud-connection)!: Setup's "Installed Apps" entry opens Setup › Packages filtered to marketplace installs, and the separate Installed Apps page is no longer shipped + + Clause-②: no (narrowing) + + + + - `MARKETPLACE_INSTALLED_UI_BUNDLE.pages` -> delete (the bundle carries no page; its entry is read from `navigationContributions`) + + Setup › Packages is now the one list of what is installed: every row says where the package came from (Source: code, marketplace or authored), and the rows whose Source is marketplace carry the actions the Installed Apps page had, Details and Uninstall. + + - **What changes.** The "Installed Apps" entry in Setup's Apps group, which the install-local plugin (`MarketplaceInstallLocalPlugin`) contributes, now opens Packages with the Source filter set to marketplace. Its label and icon are unchanged. In `MARKETPLACE_INSTALLED_UI_BUNDLE` the entry `nav_marketplace_installed` is a `component` entry on `developer:packages` with `params: { source: 'marketplace' }`, which the console opens as `/apps/setup/component/developer/packages?source=marketplace`. + - **What is no longer shipped.** The bundle no longer carries the `marketplace_installed` page, so `GET /api/v1/meta/page` stops listing it and `MARKETPLACE_INSTALLED_UI_BUNDLE` has no `pages` key. In the console that page only redirected to the filtered Packages list. The `pages.marketplace_installed` entries leave the Setup translation bundle in `@objectstack/platform-objects` (en, zh-CN, ja-JP, es-ES). The Setup nav label `nav_marketplace_installed` keeps its translations. + - **Unchanged.** The entry still appears only when the install-local plugin is mounted, so a runtime without it shows no Installed Apps entry. "Browse Marketplace" and its route are not touched. The marketplace's own package page still carries Update and the sample-data Re-seed / Purge menu, and Details on a Packages row opens it. + + What to do. Nothing in a normal deployment. If you linked to the `marketplace_installed` page directly, link to Setup › Packages with `?source=marketplace` instead. +- c7168ac: Clause-②: yes (widening) + + fix(service-package,cloud-connection): a stored or installed package body converts forward under ADR-0087 D2 at the `sys_packages` boot hydration and at the marketplace install, and one refused `sys_packages` record no longer stops the hydration of the rest + + The registration door (`ObjectQL.convertPackageBodyForward`, `@objectstack/objectql`) already replays the versioned forward conversion for a package body that declares its `engines.protocol` range. Two more doors that load a body at rest now go through that same entry point, before the registry judges the body: + + - **`sys_packages` boot hydration** (`PackageServicePlugin.start`). A stored record whose range admits this runtime, built before a retirement (for example a `type: 'home'` page with `isDefault: true`), now hydrates converted, with the engine's named warning, where the registry used to refuse it. The stored row is not rewritten, so it converts again at every boot. + - A record the registry still refuses is reported **by name** at `error`, with the refusal attached, and the records after it keep hydrating. Before, the first refusal threw out of the whole loop to a `debug` line, and every durable package after it was absent from the registry with nothing saying so. + - `Hydrated N package(s) from sys_packages into registry` counts the records the registry accepted in that pass. + - The ADR-0087 D1 handshake still runs first: a record whose declared range excludes this runtime's major is refused by it, named, as before. + - **The marketplace install** (`MarketplaceInstallLocalPlugin`, install and boot rehydrate). The body is converted once per load, and that one converted body is what `manifest.register`, the handler binder, the side effects (translations, seed datasets) and the rehydrate's sample-data heal read. Before, `register` converted its own copy while the side readers read the raw ledger entry. The ledger keeps the body as installed. + + Nothing is narrowed. A body with no declared range (a manifest built in code) meets the registry's refusals exactly as before at both doors. The install primitive `protocol.installPackage` (`POST /api/v1/packages`) is authoring-side and stays strict: the registry judges the manifest as handed. +- 8692a08: fix(cloud-connection)!: the marketplace `reseed-sample-data` door converts the installed package body forward once, as the install and the rehydrate do, so it reloads the translations those two doors load + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data` handed its side effects (the translations it reloads into the i18n service, and the seed datasets it runs) the package body exactly as the install ledger stores it. The install and the boot rehydrate hand the same side effects that body converted forward, through the ADR-0087 window the body's own `engines.protocol` opens (`ObjectQL.convertPackageBodyForward`). So for a package built before a translation retirement, a reseed reloaded a retired translation shape that the other two doors strip. The reseed now converts the body once, through the same entry point, after the protocol handshake has judged the entry as stored. + + - **Served less now:** for a package whose body declares a range this runtime admits, from a floor that predates a retirement, a reseed no longer reloads the translation shapes that the four translation conversions remove (`translation-per-app-settings-removed`, `translation-validation-messages-removed`, `translation-component-submit-label-removed` and `translation-widget-sub-caption-removed`). For example, a pre-17.4 per-app `settings` group is no longer loaded into the i18n service on reseed. After a reseed the i18n service holds what it holds after the install or a restart. + - **Refused now:** a stored body the converter throws on is no longer reloaded. The reseed raises the converter's error, as the install does, and loads nothing. Before, it reloaded the raw body. The protocol handshake that runs first throws on the same malformed range at every door, so this arises only for a body whose range reads differently to the handshake and to the converter. + - **Unchanged:** a body that declares no range is handed back as stored, so it reloads exactly as before. The handshake still runs first: a range that excludes this runtime is still answered `422 OS_PROTOCOL_INCOMPATIBLE`, before anything is converted or loaded. No conversion touches seed `data`, so a reseed seeds the same rows and answers the same counts. The ledger keeps the body as installed. + + What changes for you: nothing to rewrite. For such a package the install and every restart already log the conversion warning, once per conversion per process, and it names the remedy: rebuild the package with `os build` and republish it. +- 6973014: fix(cloud-connection)!: `bind/start`, `bind/poll` and `unbind` require `setup.access`, and `GET /status` serves the connection's details only to a caller who holds it + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The Console's Cloud Connection page sits on the Setup app, which requires the Setup Access permission (`setup.access`). The routes behind that page did not ask for it: the three that change this runtime's cloud connection checked only that the caller was signed in, and `GET /api/v1/cloud-connection/status` served the connection record the control plane reports to every caller. Both now follow the page's rule. + + - **Refused now:** `POST /api/v1/cloud-connection/bind/start`, `POST /api/v1/cloud-connection/bind/poll` and `POST /api/v1/cloud-connection/unbind` answer a signed-in caller who does not hold `setup.access` with `403` and the standard `PERMISSION_DENIED` code, and the message names the capability. The refusal comes before any call to the control plane and before the stored credential is touched. What the caller holds is read through `resolveAuthzContext`, the platform's one authorization resolver, for the session these routes already read: a grant bound through a position, or scoped to the session's active organization, counts as it does everywhere else, and an API key sent beside the session lends its owner's grants to nobody. + - **Narrowed now:** `GET /api/v1/cloud-connection/status` still answers every caller `200`, so a probe can still tell that the plugin is mounted. A caller without `setup.access` (anonymous, signed in without it, or a session the ADR-0069 policy holds) is served `bound`, `runtimeId` and `provider` only. `environmentId` and `connection` are served to a caller who holds it. + - **Unchanged:** a caller who holds `setup.access` is served exactly as before, so the Console panel's connected view, its "Approved by" row included, keeps working for an administrator. An anonymous caller of the three connection-changing routes still gets `401`, and a held session still gets the ADR-0069 `403`. The other four session routes (`install`, `installation`, `installed`, `org-packages`) are not touched. + - **An unreadable permission store is not a denial.** When the grants cannot be read, the three connection-changing routes raise the platform's `SERVICE_UNAVAILABLE` outage (`503` through the host's error render) instead of answering a `403` that reads like a real refusal. `status` keeps answering, withholds the details, and logs the outage. + + What changes for you. Whoever manages this runtime's cloud connection needs Setup Access, which the default platform-administrator and organization-administrator permission sets already carry. A script that reads `connection` or `environmentId` from `GET /status` has to call it as a signed-in user who holds Setup Access. + +### Patch Changes + +- c8a9783: feat(plugin-security)!: no seeder or materializer writes a `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and the `permission` publish materializer are deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `SecurityPlugin` no longer writes a `sys_permission_set` row for any permission set: not for the platform defaults (`admin_full_access`, `member_default`, …), not for a set a package declares, not for an organization's copy, and not on a metadata reload or an organization's creation. Permission sets are served by the security catalog (ADR-0131 D3), and since the grant names its set (`sys_user_permission_set.permission_set`), no grant path needs a row. A fresh database still promotes its first platform administrator, grants organization administrators and self-registered members, and grants every principal what it was granted before. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. Until a later stage retires the ADR-0094 projector, publishing a permission set (a package-door publish, or a writable base's direct publish) still has it write a projection row, `managed_by: 'admin'` with no `package_id` (a package-door publish wrote `managed_by: 'package'` before this release); no authorization path reads it, and uninstalling the package removes it. + + - **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. + - **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. + - **Uninstalling a package still revokes every grant of its permission sets** (ADR-0090 D5, "no ghost grants"). With no row standing for a package's set, both uninstall doors (`deletePackage` and `DELETE /api/v1/marketplace/install-local/:id`) capture the set names the package held just before they withdraw it from the registry, and the `security.package-permissions` cleanup revokes every `sys_user_permission_set` grant naming them. The withdrawal still runs before the cleanups. A grant naming such a set would otherwise survive the uninstall and be live again on reinstall. Grants written before the name column, and position bindings, are still reached through a legacy `sys_permission_set` row where an already-seeded database holds one. + - `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once. + + Added (additive, nothing an author writes changes): + + - `@objectstack/metadata-protocol`: the optional `declared?: UninstallDeclared` argument of `UninstallCleanup` (and of `runUninstallCleanups`' request), the `UninstallDeclared` type, and `captureUninstallDeclared(registry, packageId, stored?)`, the one capture both doors call. A cleanup that ignores `declared` behaves as before. + - `@objectstack/objectql`: `SchemaRegistry.securityCatalogNamesHeldBy(packageId, type)`. + - `@objectstack/plugin-security`: `cleanupPackagePermissions` takes an optional fourth argument, the captured names. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `bootstrapDeclaredPermissions` + - `normalizeManagedByVocab` + - `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` + - `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` +- 8601526: `RuntimeConfigPluginConfig.controlPlaneUrl`'s published docblock no longer says that an empty string declares "this runtime IS the cloud". It now says what the constructor does: `''` keeps marketplace and install requests on this origin, and that is all it says. + + Clause-②: no + + - The runtime that passes `''` may serve the catalog itself or proxy a control plane it does not name. The CLI's cloud-connected `os serve` passes `''` while its marketplace proxy forwards to the control plane `resolveCloudUrl()` answers. So `''` reads neither as "this runtime is the cloud" nor as "there is no upstream". This matches the `AppShellRuntimeConfig.cloudUrl` doc in `@object-ui/app-shell`. + - A runtime with no control plane says so with a decline spelling (`'off'` / `'none'` / `'local'` / `'disabled'`), in `controlPlaneUrl` or in `OS_CLOUD_URL`. The docblock now says this too. + - ⛔ No code, type, export or default change. The served `cloudUrl` and the telemetry posture do not change. +- a45d5d8: fix(auth): a server-side session read no longer renews a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Ten doors read the session in-process (`auth.api.getSession`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, later `GET /api/v1/auth/get-session` calls found a fresh row and re-issued nothing, and the cookie expired first: a dead cookie beside a live bearer, and every cookie-only path then saw a signed-out user. Measured on a fresh dev stack (better-auth 1.7.3, `expiresIn` 604800 s, `updateAge` 86400 s, a session aged to `now + expiresIn − updateAge − 60 s`): `GET /api/v1/data/:object`, `GET /api/v1/auth/me/permissions`, `GET /api/v1/meta/object`, `GET /api/v1/i18n/locales`, `GET /api/v1/packages`, `GET /api/v1/marketplace/install-local` and the MCP door each moved `expires_at` by +86460 s and set no session cookie. + + **The rule now, decided by what the request carries.** + + - **A session cookie** (a browser, including a console that sends its cookie beside its bearer): the in-process read passes `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. Measured after the change: every door above leaves `expires_at` unchanged on a cookie request and sets no cookie. + - **No session cookie** (a bearer-only client: `@objectstack/client` outside a browser, the `os` CLI): unchanged. A data read past `updateAge` still renews the session (+86460 s, measured on the same doors), so an active bearer client keeps sliding forward without calling `get-session`. No cookie is ever set on a response to a request that sent none. + + **Upgrading.** Nothing to change. A browser session renews whenever the app calls `GET /api/v1/auth/get-session`; a tab that never calls it now signs out at the session's real expiry instead of keeping a live bearer beside a dead cookie. + + `@objectstack/types` gains `inProcessSessionReadInput(headers)` (the `getSession` input for an in-process read: the request's own headers, plus `query: { disableRefresh: true }` when they carry a better-auth session cookie), `carriesSessionCookie(headers)` and the `InProcessSessionReadInput` type. A host that calls `auth.api.getSession` itself should read through `inProcessSessionReadInput` for the same reason. + + The same rule is applied to the in-process `auth.api.getSession` readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` by their own changeset. +- a360cee: fix(cloud-connection): install-local's `hotLoaded` reports whether the running kernel loaded the package (#22695) + + Clause-②: no + + - **What was wrong.** `POST /api/v1/marketplace/install-local` always answered `hotLoaded: true`. When the manifest came from the cloud catalog and `manifest.register` threw, the install still wrote the package to its ledger and answered `200` (the lenient path). The package loads at the next restart, but the answer said the running kernel held it already, and its `note` said the app was "now available in this runtime". + - **What it answers now.** On that path the answer carries `hotLoaded: false` and a new string key, `hotLoadError`, holding the register error's message. The `note` says the package is installed and cached, that the running kernel could not load it, and that the runtime registers it again at its next restart. A reader can then say "installed, loads at the next restart" instead of reporting a false success. + - **What did not change.** An install whose register succeeds answers exactly as before: `hotLoaded: true`, and no `hotLoadError` key. An inline (file-import) manifest whose register throws is still refused with `422 PLUGIN_REGISTER_FAILED`, and nothing is written. The lenient path still installs: the ledger entry is written, and the package loads at the next restart. +- 66d2757: feat(spec)!: every installed-package row declares where it came from, a closed `source: 'code' | 'marketplace' | 'authored'`, and the `GET /packages` rows declare the `writable` verdict they already served (#23972) + + Clause-②: yes (narrowing — the served row gains two declared keys, and the record schema, the served-row union and the registry's installPackage signature each refuse what they accepted before) + + **BREAKING** — three published accept sets narrow. `InstalledPackageSchema` refuses a row without `source`, or with a value outside `code` / `marketplace` / `authored`. The served-row union `InstalledPackageAtEitherStageSchema` refuses a row without a boolean `writable`. `SchemaRegistry.installPackage` and `EngineSchemaRegistryView.installPackage` take `source` as their second argument and refuse a call without it. The migration is below. None of it is mechanically convertible, because only the door that registers a package knows where it came from. + + **What is new on the wire.** `GET /api/v1/packages` and `GET /api/v1/packages/:id` serve two declared keys on every row: + + - `source` names the package's origin. `code` means the deployment's own code registered it (a `defineStack()` host, a plugin's bundled manifest, a platform package). `marketplace` means the marketplace install-local plugin installed it, and that holds after a restart too, when the plugin rehydrates it from its ledger. `authored` means it is a database-authored package (a Studio base, a duplicate, an HTTP install), including after the restart that rehydrates it from `sys_packages`. The door that registers the package stamps the value. No reader derives it. + - `writable` is the server's own writability verdict (ADR-0070 D2), served on these rows since #14375 and now declared. It is computed per read and never stored, so it is declared on the served row (`InstalledPackageAtEitherStageSchema`) and not on the registry record. + + FROM → TO for a reader of these rows: + + | you did | do instead | + | --- | --- | + | treated `writable: true` as authored, and looked up each non-writable row in the marketplace's installed list (the control plane's, else install-local's) to tell marketplace from code | read `row.source` | + | read the undeclared `writable` | read the declared `writable` (unchanged value) | + + The two keys are separate statements: `source` says where the package came from, and `writable` says what this server lets an org do to it. + + **`InstalledPackageSchema` requires `source`.** It is `InstalledPackageSourceSchema`, exported from `@objectstack/spec/kernel`. A row without it, or with a value outside the closed set, is refused at the `source` key. + + **API change for code that registers packages (`@objectstack/objectql`).** The registry records the origin and never infers it: + + | you wrote | write instead | + | --- | --- | + | `registry.installPackage(manifest)` | `registry.installPackage(manifest, 'code')` (or `'marketplace'` / `'authored'`, whichever is where the package came from) | + | `registry.installPackage(manifest, settings, scope)` | `registry.installPackage(manifest, source, settings, scope)` | + | `ctx.getService('manifest').register(artifact)` for a marketplace install | `ctx.getService('manifest').register(artifact, { source: 'marketplace' })` | + + `SchemaRegistry.installPackage` throws a `TypeError` naming the closed set when `source` is missing or unknown, before anything is recorded. `ObjectQL.registerApp(body, scope?, source?)` and the `manifest` service record `'code'` when no source is given. They accept `'code'` or `'marketplace'` and refuse `'authored'`. The `IObjectQLEngine` registry contract (`@objectstack/spec/contracts`) declares the same `installPackage(manifest, source, settings?)`. + + **The one-line fix:** pass the package's origin as `installPackage`'s second argument. + + Readers: objectui's Setup → Packages Source column and its `?source=` filter (it switches from the two-read join to `source` in its own follow-up), and the client SDK's `packages.list()` / `packages.get()` row types. + + +- 2bcaefb: Clause-②: no + + fix(cloud-connection): an install-local uninstall takes the package's objects out of `GET /api/v1/meta/object` and `GET /api/v1/meta/object/NAME` at once, not at the next restart + + - **What was wrong.** An install-local install registers the package through the `manifest` service, and that register does two things: it puts the package in the engine's registry, and it copies each of the package's objects into the `metadata` service, stamped with the package's id. `DELETE /api/v1/marketplace/install-local/:manifestId` withdrew the registry half only. The object listing merges the `metadata` service into its answer and the by-name read asks that service first, so both kept serving every object of the uninstalled package, with its `_packageId`, until the runtime restarted, while `GET /api/v1/data/OBJECT` already answered `404`. The console's object pickers and Studio's object list offered objects that no longer existed. + - **What changes.** Right after the registry withdrawal, the DELETE removes from the `metadata` service each object copy stamped with the package's id whose name the registry no longer serves. The object listing and the by-name read stop serving the package's objects in the same process, as its data route, `GET /api/v1/meta/app` and `GET /api/v1/meta/permission` already did. + - **Not touched.** A same-name object the package did not copy there (one without its stamp) stays. The package's tables and rows are untouched, as before. A refused registry withdrawal still leaves the whole package loaded until the next restart, its copies included. + - **A copy the service refuses to remove** is reported on the DELETE answer as a failed `cleanups` entry named `metadata.unregister`, the way a refused registry withdrawal is reported, and the operator log names the objects kept and the remedy: restart the runtime. The uninstall cleanups still run, and the answer is still `200`. When nothing is refused, `cleanups` is unchanged. +- 669296b: fix(cloud-connection): with `OS_RUNTIME_VERSION` unset, the server reports to the cloud the release `GET /api/v1/discovery` reports, not the Cloud Connection plugin's own `0.3.0` + + Clause-②: no + + `CloudConnectionPlugin` sends the server's version to the cloud in two places: as `runtime_version` on the approval links `POST /api/v1/cloud-connection/bind/start` returns, which the cloud's approval page shows next to the server's name, and as `runtime_version` in the registration request `POST /api/v1/cloud-connection/bind/poll` sends to the cloud's `/bind`. With `OS_RUNTIME_VERSION` unset, both carried the plugin's own `version` field, `0.3.0`. That string names the plugin, not the release the server runs, so an approver was shown `0.3.0` while discovery on the same server reported `18.0.0-next.*`. + + - **Unset now:** both places carry the `version` the server's discovery document reports. The plugin reads it from the discovery document itself, through the `protocol` service, each time it builds the approval links or the registration request, so the two answers cannot drift. + - **Set:** a set `OS_RUNTIME_VERSION` is sent verbatim, as before. + - **No discovery document:** on a server with no `protocol` service, or when reading it fails, neither place carries a version. The approval links have no `runtime_version` parameter and the registration request has no `runtime_version` key. Before, both carried `0.3.0`. + + What changes for you: nothing to rewrite. `OS_RUNTIME_VERSION` is now only needed to report a version other than the release, such as an image tag or a commit. +- aa31569: Clause-②: no + + fix(objectql): withdrawing a package that extends another package's object puts the owner's definition back at `GET /api/v1/meta/object/NAME` at once, not at the next restart + + - **What was wrong.** When a package whose content is an `objectExtensions` field on another package's object is registered on a started kernel, the ObjectQL bridge rewrites the OWNER's copy of that object in the `metadata` service with the merged body, extension included. `DELETE /api/v1/marketplace/install-local/:manifestId` of the extender withdrew its contribution from the registry, so the object listing and the data plane dropped the field, but nothing rewrote the owner's copy. The by-name read asks that copy first, so `GET /api/v1/meta/object/NAME` kept serving the withdrawn field until the runtime restarted. + - **What changes.** The ObjectQL bridge remembers which owners' copies each package's extensions rewrote, and the `manifest` service gains `restoreExtendedObjectCopies(packageId)`: once the registry no longer holds the package, it re-registers each such copy with the registry's current definition. The install-local DELETE calls it right after the registry withdrawal. The by-name read then serves the owner's body without the extension's fields, as the listing and the data plane already did. + - **Not touched.** Only a copy stamped with the registry owner's id is rewritten, the same rule the bridge overwrites by, so an authored copy stays. The DELETE answer is unchanged. A copy that cannot be restored is logged once at `error`, naming the objects and the remedy: restart the runtime. +- af284b0: feat(spec): the remaining `getSession` readers read `AuthSessionApi`'s declared result, and none reads a top-level `activeOrganizationId` + + Clause-②: yes (widening) + + - **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains three optional `user` members**, each declared for a named reader: `isPlatformAdmin` (`boolean`, the ADR-0068 D2 rung plugin-auth's `customSession` derives) and `role` (`string | null`, better-auth's `admin` plugin scalar), both read by `judgePlatformAdmin` in `@objectstack/plugin-auth`; and `name` (`string`), read by the runtime dispatcher's AI-route `req.user` resolver as the display name. Every member is optional, so every implementer and caller typechecks unchanged. + - **The readers read through the declared type instead of `any` or a cast** (types only, runtime behaviour unchanged): `judgePlatformAdmin` and the four platform-admin mounts in `@objectstack/plugin-auth` (better-auth's own session type now has to fit the declaration at those reads), the REST server's two session reads (`@objectstack/rest`), the dispatcher's posture read, `resolveSessionData` and both `HttpDispatcher` reads (`@objectstack/runtime`), the cloud-connection and install-local session reads (`@objectstack/cloud-connection`), the webhook redeliver door (`@objectstack/plugin-webhooks`), and the storage session bridge (`@objectstack/service-storage`). + - **Fix — the top-level `activeOrganizationId` fallback is removed** from the storage upload stamp (`@objectstack/service-storage`) and from install-local's `resolveActiveOrgId` (`@objectstack/cloud-connection`). The active organization is read from `session.activeOrganizationId` alone, where the contract declares it. No in-process producer answers a top-level `activeOrganizationId` (better-auth's read is `{ user, session }`), so for the shipped auth plugin nothing changes. An auth service that answered only the flattened shape now gets no organization: the upload is not stamped, and install-local reports no active organization. To fix it, answer the active organization on `session.activeOrganizationId`, as `AuthSessionApi` declares it. +- 3a734e0: feat(spec)!: a page region named `main` takes only `width: 'full'` — `'small'`, `'medium'` and `'large'` there are refused at publish (#24326) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention. + + `PageRegionSchema.width` is one enum shared by every region, so a `main` region accepted `'small'`, `'medium'` and `'large'`. The console never read them: every layout draws `main` at the width the layout leaves it (the flexible column beside `sidebar` and `aside`; one grid cell on `dashboard`), and the only reader of `main.width` is the page itself, for `'full'`, which lifts the page's max-width cap. A three-column page that asked for a `large` main got the space the two rails left, and every door reported success. + + - `PageSchema` now refuses `width: 'small' | 'medium' | 'large'` on a region named `main` (matched case-insensitively, as the console matches it), at `regions.N.width`, on every page kind and template. The message names the region and `'full'` as the one width it takes. The doors are `definePage`, `defineStack` (so `os validate` and `os build`, `STACK_SCHEMA_INVALID` 422) and the metadata save door (422 `INVALID_METADATA`). + - `'full'` on `main` is unchanged, with its page-wide meaning. Every other region accepts the same widths as before. + - The check is exported as `checkPageMainRegionWidth` from `@objectstack/spec/ui`, so a mirror built from `PageSchema.shape` can attach the same rule. + - Data at rest keeps loading: the retired ADR-0087 conversion `page-main-region-width-removed` deletes those widths on read: a stored page row, a compiled artifact inside the window its declared protocol range opens, and a package body registered with its declared range. The page renders exactly as before. + - `@objectstack/mcp` (the Connect an Agent page) and `@objectstack/cloud-connection` (the Cloud Connection page) drop `width: 'large'` from their `main` region. Both render the same. + + ### Migration: FROM → TO + + | Wrote | Write instead | + | :--- | :--- | + | `width: 'small' \| 'medium' \| 'large'` on a region named `main` | drop it; `'full'` stays | + + Deleting the key changes nothing on screen. Write `width: 'full'` only when you want a full-bleed page, which drops the page's max-width cap. +- 71fa166: Clause-②: no + + fix(cloud-connection): the sign-in refusals and the unmanaged-install copy no longer call the running system "this environment" + + - **What changed.** Inside the running system, user-facing copy names the system the user is in as "this system", or drops the noun. The seven `401 UNAUTHENTICATED` answers on `/api/v1/cloud-connection/*` now read "Sign in to connect a cloud account." (`bind/start`), "Sign in to install apps." (`install`) and "Sign in." (`bind/poll`, `unbind`, `installation`, `installed`, `org-packages`), where each used to say "Sign in to this environment …". On `/api/v1/marketplace/install-local`, the `409 RESOURCE_CONFLICT` that refuses a managed install over an unmanaged copy now names the holder as "the writable base 'BASE' — an unmanaged install of this package, or a base authored in this system — whose items are this system's own metadata", and the unmanaged install's success `note` reads "The package's metadata is now this system's own, in the base 'BASE' …". + - **Unchanged.** Every HTTP status, error code (`UNAUTHENTICATED`, `RESOURCE_CONFLICT`), `details` object and response shape. Server log lines are not touched. +- Updated dependencies [5a871e4] +- Updated dependencies [5a871e4] +- Updated dependencies [f8ef144] +- Updated dependencies [5b14b7f] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [3ae0b77] +- Updated dependencies [913fd6d] +- Updated dependencies [8eac321] +- Updated dependencies [de43524] +- Updated dependencies [a5a781b] +- Updated dependencies [04669a4] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [c395405] +- Updated dependencies [1e8b628] +- Updated dependencies [4ff18d3] +- Updated dependencies [3566c08] +- Updated dependencies [3b8e2b1] +- Updated dependencies [14ccb1d] +- Updated dependencies [7879391] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [cd3d391] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [8c105e3] +- Updated dependencies [e02833c] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [1abfc58] +- Updated dependencies [db87a02] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [6befe19] +- Updated dependencies [8caa131] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [f85a83b] +- Updated dependencies [2015c54] +- Updated dependencies [73a0a6b] +- Updated dependencies [f2a45db] +- Updated dependencies [aa9447c] +- Updated dependencies [3d91885] +- Updated dependencies [8fc50b7] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [ae97841] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [bb4f5cc] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [0b997ea] +- Updated dependencies [35afb15] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [b1f7a7a] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [3f80f17] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [ca135dc] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [46692c1] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [3ca71b6] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [018b077] +- Updated dependencies [0fea05f] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [874a38d] +- Updated dependencies [cb3bb93] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [87cd458] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [c63028e] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [3b5475a] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [117817e] +- Updated dependencies [4663801] +- Updated dependencies [d294951] +- Updated dependencies [6a6a4d3] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [0a29bec] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [8024e12] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [a3d47c2] +- Updated dependencies [21c8644] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [aa5bfdf] +- Updated dependencies [d4aaeaf] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [c6139f2] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [917b1a0] +- Updated dependencies [d9d7edd] +- Updated dependencies [78c13bd] +- Updated dependencies [9543e9c] +- Updated dependencies [4e0838d] +- Updated dependencies [d45d0e9] +- Updated dependencies [cb9841a] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [01d5034] +- Updated dependencies [f24574a] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [5d3018d] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [24e064f] +- Updated dependencies [0f05907] +- Updated dependencies [47d569e] +- Updated dependencies [2de9c03] +- Updated dependencies [5b68524] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [28e294f] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [c980d11] +- Updated dependencies [0ecac96] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- 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 [9fcea20] +- Updated dependencies [8226755] +- Updated dependencies [cdc42eb] +- Updated dependencies [7ffc578] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [e67ac4b] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [7dc7d22] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [954bf72] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cad343e] +- Updated dependencies [cbe7712] +- Updated dependencies [cd86e41] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [395ca21] +- Updated dependencies [76560a1] +- Updated dependencies [9bb9370] +- Updated dependencies [9e0945c] +- Updated dependencies [23d0bc7] +- Updated dependencies [14aff67] +- Updated dependencies [7428da0] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [b55be42] +- Updated dependencies [abb6df0] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [f7a5ebe] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9d4fb3c] +- Updated dependencies [c5cf546] +- Updated dependencies [9cb9d95] +- Updated dependencies [cf07168] +- Updated dependencies [f846cc2] +- Updated dependencies [34b740f] +- Updated dependencies [30ce56e] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [ae4dfaf] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2e15620] +- Updated dependencies [0cb45e9] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [605aa01] +- Updated dependencies [a33f3d5] +- Updated dependencies [6e18016] +- Updated dependencies [d050a8f] +- Updated dependencies [9e797a8] +- Updated dependencies [b25d98a] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [6e43b7b] +- Updated dependencies [6dede7a] +- Updated dependencies [727091c] +- Updated dependencies [077b75f] +- Updated dependencies [62f959d] +- Updated dependencies [d6bd98a] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [f3174ef] +- Updated dependencies [e46ce7b] +- Updated dependencies [d702b18] +- Updated dependencies [a06f0d4] +- Updated dependencies [a06f0d4] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [2ac0616] +- Updated dependencies [5a9a80b] +- Updated dependencies [1489952] +- Updated dependencies [8eccf51] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [aa31569] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [1bf5f6e] +- Updated dependencies [1bf5f6e] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [2bcd406] +- Updated dependencies [e74ec56] +- Updated dependencies [58a2faa] +- Updated dependencies [2bcd406] +- Updated dependencies [e74ec56] +- Updated dependencies [af284b0] +- Updated dependencies [cbfa5e4] +- Updated dependencies [14a8bd2] +- Updated dependencies [9822b05] +- Updated dependencies [889bf12] +- Updated dependencies [7a8733f] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [d79dc48] +- Updated dependencies [1173017] +- Updated dependencies [51ad8e4] +- Updated dependencies [ec749fb] +- Updated dependencies [ec749fb] +- Updated dependencies [c4526a7] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [41c7405] +- Updated dependencies [370833a] +- Updated dependencies [2f569a9] +- Updated dependencies [69147ab] +- Updated dependencies [3a734e0] +- Updated dependencies [6868514] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [9112bcd] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf16a0d] +- Updated dependencies [cf540a7] +- Updated dependencies [cf540a7] +- Updated dependencies [1827571] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [45bec43] +- Updated dependencies [33a45cc] +- Updated dependencies [964a237] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] +- Updated dependencies [cdf3777] + - @objectstack/core@18.0.0 + - @objectstack/metadata-protocol@18.0.0 + - @objectstack/objectql@18.0.0 + - @objectstack/runtime@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index 1449203def3..a1a21f8ad83 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Runtime-side client for an ObjectStack cloud control plane — marketplace browse proxy, install-local, device-code binding, org catalog and installed views, and the /api/v1/runtime/config discovery endpoint. Open mechanism (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 58a23ee6ae5..17fffbbc760 100644 --- a/packages/connectors/connector-imap/CHANGELOG.md +++ b/packages/connectors/connector-imap/CHANGELOG.md @@ -1,5 +1,32 @@ # @objectstack/connector-imap +## 18.0.0 + +### Minor Changes + +- 3d830b7: Inbound mail has a mail source: the new `@objectstack/connector-imap` reads a mailbox over IMAP, and `@objectstack/service-mailbox` polls each mailbox's folder into its ingest seam, keeping a UID cursor on `sys_mailbox` + + Clause-②: yes (a new published package, `@objectstack/connector-imap`, and its exports; new exports and plugin options in `@objectstack/service-mailbox`, and four new fields on its published `sys_mailbox` object) + + - **`@objectstack/connector-imap` (new) — protocol and authentication only.** + - `openImapSession({ account, credentials, timeoutMs?, maxLiteralBytes?, maxCommandBytes? })` opens a read-only IMAP4rev1 session: `examine(folder)` (`EXAMINE`, never `SELECT`; answers `uidValidity`, `uidNext`, `exists`), `listMessages(fromUid)`, `fetchMessages(uids)` (`BODY.PEEK[]`, so no message is marked `\Seen`), `logout()` and `close()`. A folder opened without a UIDVALIDITY is refused. + - The account and credential abstraction is shaped for the per-user mailbox: a `MailAccount` (`{ id, username, imap: { host, port?, security? } }`) and a `MailCredentialProvider` that resolves the account's credential each time a session opens. `staticCredentials(credential)` answers one fixed credential. + - A `password` credential is sent with `AUTHENTICATE PLAIN` when the server offers it, otherwise `LOGIN`; an `oauth2` access token with `AUTHENTICATE XOAUTH2`. A server that refuses `LOGIN` and offers no `PLAIN` is refused before anything is sent. + - `security: 'tls'` (the default) is implicit TLS with the certificate verified. A plaintext connection (`security: 'none'`) is refused to any host but loopback, before a socket opens. + - Failures are `ImapError` with a `kind`: `configuration`, `connection`, `timeout`, `authentication`, `refused`, `protocol` or `oversized`. + - An IMAP server is treated as an untrusted peer. A line above 4 MiB, a literal above `maxLiteralBytes` (64 MiB by default), a response of more than `IMAP_MAX_RESPONSE_LITERALS` (32) literals, and more than `maxCommandBytes` of responses to one command (128 MiB by default) are refused as `oversized` before the excess is kept, and the connection is closed. + - `@objectstack/connector-imap/testing` exports `ImapServerDouble`, an in-process IMAP server on loopback for tests. + - No runtime dependencies. + - **`@objectstack/service-mailbox` — the IMAP sync.** + - `new MailboxServicePlugin({ imap: { accountFor, pollIntervalMs?, batchSize?, maxMessagesPerRun?, maxMessageBytes?, maxBatchBytes? } })` schedules the interval job `mailbox:imap-sync` on the composed job service. For each mailbox `accountFor` answers (asked per mailbox per run; `null` for a mailbox the host does not sync), it fetches the messages above the stored cursor, in UID order, and hands each one to `ingest` under the system context. Dedupe, association, the pool and auto-reply suppression are unchanged. + - Without `imap` — the capability boot constructs the plugin with no options — no mailbox is synced, and the boot says so once. With `imap` but no job service, the boot warns, and `plugin.syncService.syncAll({ isSystem: true })` runs a pass on the host's own schedule. A run in which a mailbox could not be synced reports `degraded` to the job service. + - `sys_mailbox` gains the cursor, written by the sync only: `imap_folder`, `imap_uid_validity`, `imap_last_uid` and `imap_synced_at`. A cursor taken under another UIDVALIDITY, or in another folder, is discarded and the folder is read from its start; the messages already stored answer `duplicate`. No credential is stored on the object: the account and its credential come from `accountFor`. + - The cursor moves after each batch past every message stored, already stored, suppressed or skipped. A store failure stops the run below the failed message, so the next run retries it. A message above `maxMessageBytes` (32 MiB by default) or one the seam refuses as unreadable is skipped and logged at `error` with its UID. + - A batch is bounded by count (`batchSize`, 25) and by listed bytes (`maxBatchBytes`, 64 MiB). The session's literal bound is `maxMessageBytes` plus a 64 KiB framing envelope, so a server that omits or understates a message's size cannot hand over more. That message is skipped as `too_large`, after the batch is read again one message at a time on a fresh session, and the rest of the mailbox keeps arriving. + - A forwarded `message/global` part (RFC 6532, the internationalized `message/rfc822`) is now read as an `attachment` source, the same as `message/rfc822`. + - New exports: `MailboxSyncService`, `MAILBOX_SYNC_JOB`, `DEFAULT_MAILBOX_POLL_INTERVAL_MS`, `DEFAULT_SYNC_FOLDER`, `DEFAULT_SYNC_BATCH_SIZE`, `DEFAULT_SYNC_MAX_MESSAGES_PER_RUN`, `DEFAULT_SYNC_MAX_MESSAGE_BYTES`, `DEFAULT_SYNC_MAX_BATCH_BYTES`, `SYNC_LITERAL_ENVELOPE_BYTES`, and the types `MailboxServicePluginOptions`, `MailboxImapSyncOptions`, `MailboxAccountResolver`, `MailboxImapAccount`, `MailboxSyncTarget`, `MailboxSyncResult`, `MailboxSyncAllResult`, `MailboxSyncSkipReason`, `MailboxSyncServiceOptions`, `MailSyncEngine` and `MailSyncLogger`. + - This supersedes the ingest seam entry's "no mail source feeds the seam": the IMAP sync does. Attachments are still not stored. + ## 18.0.0-next.5 No changes in this release. diff --git a/packages/connectors/connector-imap/package.json b/packages/connectors/connector-imap/package.json index 79ec9117013..8353d99e546 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.5", + "version": "18.0.0", "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 76e5908859a..9989374afef 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,391 @@ # @objectstack/connector-mcp +## 18.0.0 + +### Minor Changes + +- f8566a0: feat(connector-mcp): a declarative stdio MCP transport runs in the declaring app's root, so its relative command and args resolve there wherever the server was started + + Clause-②: yes (widening) + + - **What changes.** For a declarative `provider: 'mcp'` connector with a stdio transport, the `mcp` provider factory now sets the child process's working directory to the app's root. It reads that root from the host through `ConnectorProviderContext.resolvePackagePath('.')`. Relative paths in the entry now resolve against the same root as the app's other relative refs, such as the `openapi` provider's `providerConfig.spec`. That covers a relative `command` path (`./bin/server`) and a script the launched program opens itself (`args: ['./scripts/server.mjs']` with `command: 'node'`). Before this change they resolved against the directory the server was started from. A boot from any other directory, such as `os verify --app examples/app-showcase/objectstack.config.ts` from the repository root, therefore registered the connector degraded, with no actions. + - **What does not change.** A bare executable (`node`, `npx`) is still looked up on `PATH`, and an absolute command or argument resolves as before. The `declarativeStdio` policy judges the `command` string exactly as written, and it runs before the root is resolved. `providerConfig.transport` gains no key: the working directory comes from the host, and an authored `cwd` there is not used. A host that does not provide `resolvePackagePath` starts the child exactly as before, in the host's current directory. A server that opens relative files of its own now opens them under the app's root. + - **New on the exported type.** The stdio variant of `McpTransport` gains an optional `cwd`, which the default client passes to the MCP SDK's `StdioClientTransport`. A hand-wired transport (`new ConnectorMcpPlugin({ transport })` or `createMcpConnector`) may set it. Left out, the child inherits the host's current directory, as before. + - **Nothing to migrate.** An app that started its server from its own directory sees no difference, because both anchors were the same directory. + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index 79d166fde02..ed511818347 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Model Context Protocol (MCP) connector for ObjectStack — a generic adapter that turns any MCP server's tools into a connector's actions on the automation engine's connector registry (ADR-0024).", "main": "dist/index.js", diff --git a/packages/connectors/connector-openapi/CHANGELOG.md b/packages/connectors/connector-openapi/CHANGELOG.md index 0d6cce52350..5116be7a064 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,380 @@ # @objectstack/connector-openapi +## 18.0.0 + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index 9bc95d74da9..1c45c756c1d 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "OpenAPI 3.x connector generator for ObjectStack — turns a declarative OpenAPI document into connector actions on the automation engine's registry, with a self-contained static-auth HTTP transport (ADR-0023).", "main": "dist/index.js", diff --git a/packages/connectors/connector-rest/CHANGELOG.md b/packages/connectors/connector-rest/CHANGELOG.md index 9004a2be808..557492b236d 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,380 @@ # @objectstack/connector-rest +## 18.0.0 + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index e152b7a8077..444a5772a16 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Generic REST connector for ObjectStack — the reference concrete connector that registers a `request` action on the automation engine's connector registry (ADR-0018 §Addendum).", "main": "dist/index.js", diff --git a/packages/connectors/connector-slack/CHANGELOG.md b/packages/connectors/connector-slack/CHANGELOG.md index c17cce15077..e646880ebb9 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,380 @@ # @objectstack/connector-slack +## 18.0.0 + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index 418f25b2dcb..9aedc91ae36 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Slack Web API connector for ObjectStack — registers `chat.postMessage` / `chat.update` / `call` actions on the automation engine's connector registry (ADR-0018 §Addendum, ADR-0022).", "main": "dist/index.js", diff --git a/packages/console/CHANGELOG.md b/packages/console/CHANGELOG.md index a9f2db7a227..638a8b53d80 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,1393 @@ # @objectstack/console +## 18.0.0 + +### 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 + +- 7c509c2: Console (objectui) refreshed to `12dc7b47c340`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 28 releasing of 28 changesets added across 32 non-merge commits; omitted: 4 commits carrying no changeset (they ship no package code). + + - **minor** — Setup lists the sharing rules installed packages declare again, read-only, with Disable / Enable and Clone (objectui#12982). (objectui `12dc7b47c`) + - **minor** — `list-view` publishes the keys of its `@objectstack/spec` row that the renderer honours on a page node (objectui#13047). (objectui `a244a172a`) + - **minor** — **BREAKING** — An import undo that leaves rows unreverted now says so in the Import Wizard (objectui#13015). (objectui `a928aaa61`) + - **minor** — A `type: 'form'` action placed at `record_related` and fired from a related-list row now opens the clicked row's edit form (`/forms/NAME?recordId=ROW_ID&recordObject=CHILD`), as i… (objectui `9f1dd79fe`) + - **minor** — A field group whose `visibleWhen` cannot be evaluated is now hidden, header and members, on the form and on the record detail page (objectui#13055). (objectui `fb85637e1`) + - **minor** — The AI build chat's pending-drafts bar no longer covers the composer's input (objectui#12978). (objectui `51269f0fd`) + - **minor** — Bound record blocks draw an authored empty state when their binding answers no row (objectui#12932, the renderer half of objectstack-ai/objectstack#24266). (objectui `c2191654a`) + - **minor** — Publish `optionListDefault` (and its parameter type `OptionListFieldLike`) from the package entry: the reading of an option list's `default: true` that create forms seed from (obj… (objectui `b9e188762`) + - **minor** — Build `@objectstack/*` from objectstack `main` `f4755a68a7` (objectui#12971). (objectui `8aa8e3e1e`) + - **minor** — Two Cloud SDUI widgets are retired: `cloud:onboarding-next`, the Welcome page's next-step block, and `cloud:plan-status`, the pricing page's current-plan marker (objectui#12954).… (objectui `30083da91`) + - **minor** — Every app now has a report index at `/apps/APP/reports` (objectui#13020). It lists the reports the app's package ships, by their localized labels, each linking to its `report/NAME… (objectui `47508f3bb`) + - **minor** — A record page that shows the same unfiltered related list both as a `record:reference_rail` card and in a `page:tabs` count badge now reads that list once on open, not twice. The… (objectui `d0b9648ef`) + - **patch** — Studio's RLS policy editor now gives a predicate the runtime cannot lower the server's verdict: an error that holds Save and autosave (objectui#13061). A `USING` or `CHECK` clause… (objectui `2a75c83b2`) + - **patch** — A master-detail edit form now applies its line rules to the header record however late that record loads (objectui#13062). (objectui `bc98626ed`) + - **patch** — A deep link to an object read by name always leaves the "Opening your console…" splash (objectui#13052). (objectui `1ce071187`) + - **patch** — A kanban card move is announced to screen readers when its write answers, not when the card is dropped (objectui#13054). (objectui `083e80735`) + - **patch** — An `app` page that declares a `record` binding now reads that row, and its components' `visibleWhen` predicates read it as `record` (objectui#12931, the renderer half of objectsta… (objectui `a23a33d6f`) + - **patch** — One page open asks the server for one object document once (objectui#13044). The metadata provider's by-name object read now goes through the adapter's `getObjectSchema`, so the p… (objectui `65fe4c285`) + - **patch** — A record page no longer asks `POST /api/v1/security/explain` for a verdict nothing on screen uses (objectui#13045). (objectui `50ba1bb0a`) + - **patch** — Charts and dataset widgets read their object's metadata through the data source, so the widgets of one dashboard ask for one object's metadata once (objectui#13039). (objectui `988aa9fa1`) + - **patch** — The Undo of an `operation: 'update'` action covers the `patch` of the declared action its `name` names, and no action renderer forwards a page action row's own `patch` (objectui#1… (objectui `61ed5ada6`) + - **patch** — A list view draws a `referenceVia` pointer column's record when its columns do not show the type half (objectui#13036). (objectui `fe11eb12e`) + - **patch** — A record link in an assistant answer opens that record inside the console (objectui#13037). (objectui `633b8f18f`) + - **patch** — The chat's step line no longer shows the agent's tool-loading step, and no longer counts it (objectui#13038). (objectui `912bfd4a6`) + - **patch** — An assistant answer reads well in the narrow assistant panel (objectui#13032). Headings inside a chat answer render at chat size (15–16px, bold, set apart by weight and spacing in… (objectui `6f877f1df`) + - **patch** — The console's pending-approvals read now asks for one page of requests and takes the count from the route's `total` (objectui#13027). (objectui `d14ba9636`) + - **patch** — A record page of a `trackHistory` object reads its audit history when the History tab is shown (objectui#13010). (objectui `acefe6fa6`) + - **patch** — A record page reads its timeline, comments, user list and foot-of-page attachments when they are shown, not on every open (objectui#13010, part 2). (objectui `29c19cc1a`) + + ⚠️ 1 of these carries a breaking change: 1 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 4 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ fix(ci): read transient jobs-API failures, re-run attempts and stale shards without false reds in the aggregate Test gate (objectui#13064) (#13077) (objectui `90a4fc49b`) + - _(no changeset)_ tooling(pin): keep one pinned @objectstack build per machine and copy it into each new checkout (objectui#12972) (#13035) (objectui `f9401e6af`) + - _(no changeset)_ tooling(pin): commit the pin's packed manifests so a lockfile-only resolution needs no build (objectui#13008) (#13031) (objectui `b014f5eea`) + - _(no changeset)_ tooling: publish computes each @objectstack/* floor from .objectstack-sha (objectui#12958) (#13024) (objectui `4484a1104`) + + Clause-②: no + + + + objectui range: `4016370af3c9...12dc7b47c340` +- 265ef64: Console (objectui) refreshed to `5e15928b4631`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 88 releasing of 91 changesets added across 82 non-merge commits; omitted: 3 release-nothing changesets, 2 commits carrying no changeset (they ship no package code). + + - **minor** — **The AI build conversation stays on the conversation and reads more compactly** (objectui#12724). (objectui `ac5907384`) + - **minor** — A section entry written as a bare field name no longer reads the object field's `visibleOn` when sections are normalized (`normalizeSectionField`, used by `ObjectForm`, `ModalForm… (objectui `684f22e84`) + - **minor** — Lists at narrow widths: a status chip is never cut to "Publish…", a table wider than its panel shows that it scrolls, and the Actions column is drawn only when a row offers an act… (objectui `4839f5caf`) + - **minor** — A `list-view` block drawn as a compact list in a narrow page region now shows a "View all" link under its rows (objectui#12703). (objectui `33ee9de4c`) + - **minor** — A list block in a page region narrower than half the page draws a compact list (objectui#12703). (objectui `a8add5c71`) + - **minor** — On a record page, a related list's "View all" opens the child object's list (objectui#12741). The destination is the ADR-0055 bare data surface, `/apps/APP/OBJECT/data?filter[FIEL… (objectui `8df26086d`) + - **minor** — `RelatedRecordHandlers` gains an optional `onViewAll` (objectui#12741). A host returns it from `resolve` to open the child object's full list page, scoped to the parent on the rel… (objectui `8df26086d`) + - **minor** — `record:related_list` now draws the "View all" link its `showViewAll` key declares (objectui#12741, ENFORCE, the objectui half of objectstack-ai/objectstack#23941). (objectui `8df26086d`) + - **minor** — The identity import no longer offers "Update existing (skip if no match)" (objectui#12697). (objectui `3e2c7634b`) + - **minor** — The import wizard offers only the write modes its host says the target accepts (objectui#12697). (objectui `3e2c7634b`) + - **minor** — **BREAKING** — The metadata-admin designer reads and writes the form-view visibility predicate as `visibleWhen` only (objectui#12696). **BREAKING** for a host that still spells the retired `visi… (objectui `236624d56`) + - **minor** — The console page header folds its actions before it cuts the description (objectui#12705). (objectui `80863adea`) + - **minor** — `LoginForm` no longer tells an SSO-enforced environment's users to use a password (objectui#12682). (objectui `9b69e1325`) + - **minor** — The audit log browser (`audit:log`) offers `sys_audit_log`'s list views as presets and links to the object list (objectui#12667, the objectui half of objectstack#23890). (objectui `8296c6c3c`) + - **minor** — App pages lay a region's blocks out on a 12-column grid, and the page description sits under its title (objectui#12624). (objectui `c9e67425e`) + - **minor** — Seven `marketplace.*` keys are removed from all ten language packs: `installedTitle`, `installedSubtitle`, `installedEmpty`, `installedAt`, `installedBy`, `installedPackageId` and… (objectui `9d857c998`) + - **minor** — Setup → Packages is the one list of what is installed: every package carries a Source, and marketplace installs carry their actions on their own row (objectui#12675, ruling B on o… (objectui `9d857c998`) + - **minor** — A settings page's `info_banner` row speaks the session locale, draws a warning in amber, and renders its body as Markdown (objectui#12671, the objectui half of objectstack#23855). (objectui `a2f38ddff`) + - **minor** — **BREAKING** — A page `list-view` node is judged in its `properties` bag, in `@objectstack/spec`'s list view spelling, and the retired `objectName` / `viewType` are refused by name (objectui#126… (objectui `378896da6`) + - **minor** — A navigation entry whose `visible` predicate cannot be evaluated is no longer drawn for everyone (objectui#12700). (objectui `71243db0d`) + - **minor** — New export `isRecordPageOf(page, objectName)` (objectui#12715): true when `page` is a record page bound to `objectName` (`type: 'record'` and `object` names it). It is the one rea… (objectui `c7462e7c8`) + - **minor** — The console renders a record on a NAMED record page (objectui#12715, the objectui layer of the print-page series). (objectui `c7462e7c8`) + - **minor** — Setup draws one page header on every page: the `sys_*` record lists, the metadata-type pages, the settings pages and the component pages. (objectui `c41ce3f24`) + - **minor** — The console's form page (`FormPage`, the `/forms/:name` and public `/f/:slug` routes) reads a form view field's and section's visibility predicate from `visibleWhen` only (objectu… (objectui `611566b4d`) + - **minor** — A form view field's `visibleOn` is no longer read when sections are normalized (`normalizeSectionField`, used by `ObjectForm`, `ModalForm` and the record form page) (objectui#1265… (objectui `611566b4d`) + - **minor** — `SchemaRenderer` no longer reads a page component's `visibility` key (objectui#12654). `@objectstack/spec` 18 retires it as a `retiredKey` tombstone naming `visibleWhen` (objectst… (objectui `611566b4d`) + - **minor** — **BREAKING** — A page `list-view` node is written in `@objectstack/spec`'s list view spelling, `data` and `type`, and a metadata page reads `properties.type` as the list kind (objectstack-ai/obj… (objectui `5c0846ebd`) + - **minor** — Show a write answer's advisory `warnings` to the user after save (objectui#12107). (objectui `6757a932b`) + - **minor** — A list names what an `object-ref` or `recipient-picker` field references, instead of printing its api name (objectui#12613). The Sharing Rules list read `crm_account` and `sales_m… (objectui `278bc29ea`) + - **patch** — Clause-②: no (objectui `5e15928b4`) + - **patch** — The console's startup sends one `get-session` and one `runtime/config` request instead of two of each: the session check and the early-branding request now hand their answers to t… (objectui `5d69b0e73`) + - **patch** — Opening the Cloud pricing or Welcome page now asks for the organisation's environment entitlements once. The plan cards, the onboarding next step and the workspace-timezone notice… (objectui `5d69b0e73`) + - **patch** — `AuthProvider` no longer asks `get-session` a second time when the console has just validated the stored session before rendering. The validated answer becomes the provider's firs… (objectui `5d69b0e73`) + - **patch** — Studio names what it shows by label on two more surfaces (objectui#12693). (objectui `cda4076be`) + - **patch** — `aggregate()` no longer reads a served `null` measure as a missing one. The analytics answer's `null` (the max or min over rows whose field is all NULL) now reaches the caller as… (objectui `35ef75036`) + - **patch** — The kanban board's drag-and-drop tells a screen reader the card's title and the column's label, in the console's language, instead of the record id and the option value (objectui#… (objectui `36e2f7b67`) + - **patch** — fix(app-shell): the Studio Validations editor says why it is read-only, to hover and to the keyboard (objectui#12691) (objectui `d0a00d795`) + - **patch** — The console's create / edit record modal now opens at the size an object's default form view authors in `modalSize` (`sm`, `default`, `lg`, `xl` or `full`). On desktop the authore… (objectui `2ff84f8bf`) + - **patch** — The "N eligible approver(s)" figure beside an approval's decision progress counts people, not slots (objectui#12781). The record page's approvals panel and the approval request pa… (objectui `f3a7b870a`) + - **patch** — fix(app-shell): "Discard draft" on the metadata editor names the item by its label, not its machine id (objectui#12690) (objectui `0f42aaf80`) + - **patch** — The developer Flow Runs page's flow picker names each labelled flow by its label again, on the list a real server serves (objectui#12783). (objectui `b52415010`) + - **patch** — The flow designer's start node offers each field on exactly the triggers the engine reads it for (objectui#12780). (objectui `79270e7c4`) + - **patch** — The app header titles a view stored with no `label` by its object's plural label, the name its tab shows (objectui#12805). (objectui `3288c12f7`) + - **patch** — A link from one doc to another in the docs portal now carries the console's base path (objectui#12686). The portal rewrites a doc body's `[x](./other_doc.md)` cross-reference to t… (objectui `8cf9cf305`) + - **patch** — **`marketplace.installedAdditiveNote` says what uninstall does now, in all ten packs** (objectui#12684). The note on a loaded marketplace row's Uninstall (its tooltip and accessib… (objectui `670ea1041`) + - **patch** — The metadata directory's "View all issues" link no longer paints warnings as errors (objectui#12768). (objectui `4279c2ed2`) + - **patch** — A list view's `date-range` user-filter chip draws the platform date picker on both bounds, and names them in the console's language (objectui#12784). It used to draw two native da… (objectui `99a0d3bf0`) + - **patch** — A settings row's source badge reads in the session's language, verb included (objectui#12694). The badge beside a setting's label said where its value comes from as an English ver… (objectui `ff215d934`) + - **patch** — A record on a print page prints its own title field (objectui#12787). Off a print page, `record:details` hides the row that the page header's H1 already shows: the declared name f… (objectui `6e4f14442`) + - **patch** — The ⌘K command palette closes after every command it runs, including one that does not navigate (objectui#12683). (objectui `3d39ca78c`) + - **patch** — The flow designer, canvas, Debug simulator and read-only flow viewer treat `http` as the HTTP node type, and no longer recognise `http_request`, `http_call` or `webhook` as node t… (objectui `5483297ea`) + - **patch** — `useActionTextLocalizer` resolves every `I18nLabel` copy an action declares (objectui#12757). It used to collapse a per-locale map only for `label` and `successMessage`, and only… (objectui `655e8977d`) + - **patch** — A page's `element:button` shows its inline action's copy in the user's language (objectui#12757). The spec types an inline action's `label`, `confirmText`, `successMessage` and `e… (objectui `655e8977d`) + - **patch** — A new report's or dataset's Label in a non-Latin script no longer names it `field` (objectui#12789). The report and dataset designers derive the Name from the Label until you type… (objectui `0b80f501f`) + - **patch** — A view stored with no `label` is named by its object's plural label on the object page, never by its qualified name (objectui#12774). The spec gives a view's `label` no default, a… (objectui `c4467ccb2`) + - **patch** — The permission set editor's provenance badge decides from `packageId` and the package artifact only (objectui#12753). Spec 18 retired the authored `managedBy` on a permission set… (objectui `c054d018d`) + - **patch** — An SSO sign-in reads in the session's language on both login pages (objectui#12762). The console's login page and app-shell's `DefaultLoginPage` passed `LoginForm` none of its fou… (objectui `8d80845af`) + - **patch** — The object designer no longer offers a "Relationship name" box on lookup, master-detail and tree fields (objectui#12758). (objectui `b275f5804`) + - **patch** — Clause-②: no (objectui `260ef7196`) + - **patch** — fix(components): `page:header` reads the spec's `variant: 'danger'` as destructive (objectui#12764) (objectui `69a408f64`) + - **patch** — In the collapsed (icon-mode) sidebar, a click on an icon reaches its row again. Before, a hidden group label could take the click instead (objectui#12744). (objectui `f1142634e`) + - **patch** — Studio: the "Read-only · Customize with an overlay" link at the end of an item's tab row (an open flow in Automations, an open permission set in Access) now folds to its layers ic… (objectui `2368d7af1`) + - **patch** — A new report's or dataset's Name keeps every underscore you type (objectui#12746). The report and dataset designers' Name box normalized each keystroke with the strict identifier… (objectui `94b2489f4`) + - **patch** — An inline edit of a `datetime` field on the record page keeps its time (objectui#12759). The record details section and the header highlights edited `date` and `datetime` fields w… (objectui `020d1788c`) + - **patch** — A record on a print page no longer prints its approval band: no "Recall approval" button, no approval status badge and no approval tally on paper (objectui#12751). (objectui `6367bfeba`) + - **patch** — List filters on `date` and `datetime` fields draw the platform date picker, the face record forms, action dialogs and grid cells already draw (objectui#12743). The native input to… (objectui `ed43c79ce`) + - **patch** — The flow designer no longer calls a live time-relative sweep "not in effect" (objectui#12672). (objectui `8cc02b444`) + - **patch** — The app bar's breadcrumb folds instead of cutting every segment short (objectui#12702). (objectui `4857cd284`) + - **patch** — Studio's Interfaces rail and the dashboard component list show the app's navigation and the widget titles in the session's language (objectui#12688). (objectui `41c1d9e93`) + - **patch** — The list toolbar draws Filter, Sort and Display as 28 px icon buttons at every width, on every list that mounts it: app object lists, Setup lists and related lists (objectui#12695… (objectui `0171c2494`) + - **patch** — After a reload, the console no longer keeps the previous organization's id once the server says the session has no active organization (objectui#12698). better-auth clears a sessi… (objectui `6e141d999`) + - **patch** — The derived highlight strip on a record page no longer picks a field from a field group that declares a `visibleWhen` (objectui#12720). (objectui `360e27a14`) + - **patch** — Metadata Diagnostics no longer paints a clean sweep as an error (objectui#12721). (objectui `a214fbe2e`) + - **patch** — Browse Marketplace says what it is loading, and a failed catalog read names its class and offers Retry (objectui#12710). (objectui `a23e80464`) + - **patch** — Setup → Packages: the Scope badge now reads the server's `writable` verdict for its writable / read-only half (objectui#12723). A marketplace install has no `scope` and the server… (objectui `8dc313530`) + - **patch** — The grid's inline cell editor and the kanban required-fields dialog draw `date` and `datetime` fields with the platform date picker, the face record forms and the action dialogs a… (objectui `d66d7e834`) + - **patch** — Editing a formula field no longer shows a false "retired `formula` key" error (objectui#12681). (objectui `7ead04904`) + - **patch** — Home answers a `current_user.can()` navigation gate from the caller's permissions (objectui#12700). (objectui `2dbe5f533`) + - **patch** — A period-over-period delta from a zero baseline no longer claims "100%" (objectui#12677). (objectui `0666a66f3`) + - **patch** — A collapsible record details section on a print page prints its header as a plain heading, with no disclosure chevron and no clickable-header chrome (objectui#12732). (objectui `464ae629b`) + - **patch** — Opening a record page sends one approvals read and one record read (objectui#12701). (objectui `e219a3d6c`) + - **patch** — In the metadata designer's create mode, a Name the author typed is no longer replaced when the Label is typed afterwards (objectui#12680). This holds for the object, report and da… (objectui `1d869199c`) + - **patch** — A caller the Setup app refuses gets the access-denied screen on Setup's item pages instead of those pages under another app's chrome (objectui#12706). (objectui `a88c20604`) + - **patch** — Console lists link names that point at another object's record, the way the record page does (objectui#12673). (objectui `b393152f7`) + - **patch** — A record details section on a print page prints every field it declares, with no "Show N empty fields" button on paper (objectui#12717). (objectui `263e9ddcc`) + - **patch** — A form section that declares no `columns` now draws two fields per row from the container's `@md` width (one below it) on every page form host — `ObjectForm`, `SplitForm`, `Tabbed… (objectui `96fa4b9bc`) + - **patch** — Setup's permission-set and position holders offer Add and Remove only to a caller holding the grant the server asks for those writes (objectui#12082). (objectui `fac2c2496`) + - **patch** — The app bar's Edit app pencil now appears only where Studio can edit: for a metadata author, on an app whose package Studio lists as writable. A read-only package's app (such as a… (objectui `40e587342`) + + ⚠️ 3 of these carry a breaking change: 3 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 2 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ fix(e2e/live): start-backend.sh records the server's pid, not a subshell's (objectui#12676) (#12737) (objectui `c22068922`) + - _(no changeset)_ chore(scripts): raise every eager-closure ceiling by 20% and move the regression the gate must catch to 695 KiB (objectui#12653) (#12699) (objectui `e6175b57f`) + + Clause-②: no + + + + objectui range: `f8267df93aba...5e15928b4631` +- f5e98a3: Console (objectui) refreshed to `0707b4ae3712`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 84 releasing of 85 changesets added across 67 non-merge commits; omitted: 1 release-nothing changeset, 1 commit carrying no changeset (they ship no package code). + + - **minor** — **The AI build cards draw the plan and result details the build agent now sends** (objectui#12724). (objectui `44cb469db`) + - **minor** — **BREAKING** — Line-item grids read a boolean column's options and a file cell's text in the session language (objectui#12924). Under a zh-CN session, a master-detail or `record:line_items` grid… (objectui `32b492b67`) + - **minor** — A `${…}` hole inside an inline locale map is evaluated (objectui#12936). In a node's `properties` bag (or its legacy `props` alias, or a block member evaluated with `useConfigBagE… (objectui `a7ae2521a`) + - **minor** — The approval decision panel (`record:approval_decision`) shows where a request came from and where its flow stands (objectui#12491). A request no person submitted, its `submitter_… (objectui `7bbd718be`) + - **minor** — The Approval Center's request drawer no longer has a "Raw data (JSON)" panel, for any viewer (objectui#12490). (objectui `9b82cedad`) + - **minor** — feat(react): a page element's API data source is rendered — `dataSource: { provider: 'api', read: { url }, path? }` binds the selected answer as `data` (objectui#12197) (objectui `c4dd7078e`) + - **minor** — feat(app-shell): five generic product-page blocks — `hero`, `stat`, `usage-bar`, `checklist`, `tile-grid` (objectui#12197) (objectui `c4dd7078e`) + - **minor** — The separate Installed Apps view is retired (objectui#12733, ruling B on objectstack#23892). Setup → Packages filtered to Marketplace is the one list of what is installed (objectu… (objectui `35244b637`) + - **minor** — A host can ask the console to draw no app switcher when the viewer is served one app (objectui#12913, the objectui half of objectstack-ai/cloud#2919). (objectui `d15ad87b1`) + - **minor** — `record:path`: a stage derived from a picklist now takes its terminal from the option's declared lifecycle `kind` (objectstack#23809) — `won` is the forward terminus, `lost` sits… (objectui `8392f74af`) + - **minor** — A record whose record page declares `print` shows its header actions again (objectui#12906). (objectui `d637be321`) + - **minor** — A list's user-filter chip no longer shows the raw field key while the object definition is loading (objectui#12890). (objectui `1b8864556`) + - **minor** — A page laid out by a slotted template draws only the regions that template places, and says so on the page for any other (objectui#12476, maintainer ruling A on objectstack-ai/obj… (objectui `044f23193`) + - **minor** — **Breaking (types):** a form select option (`SelectOption` and its zod twin `SelectOptionSchema`) carrying `kind` or `default` is now refused (objectui#12736). (objectui `c999d4c01`) + - **minor** — In Studio's Interfaces pillar, a click on a card of a kanban view's board selects the board's card, and a 看板卡片 (Kanban card) section in the view's properties edits it (objectui#12… (objectui `f5009db72`) + - **minor** — `element:text` now draws the inline Markdown its spec declares (objectui#12778). `ElementTextPropsSchema.content` is described as "Text or Markdown content", and the renderer prin… (objectui `d2b21a122`) + - **minor** — Resolve `@objectstack/*` `18.0.0-next.3` (objectui#12873). (objectui `26323c4fc`) + - **minor** — In Studio's Interfaces pillar, a click on a column header in an `object` leaf's canvas selects that column in the view's properties (objectui#12725, target 2: clicking a canvas el… (objectui `e058d7697`) + - **minor** — New `isDeadlineSettled(field, row, { scope, fields })` (objectui#11815). It answers whether a declared deadline is settled for one row: `true` only when the field declares `dueLik… (objectui `f34e877b6`) + - **minor** — The record page and related lists honour a deadline's `settledWhen` (objectui#11815). The details section, the highlights strip and each related-list row judge a declared deadline… (objectui `f34e877b6`) + - **minor** — A date reads overdue only when its field declares it a deadline, and never once the deadline is settled for the record (objectui#11815). (objectui `f34e877b6`) + - **minor** — A done row's past deadline no longer reads overdue in the grid (objectui#11815). Every column shape — object columns, name columns, inline-row fields and the object's default colu… (objectui `f34e877b6`) + - **minor** — A kanban card's declared deadline reads neutral once the card's record settles it through the field's `settledWhen`, and "Overdue Nd" in red otherwise (objectui#11815). (objectui `f34e877b6`) + - **minor** — Gallery cards and the compact list honour a deadline's `settledWhen` (objectui#11815): a done record's past due date reads neutral, an open one's reads "Overdue Nd" in red. Galler… (objectui `f34e877b6`) + - **minor** — A tree row's declared deadline reads neutral once the row's record settles it through the field's `settledWhen`, and "Overdue Nd" in red otherwise (objectui#11815). (objectui `f34e877b6`) + - **minor** — `DetailViewField.dueLike` and its `DetailViewFieldSchema` row are removed, with no alias (objectui#11815). A deadline is declared on the object's field metadata — `DateFieldMetada… (objectui `f34e877b6`) + - **minor** — feat(react): `MetadataContextValue.readObjectScope`, the object definitions one app reads (objectui#12811) (objectui `5ff64efc7`) + - **minor** — Studio's Data and Automations pillars show their properties in the one right slot, as the Interfaces pillar does (objectui#12725, item 3: properties follow the selection in every… (objectui `8d6e43574`) + - **minor** — Studio has one right slot that shows either the open item's properties or the AI copilot, never both (objectui#12725, items 1, 2, 6 and the canvas half of item 3). (objectui `34354a4b6`) + - **minor** — `DateFieldMetadata` and `DateTimeFieldMetadata` take the deadline keys from `@objectstack/spec`'s `FieldSchema` (objectui#12297). (objectui `8106db1f9`) + - **minor** — The console splash no longer waits for requests the first screen does not need (objectui#12809). (objectui `84b8ffa4b`) + - **patch** — A metric tile's value now scales before it would clip. A long value, such as a seven-digit money figure with cents (`1,290,000.00`), steps down from 24 px to 14 px as the tile nar… (objectui `0707b4ae3`) + - **patch** — `@object-ui/plugin-detail` now declares `@objectstack/spec` `^18.0.0-next.3`, up from `^18.0.0-next.1` (objectui#12946). (objectui `03f1d6528`) + - **patch** — Raise `@object-ui/app-shell`'s `@objectstack/spec` floor from `^18.0.0-next.2` to `^18.0.0-next.3` (objectui#12943). (objectui `641dad4b3`) + - **patch** — An `operation: 'update'` action no longer posts its declared `patch` as request `params` (objectui#12941). The platform action route applies the patch itself, from the action defi… (objectui `fb501bc89`) + - **patch** — Setup → Packages reads each package's Source from the server (objectui#12790, ruling B on objectstack#23892). (objectui `bc96bc34b`) + - **patch** — `record:details` draws a boolean the viewer cannot edit as the locale's word for its value (`common.yes` / `common.no`) instead of a disabled checkbox that read as a setting refus… (objectui `79cb8c03e`) + - **patch** — All ten language packs gain the panel titles the data-bound blocks now read for an unresolvable `dataSource.view` (objectui#12920): `element.KIND.sourceUnresolved` for `calendar`,… (objectui `1e6d83bc4`) + - **patch** — Data-bound blocks title their "data source could not be resolved" panel in the page's language (objectui#12920, the family of objectui#12910). The panel shows when a block's `data… (objectui `1e6d83bc4`) + - **patch** — A read-only `GridField` table shows a select cell's option label and a number or currency cell in the session's locale (objectui#12915). (objectui `8523e81db`) + - **patch** — The kanban swimlane that holds cards with no value in the swimlane field is headed by the pack's `kanban.uncategorized` instead of the English constant (objectui#12899). Under a n… (objectui `e1548b794`) + - **patch** — Studio's "Add region" writes only a region name the page's template places, and a page that names no template refuses an unplaced region on screen (objectui#12902). (objectui `bc281f4a1`) + - **patch** — The `element:*` blocks registered from `renderers/basic` draw their defaults in the page's language (objectui#12910, the close-out of objectui#12889). Each default reads `@object-… (objectui `2cfdc99df`) + - **patch** — All ten language packs gain the keys the `element:*` blocks in `@object-ui/components` now read for their defaults (objectui#12910): `element.number.sourceUnresolved`, `element.im… (objectui `2cfdc99df`) + - **patch** — A Studio item's title no longer disappears beside the header's actions on a narrow window (objectui#12897). (objectui `7bee002d2`) + - **patch** — `record:line_items` fills an untyped column from the child object's field (objectui#12854, the objectui half of objectstack-ai/objectstack#24140). (objectui `54e79f989`) + - **patch** — `ChatbotEnhanced`'s `onDesignBuiltApp` doc comment now describes what the finished build card draws since objectui#12724: "Open app" (打开应用) is the primary action and "Design in St… (objectui `c83e3fe53`) + - **patch** — Retire what the post-build transition left behind after objectui#12724 (objectui#12842). (objectui `c83e3fe53`) + - **patch** — The Console top bar no longer flashes a green "Connected" chip for two seconds after load (objectui#12891). (objectui `2727527b1`) + - **patch** — Page element blocks draw their loading, empty and error defaults in the page's language (objectui#12889). (objectui `97538115b`) + - **patch** — The console's boot session check now keeps the renewed session cookie (objectui#12892). Before the app renders, the console checks the stored sign-in token with one `get-session`… (objectui `285ddb22c`) + - **patch** — The kanban board's card lists and drag overlay are now named in the viewer's language. A lane's card list, a swimlane cell's card list and the drag overlay used English templates… (objectui `4ff5b6b3c`) + - **patch** — Studio's new-package dialog no longer offers a scope. Its Advanced section used to list `cloud`, `system` and `project`, and the server refuses the first two when a package is cre… (objectui `8e7597836`) + - **patch** — A formula field declared `returnType: 'currency'` renders as money, in a list cell, a form and a record detail (objectui#12111). (objectui `898bd2c06`) + - **patch** — The marketplace README renderer draws its inline Markdown with `@object-ui/components`' `renderInlineMarkdown`, the one `element:text` uses (objectui#12778), so a README link now… (objectui `d2b21a122`) + - **patch** — A focused kanban card opens on Enter and on Space (objectui#12883). Every card was already a keyboard tab stop, but its only activation was the pointer click, so a keyboard user c… (objectui `1d872f2f7`) + - **patch** — Metadata designer create flows now show the spec create seed's structural defaults before the first save. A new dataset's Measures section reads 1 (the seed's `count` measure) as… (objectui `2ec9e2a1a`) + - **patch** — The public form `/f/:slug` shows each section's description under its heading, as served (objectui#12333). (objectui `2b0ab9480`) + - **patch** — The OAuth consent page names the three agent scopes in words. An MCP client that asks for `data:read`, `data:write` or `actions:execute` used to show those as raw strings in the "… (objectui `b27901612`) + - **patch** — Kanban cards no longer tell screen-reader users to press the space bar. Each card's accessible description is now a sentence from the `kanban.dnd.instructions` bundle key, in the… (objectui `8a25feec5`) + - **patch** — A related list on a custom app page draws and runs its authored actions when its parent record is bound through `parentDataSource` (objectui#12870). (objectui `15b10655e`) + - **patch** — The approval decision panel's record summary honours a declared deadline's `settledWhen` (objectui#11815), judged against the snapshot it shows: a done record's past due date read… (objectui `bf376c5b3`) + - **patch** — The approvals inbox's record summary honours a declared deadline's `settledWhen` (objectui#11815), judged against the submitted snapshot it shows: a done record's past due date re… (objectui `bf376c5b3`) + - **patch** — A list view that shows a declared deadline but not the field its `settledWhen` reads now fetches that field, so a done record's past due date reads neutral instead of overdue (obj… (objectui `bf376c5b3`) + - **patch** — The data table's header cells name their column: each carries `data-column-key` with the column's `accessorKey` (objectui#12725). Studio's canvas reads it to tell which column a h… (objectui `e058d7697`) + - **patch** — A settings row's chips read in the session's language (objectui#12821). The env-lock chip said "Locked by env" in every language, and its title, shown when the server names no rea… (objectui `64acb7e59`) + - **patch** — The command palette's recently viewed rows now show what each item is: the record's object label on the right (in the viewer's language) and the object's icon on the left, in plac… (objectui `0e989914e`) + - **patch** — A refused activity read now shows one neutral sentence, "You don't have permission to view activity.", on Home's activity card and in the header bell's Activity tab. Home used to… (objectui `bb6c38cb6`) + - **patch** — A long api name no longer cuts a Studio item's title to a few characters (objectui#12791). (objectui `0c511f7d9`) + - **patch** — perf(app-shell): a console open reads the current app's objects, not the whole object registry (objectui#12811) (objectui `5ff64efc7`) + - **patch** — The flow designer names a schedule flow "Schedule", not "Schedule (cron)" (objectui#12845). (objectui `955dbaf8d`) + - **patch** — The workspace switcher, avatar menu, organisations page, create-organisation dialog and home screens fall back to the same "Organization" / "Environment" wording as the language p… (objectui `473e5a0a8`) + - **patch** — The sign-in page's tenant hint falls back to "You are signing in to this environment", matching the language packs (objectui#12851). (objectui `473e5a0a8`) + - **patch** — The console no longer calls two different things "workspace" (objectui#12851). (objectui `473e5a0a8`) + - **patch** — Dataset designer: picking a new dataset's base object no longer drops the template's `count` measure, so a dataset created by opening it, picking an object and saving keeps its ro… (objectui `fb1700372`) + - **patch** — A declared action `icon` is now drawn beside the label on the row's inline primary button, the row "⋮" menu item and the record-header quick action (`record:quick_actions`). The n… (objectui `1aa622a7a`) + - **patch** — Home's "Activity feed" card now shows the record activity panel's permission sentence ("You don't have permission to view activity…") when the server refuses its `sys_activity` re… (objectui `024d44694`) + - **patch** — In the collapsed (icon-mode) console sidebar, the hidden Area, Recent and Starred group labels are no longer announced by screen readers (objectui#12795). The `SidebarGroupLabel`… (objectui `e43a16207`) + - **patch** — In the collapsed (icon-mode) sidebar, a hidden group label is no longer a keyboard focus stop, and screen readers no longer announce it (objectui#12795). Before, Tab from the row… (objectui `e43a16207`) + - **patch** — A metric widget with no `layout` on a dashboard that declares `columns` now spans one grid row instead of two, the same height the dashboard designer gives a new widget. The tile… (objectui `db4417e21`) + - **patch** — Approvals Inbox: on a narrow screen the inline Approve / Reject column stays pinned to the table's right edge, with a shadow on its left edge while the table scrolls under it, ins… (objectui `4b3d6edae`) + - **patch** — Studio Interfaces names a package that has no app yet by its display name, the one the top bar prints, instead of its id (objectui#12718). The rail heading read `com.qa.recheck ·… (objectui `e1351a8e3`) + - **patch** — Metadata designer create flows no longer save the spec create seed's placeholder identity. The seed's `label` and its placeholder object binding are taken from the author or not a… (objectui `b921f9090`) + - **patch** — In the collapsed (icon-mode) console sidebar, the rail now scrolls vertically when its rows overflow, as the expanded sidebar does. Before, the rows below the fold could not be re… (objectui `0812a190f`) + + ⚠️ 2 of these carry a breaking change: 2 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears 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(adr-0056): AdminScopeSchema target shape lists only the live keys (#12903) (objectui `5ec2175fd`) + + Clause-②: no + + + + objectui range: `5e15928b4631...0707b4ae3712` +- 3b086e7: Console (objectui) refreshed to `4016370af3c9`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 32 releasing of 32 changesets added across 33 non-merge commits; omitted: 1 commit carrying no changeset (they ship no package code). + + - **minor** — **BREAKING** — The object-page special cases for the retired permission-set catalog object are removed (objectui#7611, after objectstack's ADR-0131 stage 8 retired `sys_position`, `sys_permissio… (objectui `78b07f468`) + - **minor** — `@object-ui/react` now exports `buildNodeEvaluationScope(predicateScope, boundRecord, pageVariables)`, the one builder of the scope a page node's `visibleWhen` and other values ar… (objectui `9e7e1a55b`) + - **minor** — A page route reads the objects its page binds, not the whole object registry (objectui#12980, part 1). (objectui `024b7b665`) + - **minor** — feat(plugin-chatbot): a drafted result's Preview button appears for any draft kind, and the draft preview bar names what is pending on the surface it shows (objectui#12968) (objectui `76f91f80d`) + - **minor** — A row action's templates read the row as `record`, the way the record header's do (objectui#12967). (objectui `025130ca0`) + - **minor** — The Studio copilot's applied card can open the change review and undo the draft; the 变更 badge follows an in-turn publish; the chat fits the copilot's slot at a 1010 px window (obj… (objectui `43efd078a`) + - **minor** — The list toolbar's Display control compares hidden fields and density against the view's own value again (objectui#12949). On a console list, after a user hid a column (or changed… (objectui `b5153f44e`) + - **minor** — Studio's 变更 badge and the item header follow the copilot's drafts as they land (objectui#12725, item 5). (objectui `eee54ed79`) + - **minor** — **The copilot's change card says when a change will be saved as a draft, and the applied card offers review and undo** (objectui#12894). (objectui `3da39ae42`) + - **patch** — data-table: the whole leading cell of a selectable row is now the selection's hit area. A click beside the small checkbox toggles the row instead of opening the record (objectui#1… (objectui `4016370af`) + - **patch** — `page:tabs` probes a related list's badge count from the record the record context names (`recordId`, the route's id on a record page) instead of the id inside the loaded row, so… (objectui `46dbf32bf`) + - **patch** — The permission-set editor's read-only badge now names the caller's capability when that is the gate that closed (objectui#13011). (objectui `09eadf4e1`) + - **patch** — The console sidebar's capability gate no longer loads the whole object registry in an app created in the console (an app with no package). It asks, by name, every object the app l… (objectui `2b1651d14`) + - **patch** — The by-name object scope no longer records a failed read as a missing object (objectui#13009). A by-name read that obtains no answer (a 5xx, a network drop, a 404 without the `RES… (objectui `d0934b919`) + - **patch** — The AI build plan card now draws a composed record title's line (for example 「名称:自动生成「{图书} · {读者} · {借出日期}」,表单里不出现」) in place of the plain 「名称:LABEL」 line, read from the plan card… (objectui `4b7ce4705`) + - **patch** — In an app that names no package (an app created in the console is stored bound to none), two chrome readers no longer load the whole object registry (`GET /api/v1/meta/object`) wh… (objectui `1624e08f6`) + - **patch** — A page route reads more of the objects its page binds in its own scope read, and an interface list page inside a package app no longer reads its source object a second time (objec… (objectui `d3fd5e4f9`) + - **patch** — `record:quick_actions`: a refused declared action array (a mixed id/object array) no longer draws its "refused at index N (see console)" box in a runtime view. That box is develop… (objectui `0d9764260`) + - **patch** — `record:quick_actions` no longer shows its authoring placeholder ("record:quick_actions — no actions configured") to a viewer whose named actions are all withheld. A block that na… (objectui `1d3da6c1e`) + - **patch** — The AI build plan card draws a stage field's lifecycle on its chip (for example 「借出 · 已还 ✓完成 · 逾期 ⚠」), read from the plan card's `lifecycle` key. A chip without the key draws as b… (objectui `5f599dbcd`) + - **patch** — Opening the console no longer starts nine reads the page does not show in the same moment as the page's own reads (objectui#12980, part 3). (objectui `6db875efc`) + - **patch** — On a free-plan organization, the environment list's locked mark, "Development environments · Team plan and up" (zh 「开发环境 · 团队版起」), is now drawn only for a viewer who can upgrade (… (objectui `4dfdebdf5`) + - **patch** — `page:tabs` no longer counts a related list the viewer cannot see (objectui#12987). A tab's derived badge sums the `record:related_list` blocks under it; a list whose own node-lev… (objectui `909c64e3a`) + - **patch** — `record:quick_actions` maps an action's spec `variant` onto the `Button` vocabulary the way the record header and the row menu do: `danger` draws the destructive style and `primar… (objectui `bcd9717e8`) + - **patch** — The Import Wizard no longer offers "Run automations & triggers" or "Import as historical data" (objectui#12985). Both import doors (`POST /api/v1/data/:object/import` and `/import… (objectui `cb8000181`) + - **patch** — A console route's view no longer holds the boot splash after the route's data is in (objectui#12980, part 2). (objectui `736b9cee6`) + - **patch** — Console: a create-workspace refusal carrying objectstack's `ORGANIZATION_CREATION_DISABLED` code now shows the localized "Creating new organizations is disabled on this instance."… (objectui `ccddde951`) + - **patch** — The help menu gains an About entry on every console bar (the tenant console and the control plane draw the same header). It opens a small dialog naming the build the console was c… (objectui `ea34d9b7e`) + - **patch** — The Studio copilot no longer draws the floating pending-changes bar over its composer (objectui#12965, part of objectui#12725). (objectui `32a171e56`) + - **patch** — The list's quick-search box and the record pickers' type-ahead are no longer offered on an object whose search resolves to no field, and a refused search shows the server's messag… (objectui `b7fe09eba`) + - **patch** — The metadata designer's copy no longer names the running system by the cloud noun "environment" (objectui#12960, the same rule objectui#12956 applied to the locale packs). Where a… (objectui `8dfc6b7a4`) + - **patch** — Console copy names the system the user is already in, not the cloud noun "environment" (objectui#12956). Inside a running deployment, the Settings subtitle and its first category,… (objectui `7f9cc37d0`) + + ⚠️ 1 of these carries a breaking change: 1 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears 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)_ feat(tooling): objectui consumes @objectstack/* by commit — .objectstack-sha, bump-objectstack.sh, dev and CI built from the pin (objectui#12957) (#12963) (objectui `749ed7607`) + + Clause-②: no + + + + objectui range: `0707b4ae3712...4016370af3c9` +- 74c4b34: Console (objectui) refreshed to `088e0f705520`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 3 releasing of 3 changesets added across 3 non-merge commits. + + - **minor** — A record-form field can name one item of a metadata registry by its machine name: a `text` field that declares `widget: 'position-ref'` lists the position registry, and one that d… (objectui `088e0f705`) + - **patch** — A position approver is read through the spec's registry binding, `{ source: 'registry', type }` (objectstack#22710): its readable label and its designer picker come from the posit… (objectui `83b9fade2`) + - **patch** — A rich-text field no longer draws hint lines above its editor (objectui#12110, ruled B). The `markdown`, `html` and `richtext` editors used to show "Format: markdown" (or "Format:… (objectui `2498a0753`) + + Clause-②: no + + objectui range: `e5311165a8d6...088e0f705520` +- e040b8c: Console (objectui) refreshed to `0df67f237c5a`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 28 releasing of 29 changesets added across 21 non-merge commits; omitted: 1 release-nothing changeset, 1 commit carrying no changeset (they ship no package code). + + - **minor** — The Setup catalog for positions and permission sets reads the registry. It is built from the metadata-admin list and editors in their environment scope, not from new pages (object… (objectui `0df67f237`) + - **minor** — A bulk action's field-backed param now renders its real control on the selection bar, and the confirm step names the picked record (objectui#12105). (objectui `29b994902`) + - **minor** — A create form says when the server will fill a field: "Set automatically when saved." under a field whose `defaultValue` the server resolves at insert, while the field is empty (o… (objectui `b7cd5c891`) + - **minor** — A calendar view in a list draws every record of the days it shows, not the first 100 records of the object (objectui#12081 item 2). (objectui `16c8810ac`) + - **minor** — In-place edit on the record page and a list's in-cell edit honour the caller's field write grant, and the owner field honours the transfer grant (objectui#12103). (objectui `5f75cfad1`) + - **minor** — List views read an absent `userActions.editInline` as on, the v18 protocol default (objectui#12086, the consumer half of objectstack#22605). A list view is editable in place by de… (objectui `5330afd1f`) + - **minor** — Five write affordances that read no grant at all now read the affordance-to-grant map, so a caller without the grant is no longer offered a write the server then refuses (objectui… (objectui `5af42dbbd`) + - **minor** — After a marketplace install into the environment the console is rendering, the package page waits until that environment serves the installed package's app, and only then refreshe… (objectui `3c0f8069e`) + - **minor** — `element:repeater` and `element:number` publish the node-level `dataSource` binding as their only query input (objectui#12085). `@objectstack/spec` retires the element layer's fla… (objectui `ce991bd70`) + - **minor** — A create form asks its fields the create question, so a role that may create records but not edit them can fill and submit the form (objectui#12082). Every console affordance that… (objectui `023f00d46`) + - **minor** — **BREAKING** — The row type of the `VersionHistory` timeline is declared as `VersionHistoryEntry` instead of `VersionEntry` (objectui#6349, batch 10). `@object-ui/collaboration` publishes an unr… (objectui `12ff25631`) + - **minor** — The props of this package's `ThemeProvider` are declared and exported as `ThemePreferenceProviderProps` instead of `ThemeProviderProps` (objectui#6349, batch 10). `@object-ui/reac… (objectui `12ff25631`) + - **minor** — `ConnectionState` is a new export: the lifecycle state of one client connection, `'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'` (objectui#6349, batch 10)… (objectui `12ff25631`) + - **minor** — The props of this package's minimal `AppShell` container are declared and exported as `MinimalAppShellProps` instead of `AppShellProps` (objectui#6349, batch 9). `@object-ui/layou… (objectui `1b2d0160f`) + - **minor** — The props of this package's view switcher are declared and exported as `ListViewSwitcherProps` instead of `ViewSwitcherProps` (objectui#6349, batch 8). `@object-ui/plugin-view` pu… (objectui `d758f2f20`) + - **patch** — **The console no longer offers an object to a caller who cannot read it** (objectui#12109). A navigation entry bound to an object (`type: 'object'`, by its `objectName`) is not dr… (objectui `5a65f7d57`) + - **patch** — An object timeline with a `groupByField` draws one group per value, headed by the value's display name (objectui#12104). (objectui `206505c34`) + - **patch** — An org install from the Marketplace catalog's "Your organization" strip, on a cloud-managed environment, now waits until the environment serves the installed package's app before… (objectui `a21ff9a95`) + - **patch** — The console asks for recent activity only when the signed-in user may read it, and a failed read no longer shows as "No recent activity" (objectui#12081, item 8). (objectui `029bdaf45`) + - **patch** — An app that serves the signed-in user no navigation now opens on an app-level empty state, not on the Studio home (objectui#12079). (objectui `417e24567`) + - **patch** — The record chatter stores each reaction as the member's own `sys_comment_reaction` record, and stops writing the whole `sys_comment.reactions` column (objectui#12078; ruling A, am… (objectui `de302c731`) + - **patch** — fix(console): a long flow screen or action parameter list keeps its Submit / Confirm on screen (objectui#12080) (objectui `4d0ff2331`) + - **patch** — `ConnectionState` is re-exported from `@object-ui/types` instead of being declared a second time here (objectui#6349, batch 10). `@object-ui/data-objectstack` declared the same fi… (objectui `12ff25631`) + - **patch** — `ConnectionState` is re-exported from `@object-ui/types` instead of being declared a second time here (objectui#6349, batch 10). `@object-ui/collaboration` declared the same five… (objectui `12ff25631`) + - **patch** — Doc comments only. `UserDataAdapter` now carries the contract notes `@object-ui/app-shell` kept on its own copy of the interface: implementations must be safe to call concurrently… (objectui `1b2d0160f`) + - **patch** — The props of the console's page header are declared as `ConsolePageHeaderProps` instead of `PageHeaderComponentProps` (objectui#6349, batch 8). `@object-ui/layout` publishes `Page… (objectui `d758f2f20`) + - **patch** — Doc comment only. The `PageHeaderComponentProps` comment no longer says `@object-ui/app-shell` shares the name: the console's header props are now `ConsolePageHeaderProps`, so thi… (objectui `d758f2f20`) + - **patch** — The props of the dashboard's read-only record drill drawer are declared as `DashboardRecordDetailDrawerProps` instead of `RecordDetailDrawerProps` (objectui#6349, batch 8). `@obje… (objectui `d758f2f20`) + + ⚠️ 1 of these carries a breaking change: 1 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ fix(scripts): an injected tree hashes apart from an installed one, so a local Spec Main Shape Gate reproduction cannot leave verdicts a sibling worktree replays (objectui#12114) (… (objectui `a194b4e37`) + + Clause-②: yes (widening: the html-page save gate and `os validate` read the SDUI manifest this console ships, and at this pin `element:repeater` and `element:number` publish the node-level `dataSource` binding as their only query input. A page source with `` and no flat `object` was refused with `missing-required-prop` against the previous manifest and passes against this one; a flat `object` / `filter` / `sort` / `limit` still passes, now with a non-gating `unknown-prop` warning.) + + + + objectui range: `20c6d351ad74...0df67f237c5a` +- f451a59: Console (objectui) refreshed to `1fb1ced36243`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 10 releasing of 11 changesets added across 11 non-merge commits; omitted: 1 release-nothing changeset, 2 commits carrying no changeset (they ship no package code). + + - **minor** — The author of a comment on a record page can now edit and delete it from the record feed (objectui#12268). (objectui `7af0ab378`) + - **minor** — **BREAKING** — Both dashboard surfaces draw a widget's legacy `component` envelope the way it is authored: a `metric-card` node draws as before, and any other node draws the retired-format promp… (objectui `e8fc452c3`) + - **minor** — The console's assistant button no longer covers the record page's inline-edit Save or an attachment's Download and Delete, and deleting an attachment asks first (objectui#12248). (objectui `51c661e38`) + - **minor** — **BREAKING** — The exported field metadata types stop declaring the members `@objectstack/spec`'s `FieldSchema` refuses by name and no reader honours, and two type values become the spec's `Fiel… (objectui `10aba37d1`) + - **patch** — A record page's header totals now update when you edit or delete a row in one of its related lists (objectui#12244). Before, a quote's total in the header highlight strip kept its… (objectui `30c2e4e1d`) + - **patch** — fix(plugin-report): a report lays out a select dimension in the field's declared option order (objectui#12243) (objectui `e5c8ea7fe`) + - **patch** — Home's activity card names each row's object by its label in the reader's language (objectui#12238). (objectui `ee5c49171`) + - **patch** — A record activity feed shows a call, meeting or email logged on the record, instead of hiding it as a completed task (objectui#12228). On an object with no authored record page, a… (objectui `0cd322d78`) + - **patch** — A currency field's fixed currency, declared as `currencyConfig`, shows on the record quick-look panel and in related lists (objectui#12242). (objectui `10aba37d1`) + - **patch** — The record form stops reading a field's snake_case `visible_on`. A field's visibility is `visibleWhen`, the key `@objectstack/spec`'s `FieldSchema` declares (objectui#12242). (objectui `10aba37d1`) + + ⚠️ 2 of these carry a breaking change: 2 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 2 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)_ ci(test): the coverage lane runs eight shards, then both test matrices shard by measured duration (objectui#12208, objectui#12256) (#12263) (objectui `1fb1ced36`) + - _(no changeset)_ ci(type-check): the turbo cache replays unchanged packages, and pull-request and queue runs type-check only what the change affects (objectui#12222) (#12251) (objectui `1ce8db8d9`) + + Clause-②: no + + + + objectui range: `7578323a544e...1fb1ced36243` +- ba95607: Console (objectui) refreshed to `20c6d351ad74`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 33 releasing of 33 changesets added across 24 non-merge commits; omitted: 1 commit carrying no changeset (they ship no package code). + + - **minor** — The approval decision panel draws a short notice instead of nothing when it is placed anywhere other than an approval request's record page (objectui#12072). (objectui `20c6d351a`) + - **minor** — The per-group aggregation type that `useGroupedData` takes is declared and exported as `GroupAggregationConfig` instead of `AggregationConfig` (objectui#6349, batch 7). `@object-u… (objectui `c0c0a0d56`) + - **minor** — The view switcher's views are typed as `@object-ui/core`'s `ListViewVisualization`, and this package no longer re-exports that union as `ViewType` (objectui#6349, batch 7). `@obje… (objectui `c0c0a0d56`) + - **minor** — **BREAKING** — The console sidebar no longer lets a user drag the app's menu into a private order; the Pinned section is now the one place a user orders entries (objectui#12059). Arranging an ap… (objectui `8a55f0ccd`) + - **minor** — **BREAKING** — feat(types)!: `UnifiedViewConfig.chart` retires with a tombstone that names the spec's list chart block, and the three `object-chart` legacy-axis refusals stop calling those keys… (objectui `c7b30bd66`) + - **minor** — An `element:repeater` node may omit `properties.object` when its `dataSource.object` names the object. The zod arm `ElementRepeaterBlockSchema` now applies the same `object` waive… (objectui `2a48bd408`) + - **minor** — A pointer field draws the record it points at, the console reads an approval request's record page through the approvals routes, and the approval decision panel is built for the r… (objectui `a368ccb1a`) + - **minor** — **BREAKING** — feat(plugin-list)!: a chart list view binds only an ADR-0021 `dataset`; the legacy inline chart axes and their `name` / `value` floors are retired on every route (objectui#6152, r… (objectui `025341692`) + - **minor** — **BREAKING** — `createObjectStackUploadAdapter` no longer sends an upload's `path` as the storage scope (objectui#12055). (objectui `7282c6a51`) + - **minor** — **BREAKING** — The result type of `useConfirmDialog` is declared as `DesignerConfirmDialogState` instead of `ConfirmDialogState` (objectui#6349, batch 6). `@object-ui/app-shell` declares an unre… (objectui `1f1c4b526`) + - **minor** — **BREAKING** — feat(plugin-list)!: the renderers stop reading the list-view keys every door refuses: the pre-#2231 aliases, `calendar.defaultView`, and the undeclared keys a per-kind block carri… (objectui `3fd862510`) + - **minor** — **BREAKING** — `objectui validate` and `objectui check` judge a document through the strict authoring face (objectui#5250) (objectui `e4c0b5432`) + - **minor** — The runtime chat contracts have their own names, apart from `@object-ui/types`' authoring `ChatMessage` / `ChatToolInvocation` (objectui#6349, batch 5). The shape ` + + objectui range: `47b1f0bb7174...20c6d351ad74` +- ea9fd99: Console (objectui) refreshed to `4997995022f8`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 5 releasing of 5 changesets added across 7 non-merge commits; omitted: 2 commits carrying no changeset (they ship no package code). + + - **minor** — A Studio form field that declares the `ref-multi:permission` widget now renders a picker of the declared permission sets instead of the JSON editor fallback (objectui#12126). (objectui `499799502`) + - **minor** — The Marketplace's install answers now say what the runtime served (objectui#12098). (objectui `107139379`) + - **minor** — A paged grid's column footer reads the whole matched set, or says it reads the page (objectui#12081 item 3). (objectui `69bca0af6`) + - **patch** — A boolean field whose `defaultValue` is a CEL expression or a runtime token now gets that default on create (objectui#12121). (objectui `c9fc10b1e`) + - **patch** — More write affordances read the affordance-to-grant map, so a caller without the grant is no longer offered a write the server then refuses (objectui#12082, leg A of the write-cen… (objectui `752504520`) + + **In this console build, declared nowhere** — objectui merged 2 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: stop teaching keys the strict authoring face refuses (objectui#12131, phase 1) (#12134) (objectui `66eb5604e`) + - _(no changeset)_ fix(examples): repair the class (i) keys the strict authoring face refuses (objectui#5250 slice C) (#12128) (objectui `802f0bc41`) + + objectui range: `0df67f237c5a...4997995022f8` +- 1e44436: Console (objectui) refreshed to `7578323a544e`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 29 releasing of 31 changesets added across 25 non-merge commits; omitted: 2 release-nothing changesets, 2 commits carrying no changeset (they ship no package code). + + - **minor** — **BREAKING** — A page route's breadcrumb no longer carries a 「Pages」 crumb (objectui#12198, executing the maintainer's ruling on objectstack-ai/cloud#2768). **BREAKING** for `@object-ui/i18n`: t… (objectui `7578323a5`) + - **minor** — A flow action whose run pauses for input now closes with the action's own `successMessage` (objectui#12234). (objectui `360cf3215`) + - **minor** — **BREAKING** — A dashboard widget's legacy `component` envelope takes a `metric-card` node only, and refuses every other node by name with the fix, on both zod faces and on the TypeScript type (… (objectui `6fb281d64`) + - **minor** — `BaseFieldMetadata` declares a field's help text as `inlineHelpText`, typed by reference to `@objectstack/spec`'s `FieldSchema.inlineHelpText`, and no longer declares `help` (obje… (objectui `923504f4a`) + - **minor** — A record whose `approval_status` reads `pending` no longer opens locked when the approvals read answered and found no request (objectui#12220). (objectui `5c2982adf`) + - **minor** — **BREAKING** — A free-plan organization's environment list no longer offers a primary button that opens a paywall dialog (objectui#12190). The maintainer's rule is that no primary button opens a… (objectui `fa778e67f`) + - **minor** — A `type: 'url'` action or an `element:button` whose target is `mailto:` now opens the mail client, where it used to fail at click (objectui#12191, the objectui half of ruling A+;… (objectui `334f0cff9`) + - **minor** — Console: the app's identity moves into the top bar (objectui#12201, the maintainer's ruling of 2026-10-12). (objectui `38bb37333`) + - **minor** — The `lookup` widget reads `@objectstack/spec`'s `allowCreate`, `lookupColumns` and `lookupPageSize` alone. Their snake_case twins are retired, with no alias (objectui#12157). (objectui `466a219eb`) + - **minor** — `LookupFieldMetadata` declares the lookup picker's `allowCreate`, `lookupColumns` and `lookupPageSize` in `@objectstack/spec`'s spelling, each typed by reference to the spec's `Fi… (objectui `466a219eb`) + - **minor** — Every built-in locale pack gains one key, `form.maxFileSize` (objectui#12156). It is the size-limit hint the flat `ObjectForm` appends to the description of a `file` or `image` fi… (objectui `b2316074a`) + - **minor** — **BREAKING** — A `file` or `image` field's size limit is now `maxSize`, the one spelling the widgets read and the spec accepts (objectui#12156). (objectui `b2316074a`) + - **minor** — **BREAKING** — The console boot screen says one honest line, and shows no English before the user's locale (objectui#12187, the first of its three fixes). (objectui `54159d329`) + - **patch** — The console builds again on a case-insensitive filesystem, which is macOS's default (objectui#12184). Two modules in the console's settings pages, the workspace-timezone prompt di… (objectui `08b5d97d1`) + - **patch** — A field-backed action param's help line reads the field's `inlineHelpText`, in the order the record form reads it (objectui#12213). (objectui `923504f4a`) + - **patch** — The record form draws a field's `inlineHelpText` under its control (objectui#12213). (objectui `923504f4a`) + - **patch** — Lookup links on a record page and on a custom page now carry the console's base path, so the same link works from a left click, a new tab, a middle-click and a copied link (object… (objectui `97e7d4edd`) + - **patch** — A console page load reads `sys_user_preference` once, not once per preference key (objectui#12187). (objectui `d48071e3d`) + - **patch** — The bell's Activity tab names each row's object by its label in the reader's language (objectui#12212). (objectui `fc1bc6664`) + - **patch** — An empty gallery draws the same empty state as an empty grid, translated, instead of "No items to display" in English (objectui#12217). (objectui `f5b66bf97`) + - **patch** — The lifecycle badge (`ManagedByBadge`) draws only for a caller who can author metadata (objectui#12194). An end user sees it nowhere: not on the record page, the object list heade… (objectui `f38a42827`) + - **patch** — A console page route stops loading the Studio's designers, the interface list and one file per icon before its content (objectui#12187). (objectui `6eca5b5a7`) + - **patch** — The `position-ref` grant picker no longer offers the `everyone` and `guest` audience anchors (objectui#12200). Every authenticated or anonymous principal holds an anchor implicitl… (objectui `a491b64a6`) + - **patch** — A record's Edit form saves the owner the user changes or clears (objectui#12205). The form dropped `owner_id` from every save, so a reassignment or a clear made in the Edit dialog… (objectui `6c437b07e`) + - **patch** — Two producers hand the `lookup` widget `@objectstack/spec`'s spellings, moving with the widget's own retirement of the snake_case reads (objectui#12157): (objectui `466a219eb`) + - **patch** — The detail view's inline-edit field enrichment no longer copies `allow_create`, `lookup_columns` or `lookup_page_size` off the object schema. The lookup picker reads `allowCreate`… (objectui `466a219eb`) + - **patch** — The internal ledger behind `ObjectGrid`'s relational copy set drops its `allow_create`, `lookup_columns` and `lookup_page_size` rows, because `LookupField` no longer reads those s… (objectui `466a219eb`) + - **patch** — An inline edit of a date-only field on a record page saves the day the user picked, in every timezone (objectui#12206). (objectui `803f678b3`) + - **patch** — The flat `ObjectForm`'s file-size hint now reads the spec's `maxSize`, and it is shown in the session language (objectui#12156). (objectui `b2316074a`) + + ⚠️ 5 of these carry a breaking change: 5 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 2 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)_ ci: Spec Main Shape Gate leaves the required set — advisory on PRs, hourly on main; the required-check pin declares the seven ruled names (objectui#12240) (#12250) (objectui `5039e8117`) + - _(no changeset)_ ci: two-speed CI — affected tests on PR and queue runs, full suite and coverage on main's push and hourly schedule, spec gate reuse by objectstack sha, fifteen gate workflows fold… (objectui `21fdeb34d`) + + Clause-②: no + + + + objectui range: `9646e2ed2bf3...7578323a544e` +- 2acff87: Console (objectui) refreshed to `9646e2ed2bf3`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 13 releasing of 14 changesets added across 9 non-merge commits; omitted: 1 release-nothing changeset, 1 commit carrying no changeset (they ship no package code). + + - **minor** — The FilterBuilder's row and group types are exported as `FilterBuilderRow` and `FilterBuilderRowGroup` instead of `FilterBuilderCondition` and `FilterGroup` (objectui#6349, batch… (objectui `26b0bd2d1`) + - **minor** — `FilterBuilderOperator` is no longer exported from this package (objectui#6349, batch 12). It was `@objectstack/spec`'s `ViewFilterOperator`, the protocol's canonical view-filter… (objectui `26b0bd2d1`) + - **minor** — The result type of this package's `toDomProps` is declared and exported as `FieldDomProps` instead of `DomProps` (objectui#6349, batch 11). `@object-ui/core` publishes a different… (objectui `35a2b59a5`) + - **minor** — The node type of the tree this package's parser produces is declared and exported as `ParsedSchemaNode` instead of `SchemaNode` (objectui#6349, batch 11). `@object-ui/types` publi… (objectui `35a2b59a5`) + - **patch** — The Studio fallback form for an email template no longer offers `category` or `isSystem`, and the fallback skill form no longer offers `triggerPhrases` (objectstack-ai/objectstack… (objectui `9646e2ed2`) + - **patch** — A gallery card whose title field is a lookup now shows the referenced record's name, not `[object Object]` (objectui#12188). (objectui `5dd583f5e`) + - **patch** — The list view's URL filter state reads the FilterBuilder's group by `@object-ui/components`' new name for it, `FilterBuilderRowGroup` (it was exported as `FilterGroup`; objectui#6… (objectui `26b0bd2d1`) + - **patch** — `ListViewProps.onFilterChange` and `ListViewProps.initialFilters` now name the advanced-filter group by `@object-ui/components`' new name for it, `FilterBuilderRowGroup` (it was e… (objectui `26b0bd2d1`) + - **patch** — `toFilterGroup` now declares its return type by `@object-ui/components`' new name for the FilterBuilder's group, `FilterBuilderRowGroup` (it was exported as `FilterGroup`; objectu… (objectui `26b0bd2d1`) + - **patch** — Under a tenancy wall (`isolated` or `group`), the security catalog's pages are read-only for everyone but the platform operator, and say why (objectui#12183). (objectui `d928c8394`) + - **patch** — `DataSource` inside this package is now a re-export of `@object-ui/types`' `DataSource`, the one adapter contract, instead of an older and looser second declaration of it (objectu… (objectui `35a2b59a5`) + - **patch** — On a metadata type the `OS_METADATA_WRITABLE` hatch elevates, an item a package ships now opens read-only in the metadata editor and in the permission matrix, and an item no packa… (objectui `5b20aed39`) + - **patch** — Studio's landing page and the permission matrix name the overlay that exists: an environment overlay, not an org overlay (objectui#12171). (objectui `b0c5f9ddf`) + + **In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears 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)_ ci(site): deploy the docs from a GitHub Actions workflow every 6 hours, and turn Vercel's Git-triggered builds off (objectui#12178) (#12182) (objectui `7e30d0b6e`) + + Clause-②: no + + objectui range: `088e0f705520...9646e2ed2bf3` +- 299a2c6: Console (objectui) refreshed to `a58626c88dc8`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 36 releasing of 36 changesets added across 36 non-merge commits. + + - **minor** — The timeline's unused "Overdue" bucket label is removed: `timeline.bucket.overdue` is gone from all ten language packs and from the timeline's built-in English defaults (objectui#… (objectui `c910630cf`) + - **minor** — **BREAKING** — Inside a dashboard widget's legacy `component` envelope, a `metric-card` is now judged by the slot's component arm alone, so every key that arm refuses is refused there too, with… (objectui `89cc738da`) + - **minor** — **BREAKING** — "Recently Accessed" shows each item's own label, in the current language, and a visit that does not change the list no longer writes the `ui.recent` preference (objectui#11678). (objectui `48c82c9d5`) + - **minor** — The calendar and the timeline start the week on the first day of the week of the user's locale (objectui#11675), instead of a fixed Sunday (calendar) and a fixed Monday (timeline)… (objectui `9ca3cacbe`) + - **minor** — The console's settings pages resolve icons through the shared `getLazyIcon` helper and no longer log `[lucide-react]: Name in Lucide DynamicIcon not found` (objectui#11679). (objectui `abd374bb2`) + - **minor** — `DefaultLoginPage` and `DefaultRegisterPage` offer a generic sign-up only where the server would accept one (objectui#11705). Under the default `invite_only` audience posture the… (objectui `7e2d5b0ee`) + - **minor** — Objects with a picklist-bound field save again from the OWD overview, the Setup fields and objects pages, the metadata-admin embedded-item editor, and `MetadataService.saveFields`… (objectui `da3545375`) + - **minor** — The keyboard-shortcuts dialog (`?`) lists only shortcuts that do something (objectui#11674). (objectui `de96f3d0c`) + - **minor** — **BREAKING** — A `metric-card` placed in a dashboard's widget slot refuses `label` by name and points at `title`, the key its heading is drawn from (objectui#4425). (objectui `eb4552e71`) + - **minor** — The console's login and register pages offer a generic sign-up only where the server would accept one (objectui#11691). `/api/v1/auth/config` states the sign-up rule as two keys,… (objectui `daa7caff4`) + - **minor** — An object-bound timeline no longer heads every past date "Overdue": a day before today goes under a neutral "Earlier" bucket, translated in every language (objectui#11676). (objectui `ce464d958`) + - **minor** — Studio reads shared picklists: a select field can use one, an object with a picklist-bound field saves again, and the picklist page is read-only (objectui#10202, the objectui half… (objectui `c3623eb11`) + - **minor** — The AI chat's tool-approval card and its "Open in Builder" handoff card are translated in every language (objectui#11667). (objectui `57d82cb34`) + - **minor** — The chat launchers show a marker while a proposed plan awaits the user's approval (objectui#11666, item 6 of objectui#2458). A user who closed the chat with a blueprint still wait… (objectui `848ba0e12`) + - **minor** — Five fixes on the AI build surface, from the 2026-10-05 cloud acceptance run (objectui#11658). (objectui `fc3c2cc1c`) + - **minor** — `action:group` and `action:menu` no longer read a member's `properties.params` (objectui#11638). A container member's `properties.params` no longer reaches the action runner. (objectui `73b5d7764`) + - **minor** — `LoginForm` shows its "Don't have an account? Sign up" row only when the caller passes `registerUrl` (objectui#11634). The prop used to default to `'/register'`, so a caller that… (objectui `f1a177c41`) + - **minor** — A field group's `visibleWhen` now gates its section on the record detail page, the same way it gates the section on the entry form (objectui#11630). Take an object whose `fieldGro… (objectui `76993f8ef`) + - **patch** — fix(plugin-tree): tree cells draw the same field faces as list cells, so a boolean no longer shows as `true` (objectui#11686) (objectui `a58626c88`) + - **patch** — **The record header's highlight chips share the row's free width and truncate only when it runs out (objectui#11684).** A Product with SKU "QA Widget 1" read "QA Wid…" in the reco… (objectui `7300fcafe`) + - **patch** — An authored `view:calendar` or `view:timeline` node loads its plugin and renders the calendar or the timeline, and a console boot no longer logs the registry's race warning for th… (objectui `e6dcd85cc`) + - **patch** — The approvals inbox's request drawer shows its record summary the way the record page shows the record (objectui#11677). (objectui `3409fe89e`) + - **patch** — The flow designer saves a screen field's `Min` and `Max` as numbers (objectui#11664). (objectui `5ba255538`) + - **patch** — The metric cards no longer write authored keys they do not read onto the page (objectui#4425). Rendered through `SchemaRenderer`, which is how every dashboard draws its KPI tiles… (objectui `a600924f2`) + - **patch** — Console, record page and Studio copy from the 2026-10-05 cloud acceptance run (objectui#11659). (objectui `f9f4a62d5`) + - **patch** — The flow designer's SLA escalation switch on an approval node no longer writes a refused `escalation: { enabled: false }` block, and switching it off keeps the values the author e… (objectui `9f3ed7bca`) + - **patch** — Cancelling a background import shows the rows the server already committed, with Undo (objectui#11650). `ImportWizard`'s Cancel used to show "Import cancelled · 0 imported" withou… (objectui `846f98251`) + - **patch** — Drag-to-reorder works on a grouped sidebar menu, within each level (objectui#11626). `NavigationRenderer` had a sortable path only in its group-free arm. Every stock app's menu is… (objectui `b88937b65`) + - **patch** — The `object-kanban` board totals the view's `summarizeField` in each column header (objectui#11629). (objectui `f4370f426`) + - **patch** — On an object that declares no list view, a grid toolbar change no longer sends a save the door refuses (objectui#11643). Such an object opens on the "All Records" tab the console… (objectui `43671468d`) + - **patch** — The console's `/verify-email?token=…` page verifies the address again (objectui#11633). It used to send the token as `POST /api/v1/auth/verify-email` with a JSON body. better-auth… (objectui `8057a8b14`) + - **patch** — On a runtime with no marketplace that still mounts install-local (an offline boot, `OS_CLOUD_URL=off`), a local install's Details page now offers its local menu: re-seed sample da… (objectui `0baf86f1b`) + - **patch** — A dataset-bound dashboard widget names its comparison window from `compareTo.kind` (objectui#11632). `previousPeriod` reads "vs previous period" and `previousYear` reads "vs last… (objectui `e398a54f0`) + - **patch** — Two grid toolbar changes to one view in one session now both survive a reload (objectui#11642). Before, the second change overwrote the first: changing density and then sorting by… (objectui `531b26c8f`) + - **patch** — A lookup whose `dependsOn` names a parent field drops its selection when that parent changes or is cleared (objectui#11631). (objectui `c00039842`) + - **patch** — A grid toolbar change on a served view is stored where the save door keeps it, so it survives a reload (objectui#11625). This covers density, a column-header sort and the hide-fie… (objectui `59917c4b2`) + + ⚠️ 3 of these carry a breaking change: 3 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + + + objectui range: `0abd4f9f8769...a58626c88dc8` +- 325a92e: Console (objectui) refreshed to `b9814b334d8a`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 31 releasing of 35 changesets added across 36 non-merge commits; omitted: 4 release-nothing changesets, 4 commits carrying no changeset (they ship no package code). + + - **minor** — Studio's first draft save after opening an item no longer replaces a draft that was saved elsewhere in the meantime (objectui#11773). (objectui `b9814b334`) + - **minor** — An empty list draws its declared link, and a list page's own empty state wins over its source view's (objectui#12270). (objectui `b434f5f47`) + - **minor** — A `record:details` section heading can show how many values a field holds (objectui#12192). (objectui `c08c9332a`) + - **minor** — The console and the runner bundles are built against `@objectstack/*` 18.0.0-next.0 (objectui#12030). In both, the `@objectstack/*` ranges are `devDependencies`, but each package… (objectui `c5406c201`) + - **minor** — The ten locale packs follow `@objectstack/spec` 18's platform capability set (objectui#12030): the key `capability.label.manage_org_presentation` is replaced by `capability.label.… (objectui `c5406c201`) + - **minor** — **BREAKING** — Depend on the `@objectstack/*` 18 prerelease line (objectui#12030). (objectui `c5406c201`) + - **minor** — A report the viewer may not read shows the no-access state, not the server's refusal (objectui#12330). (objectui `2b06a91e6`) + - **minor** — A flow action launched from a list toolbar now resolves the row selection by the same rule as a script action: `@object-ui/core`'s `resolveServerActionRecordId` (objectui#12331).… (objectui `2722d33c5`) + - **minor** — Console writes that landed in silence now say so, and controls disabled on a read-only package say why to every user, not only to a mouse (objectui#12303). (objectui `b64fda3f6`) + - **minor** — A record page draws one provenance footer for its record, whatever the number of `record:details` nodes it carries (objectui#12307). (objectui `28ebfb5f8`) + - **minor** — The marketplace package page offers the install modes a package permits, managed or an unmanaged copy, and reads what the install door answers (objectui#12288, the objectui stage… (objectui `cea429b98`) + - **minor** — Calendar quick-create no longer offers a dialog that cannot succeed. On an object with a required field it cannot fill, an empty-day click opens the full create form with the clic… (objectui `ee470719e`) + - **minor** — A grid's inline-edit save no longer overwrites a concurrent write without a word (objectui#12292). The grid's default row save and "Save All" now send each row's `updated_at` as `… (objectui `3092aca7a`) + - **minor** — The OAuth consent page names the requesting application by its registered name again, and never shows the raw `client_id` in the name's place (objectui#12301). (objectui `ca45c6d55`) + - **minor** — The strict authoring face accepts `dataSource` on `record:related_list` (objectui#12302). The block's renderer has honoured the spec's per-element binding since objectstack#6953:… (objectui `093d55bb6`) + - **minor** — `record:details`, `record:highlights` and `record:quick_actions` honour the per-element `dataSource` binding the spec already declares on every page component (objectui#12294). A… (objectui `82e5f5348`) + - **minor** — `AutoNumberFieldMetadata` declares `autonumberFormat`, typed by reference to `@objectstack/spec`'s `FieldSchema.autonumberFormat`, and keeps `format` as its shorthand (objectui#12… (objectui `fd4872f9e`) + - **minor** — **BREAKING** — `buildValidationRules` stops compiling a field-level `pattern` / `pattern_message` and `validate` (objectui#12242, maintainer ruling A). `@objectstack/spec`'s `FieldSchema` refuse… (objectui `5d3f437ab`) + - **minor** — **BREAKING** — The field metadata types stop declaring the keys a host writes at runtime and the field-level regex keys, and `geolocation` leaves the `FieldMetadata` union (objectui#12242, secon… (objectui `5d3f437ab`) + - **minor** — `@object-ui/fields` exports `fieldHelpText(field)`, the one read of the help line a field draws under its control (objectui#12213). (objectui `5b45c0d50`) + - **minor** — **BREAKING** — A dashboard or report route's breadcrumb no longer carries a 「Dashboards」 or 「Reports」 crumb (objectui#12273, applying the maintainer's ruling on objectstack-ai/cloud#2768, as obj… (objectui `9f282ef53`) + - **patch** — `data-table`: an inline edit made while a row save or Save All is in flight is no longer dropped when the save settles (objectui#12322). (objectui `72594f29e`) + - **patch** — A failed first `connect()` with `autoReconnect` now retries discovery and settles (objectui#12318). The retry used to call `connect()` from inside the attempt it was retrying. `co… (objectui `866763bda`) + - **patch** — Metadata admin detail pages no longer overprint their header on a narrow window (objectui#12306). (objectui `67ca3af91`) + - **patch** — An inline edit in a grid is no longer written to another record when the rows are re-read in a new order (objectui#12310). The table kept each pending edit under its row's positio… (objectui `a8051d654`) + - **patch** — Each app tile in Home's "Your apps" strip is now a link to the app, so an app can be opened in a new tab and its address can be copied (objectui#12305). (objectui `0ffa07605`) + - **patch** — The connection state follows the requests the console already makes, so the header chip now shows a mid-session outage (objectui#12295). (objectui `40cb13b2e`) + - **patch** — The console's Setup › Apps page reports what the server did with a write (objectui#12300). Before, a Delete and a Disable could report success for a write that changed nothing. (objectui `068a5407f`) + - **patch** — Saving a master-detail edit form no longer rewrites the position of lines the user did not touch (objectui#12286). (objectui `f0532d8a8`) + - **patch** — A dashboard header action of type `url`, or of no declared type, now goes through the action runner, like every other header action (objectui#12275). (objectui `74964ad68`) + - **patch** — A flow action whose flow runs straight through, with no screen, now closes with the flow's own `successMessage` when the action declares none (objectui#12274). (objectui `bfca2a2ce`) + + ⚠️ 4 of these carry a breaking change: 4 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 4 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(ci): retire the four-shard leftovers in AGENTS.md section 9 and two comment sites (#12287) (objectui `157017967`) + - _(no changeset)_ docs(fields): the auto-number page teaches the spec's format tokens, with no quarter token and no hand-written renderer (objectui#12258) (#12280) (objectui `5595aabca`) + - _(no changeset)_ ci(spec-main-shape-gate): drop the merge_group trigger now the context is out of the required set (objectui#12252) (#12262) (objectui `a1a917a08`) + - _(no changeset)_ ci(type-check): the affected speed builds the packages the repo-root setup program imports (objectui#12272) (#12277) (objectui `48fb558d3`) + + Clause-②: no + + + + objectui range: `1fb1ced36243...b9814b334d8a` +- a69a40a: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + Console (objectui) refreshed to `daeb77fc84dd`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 4 releasing of 4 changesets added across 5 non-merge commits; omitted: 1 commit carrying no changeset (they ship no package code). + + - **minor** — Studio, Interfaces pillar: an html or react page reads like a code page. The source editor's plain-text fallback numbers its lines and wraps long ones as the code editor does, and… (objectui `830eb1f2d`) + - **patch** — A new record page seeded in Studio is saved with the `default` layout template instead of `full-width`, and the page node's `template` member is typed by reference to the spec's (… (objectui `daeb77fc8`) + - **patch** — Two Studio pickers are named by the labels shown beside them (objectui#12510). Nothing changes on screen. (objectui `3e09c1e8d`) + - **patch** — Studio's empty surfaces now carry the action that fills them (objectui#12514). (objectui `f23eb01cb`) + + **In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears 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(studio-redesign): move the 61 PR screenshots under after/ to an evidence branch and remove them from main (#12583) (objectui `7066dae53`) + + objectui range: `f96f771140f1...daeb77fc84dd` +- e23eccb: Console (objectui) refreshed to `e5311165a8d6`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 21 releasing of 23 changesets added across 24 non-merge commits; omitted: 2 release-nothing changesets, 1 commit carrying no changeset (they ship no package code). + + - **minor** — **BREAKING** — Studio and the metadata admin read a metadata type's overlay flag under its new name, `allowEnvironmentOverlay`, and the read-only banners name `OS_METADATA_WRITABLE` (objectui#12… (objectui `e5311165a`) + - **minor** — The marketplace package page's **Update** button opens an update dialog, and a package README's raw HTML is no longer printed as text (objectui#12146). (objectui `82bb1d584`) + - **minor** — **BREAKING** — A form field declares `step` and `className` on both faces, so the strict authoring face stops telling authors to remove two keys that work. A built-in form branch now joins an en… (objectui `d2500d8be`) + - **minor** — "Clone to customize" on a packaged permission set calls the server's clone door (objectui#7611, objectstack ADR-0126 §7.1). (objectui `6d5879fe9`) + - **minor** — **A dashboard or a list view the server refuses you, opened by its URL, now says it was refused** (objectui#12112, the console half of objectstack#22611's ruling A). (objectui `43e150518`) + - **minor** — Setup's "Assigned users" on a permission set writes a new grant by the set's name alone (objectui#7611, after objectstack#22863, ADR-0131 stage 6b-2-pre). (objectui `faf4ce952`) + - **minor** — **BREAKING** — A form field declares `avatarField`, the record field each candidate of the search picker draws its avatar from, on both faces, so the strict authoring face stops telling authors… (objectui `c0c2f792b`) + - **minor** — A `position` decision output lists the position registry and commits position names (objectui#7611, objectstack ADR-0131 D3/D4). (objectui `8cc7158b4`) + - **minor** — The capability select lists the capability registry, and the permission-set editor's provenance badge reads the layered envelope (objectui#7611, objectstack ADR-0131 D3/D7). (objectui `53ee2f854`) + - **minor** — **BREAKING** — A form field declares the rest of the field metadata its widget reads off the `fields[]` entry, on both faces, so the strict authoring face stops telling authors to remove keys th… (objectui `0665c3be7`) + - **minor** — Screen readers hear the names of more controls in the viewer's language (objectui#12145). (objectui `e723f58ee`) + - **minor** — The suggested-bindings panel shows each suggestion's real state and can revoke an acceptance (objectui#7611, after objectstack#22854). (objectui `f962cdc09`) + - **minor** — The record feed and a related list's Add existing and fallback remove read the affordance-to-grant map, so a caller without the grant is no longer offered a write the server then… (objectui `158c75d90`) + - **minor** — A paged table's first, previous, next and last page buttons have accessible names (objectui#12124). (objectui `dc6d28e86`) + - **patch** — The Studio app's root now opens the app's first navigation entry, like every other app's root, and `StudioHomePage`, Studio's old metadata overview, is retired (objectui#12113). (objectui `ed12d48ec`) + - **patch** — A form field's own `defaultValue` is no longer drawn in its control, so the form never shows a value it does not save (objectui#12155). (objectui `f3d540814`) + - **patch** — A grouped grid's own first, previous, next and last page buttons have accessible names (objectui#12143). (objectui `2381c07f3`) + - **patch** — Console rendering fixes from the HotCRM browser pass (objectui#12110): (objectui `5330038f4`) + - **patch** — The record page's discussion shows stored replies under their comment, and every comment offers the reply input where the caller may post one (objectui#12144). (objectui `a7d492011`) + - **patch** — A record activity feed shows an object's `activityMilestones` rows of type `completed`, such as "Case resolved", instead of hiding them as completed tasks (objectui#12106). (objectui `1d1cfe0c1`) + - **patch** — A record-source refusal (`RECORD_SOURCE_REQUIRED`, raised by every arm that requires a record source) now reports the same path every time (objectui#5250). (objectui `9b7550999`) + + ⚠️ 4 of these carry a breaking change: 4 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 1 commit in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared it, so it appears 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)_ feat(scripts): the JSON-fence gate reads the published READMEs (objectui#12131, phase 2) (#12139) (objectui `ce9811c96`) + + + + objectui range: `4997995022f8...e5311165a8d6` +- bf492c8: Console (objectui) refreshed to `f0268ad78485`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 173 releasing of 175 changesets added across 170 non-merge commits; omitted: 2 release-nothing changesets, 4 commits carrying no changeset (they ship no package code). + + - **minor** — The chat build panel reads cloud's post-apply verification loop, and an unknown build phase no longer reads as "Building" (objectui#11988, the receiver objectstack-ai/cloud#2172's… (objectui `6d5eb3430`) + - **minor** — **BREAKING** — feat(types)!: a list view's legacy `options` bag is the `@objectstack/spec` list overlay's bag by reference, `ListViewTimelineConfig` is the list view's own `timeline` block, and… (objectui `5d77c099b`) + - **minor** — The built-in metadata designers leave the console's first load: `@object-ui/app-shell` registers them from a chunk it loads on its own, once the package entry has run (objectui#11… (objectui `4babf4035`) + - **minor** — The Studio landing (`/studio`) gets the console's own header (objectui#11863). Its old header held one control, the product wordmark linking back to Home. It now mounts `AppHeader… (objectui `7ebff3991`) + - **minor** — **BREAKING** — feat(types)!: the list view's `kanban`, `calendar`, `gallery` and `timeline` blocks are the `@objectstack/spec` 17.7.0 `ListViewSchema` slots by reference, and their `.passthrough… (objectui `cf62edf68`) + - **minor** — `element:repeater` reads the node-level `dataSource` binding first (objectui#11880, the repeater half of the objectui step of objectstack#11509, ruled A-narrow). (objectui `aaba8655a`) + - **minor** — The Studio landing (`/studio`) lists the packages the author was last in, above its package cards (objectui#11863). A visit to a package's pillar builder (`/studio/PKG/TAB`) is re… (objectui `054fd8441`) + - **minor** — The Import Wizard's preview checks a `time` column, and the user import's email column, the way the server's import judges them, so the import button no longer counts rows the ser… (objectui `fbad07885`) + - **minor** — **BREAKING** — feat(types)!: `ObjectGridSchema.defaultFilters` and the flat `ObjectGanttSchema` / `ObjectMapSchema` `filter` follow their `@objectstack/spec` rows (objectui#6152, round 10) (objectui `b13ea3c67`) + - **minor** — A console object list's toolbar grouping is now in the URL, beside its Filter panel conditions, search term and sort, so a grouped list can be shared as a link or bookmarked (obje… (objectui `75bacf93b`) + - **minor** — **BREAKING** — A form field declares `scale` and `currencyConfig` on both faces, by reference to `@objectstack/spec`'s `FieldSchema`, so the strict authoring face accepts the decimal places and… (objectui `18d7b489c`) + - **minor** — The list's Sort picker lists a field it keeps only for the current sort as removable, never as a new choice (objectui#11943). (objectui `4f4fc03f7`) + - **minor** — **BREAKING** — feat(types)!: the `object-calendar` element's `calendar` container is the `@objectstack/spec` 17.7.0 slot by reference, and its `.passthrough()` is gone (objectui#6152, round 9) (objectui `1473757bf`) + - **minor** — **BREAKING** — feat(types)!: `object-grid` `operations` and the `object-grid` / `object-kanban` / `object-calendar` `filter` follow the `@objectstack/spec` 17.7.0 rows (objectui#6152, round 8) (objectui `d7e9e9ab6`) + - **minor** — The metadata designer registries are observable, so a designer registered after a surface rendered now appears in it (objectui#11939, step 1 of objectui#6795). (objectui `a835c3149`) + - **minor** — **BREAKING** — The `sidebar` node declares `side` on both faces, so the strict authoring face accepts the edge the sidebar is drawn against (objectui#11070). (objectui `172acc313`) + - **minor** — New SDUI widget `cloud:workspace-timezone-notice`: one line on the Cloud welcome page naming the timezone a workspace was seeded with at creation (objectui#11930, the objectui hal… (objectui `45d585363`) + - **minor** — A new workspace now takes its creator's browser timezone when it is created, so its first administrator is no longer asked for a zone the browser already knows (objectui#11908). (objectui `dfa15c860`) + - **minor** — **BREAKING** — The metric sub-caption is retired on the reader side (objectui#11389, ruling C, the objectui half after `@objectstack/spec` 17.7.0). **BREAKING** for any dashboard that drew a cap… (objectui `d92b2a15d`) + - **minor** — `action:button` delivers `undoable` where a record is in scope, and publishes it (objectui#11168, ruling B on objectui#11754). (objectui `f0496bdf1`) + - **minor** — **BREAKING** — feat(types)!: retire `object-grid`'s `resizableColumns` on both faces, and stop `ObjectGrid` reading it (objectui#6152, round 7) (objectui `d73d98770`) + - **minor** — A record action greyed out by its declared `disabled` predicate now says why (objectui#11811). It shows the reason "Not available for this record", and the same text is its access… (objectui `455c64665`) + - **minor** — The list filter builder starts on the view's first column, hides hidden fields, and offers one empty check where "empty" and "null" mean the same records (objectui#11810). (objectui `1e1f09e99`) + - **minor** — Dates and times render in the time zone the server answers for the signed-in workspace (objectui#11693). (objectui `d50f7241b`) + - **minor** — The console asks an administrator once to set the workspace timezone while it is still the platform default, pre-filled with the browser's zone (objectui#11758, the gate the objec… (objectui `3bf8894ef`) + - **minor** — Installed Apps now reads a package the runtime refused to load at startup as "Not loaded", says why, and keeps its Uninstall (objectui#11645). (objectui `7b1770590`) + - **minor** — `DashboardWidgetSchema` chains `@objectstack/spec`'s `checkDashboardWidgetChartMeasureArity` after the metric-family check (objectui#11334, objectui#11417), so `safeValidateSchema… (objectui `c0862c1cc`) + - **minor** — `element:text` drops the two pre-convergence `variant` spellings `heading` and `subheading`, which `@objectstack/spec` 17.7.0 retires (objectstack#21015, objectui#11717). The regi… (objectui `c0862c1cc`) + - **minor** — The `page` node and the spec page-kind nodes (`record` / `home` / `utility`) chain `@objectstack/spec`'s `checkPageRequiresKind` (objectui#11717), as they already chain `checkPage… (objectui `c0862c1cc`) + - **minor** — Screen readers can name and reach the controls of the view tab bar, the settings form, the sidebar menus, the table's selection column and the percent cell (objectui#11690). axe-c… (objectui `9fc68aadb`) + - **minor** — A record delete confirmation now names what it deletes and confirms with a destructive "Delete" button (objectui#11695). (objectui `17acfbb9b`) + - **minor** — The organization Settings page no longer offers a slug edit the framework will refuse (objectui#11720). From framework 17.7.0, better-auth's organization update refuses a new slug… (objectui `ded4494b6`) + - **minor** — Object list pages are titled with the object's plural label (objectui#11696). (objectui `6be0f7a46`) + - **minor** — Read-only boolean values, the boolean list face and the Home greeting's punctuation follow the language (objectui#11689). (objectui `22b503c30`) + - **minor** — An empty list's copy no longer contradicts the page it sits on (objectui#11687). (objectui `834c5594b`) + - **patch** — Studio's "New automation" dialog opens on common starting points in plain words, with the trigger form under "Advanced" (objectui#11861: the automations entry, the last of the thr… (objectui `f0268ad78`) + - **patch** — A permission set's advanced facets pick with the shared `Select`, the control the rest of Studio picks with (objectui#11865, the facets' part of that card): a Row-Level Security p… (objectui `1ad0c0824`) + - **patch** — A quick-filter selection restored from the URL now applies when the list's dropdown chips come from the object definition (objectui#12001). (objectui `e616327ab`) + - **patch** — The list toolbar's hide-fields popover, Group editor and Row color select, and its user-filter chips, no longer offer a field the user may not read (objectui#11984). They now ask… (objectui `164f02cf6`) + - **patch** — Studio on a smaller or resized window (objectui#11795). Studio stays a desktop tool; three things change below desktop width: (objectui `70f1b2755`) + - **patch** — An `object-view` grid now exports the filter it shows, and returns to page 1 when that filter changes (objectui#11880, item 5). (objectui `5bc55c0c5`) + - **patch** — The designer forms pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the designer forms' part of that card): `FieldDesigner`'s type fi… (objectui `e8f9977f5`) + - **patch** — The console's Public Forms, Flow Runs and Profile pages pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the console pages' part of t… (objectui `28557855f`) + - **patch** — The dashboard designer's widget property panel picks a widget's Type and Color Variant with the shared `Select`, and the designers' property panel (`PropertyEditor`) draws a `sele… (objectui `5ad818310`) + - **patch** — The list toolbar's field-list read check is rolled back to restore the console's first-load budget, and it re-lands with objectui#11984 after objectui#11939's reclaim. (objectui `902fbe190`) + - **patch** — Studio's Interfaces create dialog picks a new page's source kind, and a new report's dataset and measure, with the shared `Select`, the control the rest of Studio picks with (obje… (objectui `fb361efe0`) + - **patch** — Studio's package record sharing overview picks each object's internal and external sharing model with the shared `Select`. That is the control Studio's object Settings tab already… (objectui `b1f0dcc00`) + - **patch** — A console list's view-tab switch starts the next view with the quick-filter selections its own URL carries (objectui#11992). The object page read the `uf_FIELD` selections once pe… (objectui `9c3798249`) + - **patch** — Studio's object Settings tab picks the sharing model, the external sharing model, the name field, the lifecycle field and the next highlight field with the shared `Select`, the co… (objectui `d21bd083e`) + - **patch** — A rolled-back Studio publish shows its "Nothing was published" banner again (objectui#11985). (objectui `1b8388bef`) + - **patch** — Studio's *Explain access* panel picks the object and the operation with the shared `Select`, the control Studio's validation rule editor already picks with (objectui#11865, the *E… (objectui `0bbb67b9a`) + - **patch** — **BREAKING** — `element:record_picker` and `element:number` bind data through the node-level `dataSource` only (objectui#11880, part of the objectui half of objectstack#11509, ruled A-narrow). (objectui `74add0c12`) + - **patch** — The console header no longer shows a "Search ⌘K" trigger where it would open nothing (objectui#11912). (objectui `a920d8c29`) + - **patch** — A refused package publish names each refused item with the server's reason, on the package sheet and on Home's *Publish all* and the draft-preview bar (objectui#11922). (objectui `c6e1b8c6b`) + - **patch** — Studio's action "New" menu opens on common actions in plain words, with the blank action under "Advanced" (part of objectui#11861: the actions entry). (objectui `d8be368c7`) + - **patch** — Studio's flow-node inspector, in its fallback form, writes only config keys the node's contract declares (objectui#11968). (objectui `0ab2b9885`) + - **patch** — Studio's validation rule editor picks a rule's type, field, built-in format and severity with the shared `Select`, the control the condition builder beside them already uses (obje… (objectui `f19b91540`) + - **patch** — Studio keeps a dashboard widget as an unsaved edit while the author changes its measure, instead of showing a red error the moment the last measure is removed (objectui#11951). (objectui `989b19082`) + - **patch** — Studio's field inspector gives `rating`, `slider` and `progress` fields a number *Default value* control, so the default saves as a number (objectui#11966). (objectui `31741d5f4`) + - **patch** — Studio Interfaces: a leaf save that goes through now clears the red "Changes not saved" strip an earlier refused save showed (objectui#11950). (objectui `402adc8aa`) + - **patch** — Studio's field type catalog now holds every field type the spec declares: it gains `user`, `secret` and `record` (objectui#11909). (objectui `0c9fb8ea3`) + - **patch** — Studio's Interfaces pillar can now create a page in a writable package, and opens it on its source beside a live preview (objectui#11823, step 3). (objectui `7546570a4`) + - **patch** — Studio's validation-rule "New" menu opens on common rules in plain words, with the rule types under "Advanced" (part of objectui#11861: the validation-rule entry). (objectui `7546570a4`) + - **patch** — Studio keeps a new dashboard widget as an unsaved edit until a dataset and a measure are picked, instead of showing a red error right after *Add widget* (objectui#11910). (objectui `a523a3e2e`) + - **patch** — Studio's action editor writes a script body that names its language, so the first keystroke is no longer refused (objectui#11921). (objectui `7129cd575`) + - **patch** — Studio's field inspector offers no *Default value* control on a `record`, `location` or `address` field, as it offers none on a `composite` or `repeater` field (objectui#11947). (objectui `bcd3f0803`) + - **patch** — The record picker (`RecordPickerDialog`) is laid out from the design system's own primitives (objectui#11903). It is the dialog behind a multi-select lookup's "Browse all records"… (objectui `5ab2f19b3`) + - **patch** — A create form preselects the option a field's option list marks `default: true` (objectui#11914). (objectui `ccddd1186`) + - **patch** — The list's Sort picker no longer offers a field the user may not read (objectui#11943). Choosing such a field sent a sort the server refuses, and the list went blank on "no access… (objectui `e44f9bc43`) + - **patch** — A read-only package's form designer no longer tells a screen reader how to drag a field card (objectui#11924). (objectui `94f48a28b`) + - **patch** — Studio's condition builder no longer writes `!field` or a bare `field` for a field that is not a boolean, when it knows the field's type (objectui#11894). (objectui `247f50349`) + - **patch** — Studio's page previews draw the page's own kind, as the running app does (objectui#11933): the live preview beside an `html` or `react` page's source, and the Interfaces page canv… (objectui `ccc2824cf`) + - **patch** — The list Group panel picks its field with the shared `Select`, the control the Filter and Sort panels beside it pick a field with (objectui#11865, the Group panel's part of that c… (objectui `7241a81e0`) + - **patch** — Studio's validation-rule "Runs on" boxes follow the spec's events (objectui#11923). (objectui `70e3d7721`) + - **patch** — Studio's form designer can be arranged from the keyboard on a canvas of any width (objectui#11898). With a field card picked up, each arrow key now moves it one place in the form'… (objectui `60d11cd61`) + - **patch** — Studio's field pickers offer the fields of an object that is still an unpublished draft (objectui#11895). (objectui `2fec2e007`) + - **patch** — The list's Filter panel offers only the fields the user may read (objectui#11925). Its field list now asks the same field-level read check the list's columns already use, `checkFi… (objectui `077d19825`) + - **patch** — The console is back under its first-load budget. Two recent Studio changes are rolled back to get there: the common-rule starting points in an object's Validations "New" menu (obj… (objectui `3c888c6e6`) + - **patch** — The console's record page now says what its record read actually answered: no access, not found, or could not load (objectui#11902). It used to answer every failed read with "Reco… (objectui `f1781be3a`) + - **patch** — Studio's landing page now points a read-only package at the routes that exist for customizing it (objectui#11808). (objectui `e81c84693`) + - **patch** — Four more editors stop asking for the layers of an item that was never published, so the browser console no longer logs a 404 for it (objectui#11799). The framework answers `GET /… (objectui `a025f4d12`) + - **patch** — Studio's flow designer connects two nodes the flow already has (objectui#11905). (objectui `4ebc25001`) + - **patch** — Studio's automation canvas header names the open flow by its label, not by the metadata type word (objectui#11665, the residual of objectui#11659 ruling 6). (objectui `d52d90855`) + - **patch** — Studio's form designer announces a field card's role in the author's language (objectui#11872). (objectui `fc82a31a6`) + - **patch** — Studio, and the app designer's navigation canvas, show a label first and the machine name or id as secondary text, in the places that showed only the machine name or id (objectui#… (objectui `0feabe8f6`) + - **patch** — Studio's field inspector picks a field type from a searchable list grouped by category, where it was one flat select of every type (objectui#11793). (objectui `72f5c608e`) + - **patch** — Studio's new validation rules, hooks and actions start from safe defaults (objectui#11820). (objectui `b4693b7b7`) + - **patch** — A console object list's Filter panel conditions, search term and sort are now in the URL, so a filtered, sorted list can be shared as a link or bookmarked (objectui#11860). (objectui `340bfabf8`) + - **patch** — Studio's publish review and publish confirmation say what changes (objectui#11807). (objectui `b9d9397b3`) + - **patch** — The Import Wizard preview marks a cell exactly where the server's import refuses it on dates, on rating, slider, progress and toggle columns, and on email columns, so the import b… (objectui `9fc6ad784`) + - **patch** — Studio's Interfaces pillar can now create a dashboard or a report in a writable package (objectui#11823, step 2). (objectui `a49c4bd27`) + - **patch** — Metadata-form code fields stay editable when Monaco cannot load (objectui#11858). (objectui `368359179`) + - **patch** — The Approvals Inbox no longer links to a record when the approver has no read of its object at all (objectui#11878, ruling C on objectstack-ai/objectstack#7497). Such an approver… (objectui `230e4b230`) + - **patch** — The login and register pages say when the server cannot be reached, and offer sign-up only once the server has said whether it accepts sign-ups (objectui#11806). This applies to t… (objectui `09821b1db`) + - **patch** — The console's first page load no longer downloads the documentation reader's markdown extras (objectui#11854). (objectui `7afcea06e`) + - **patch** — Both *Duplicate* forms judge a new package id by the spec's id rule, the one the server refuses by (objectui#11855). (objectui `475bda1a2`) + - **patch** — Grid inline edit marks a row modified only when a value really changed, draws one "Actions" column, and the "Edit inline" toggle reports its state (objectui#11816). (objectui `9844bbf92`) + - **patch** — Studio's form designer can be arranged from the keyboard (objectui#11871). Picking a field card up with Space or Enter, moving it with the arrow keys and dropping it with Space or… (objectui `a476dee29`) + - **patch** — The profile page states the access the console resolves, not the auth library's raw `user.role` (objectui#11866). (objectui `0855fc671`) + - **patch** — Studio stops asking questions whose expected answer is an error, so the browser console no longer fills with red lines that hide real failures (objectui#11799). (objectui `5dff0272d`) + - …and 73 more releasing changesets in this range (list capped at 100; see the objectui range below). + + ⚠️ 10 of these carry a breaking change: 10 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 4 commits in this range with no `.changeset/*.md`. The code is inside the pin above and ships here, but nothing upstream declared them, so they appear in no objectui CHANGELOG and in no entry above. Listed by subject rather than counted, because a count cannot tell a dependency bump from a form-behaviour change (objectstack#6174); the upstream gate that would prevent this is objectui#3387. + + - _(no changeset)_ fix(plugin-list): the hide-fields, Group and Row color lists and the user-filter chips ask the field read (objectui#11984) (#11999) (objectui `aa67386a5`) + - _(no changeset)_ chore(scripts): raise the console eager-closure ceiling by a fixed 5,120-byte allowance for Studio repairs while the designers are eager (objectui#11942) (#11949) (objectui `1c51e973b`) + - _(no changeset)_ feat(app-shell): Studio Interfaces pillar creates a page and opens it on source plus live preview (objectui#11823 step 3) (#11932) (objectui `f3a0488c9`) + - _(no changeset)_ feat(app-shell): Studio's validation New menu opens on common rules, with the rule types under Advanced (part of objectui#11861) (#11931) (objectui `19a734866`) + + + + objectui range: `a58626c88dc8...f0268ad78485` +- e66f13c: Console (objectui) refreshed to `f8267df93aba`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 46 releasing of 46 changesets added across 40 non-merge commits; omitted: 3 commits carrying no changeset (they ship no package code). + + - **minor** — **BREAKING** — The page renderer no longer compiles a `kind: 'jsx'` page node as `html`. This completes the retirement of the page kind `jsx` that `@objectstack/spec` 18 made (objectstack-ai/obj… (objectui `f8267df93`) + - **minor** — The page schemas no longer import or attach `@objectstack/spec`'s page-parse judge of a view-data component's retired `api` binding, which objectstack#23784 removed from the spec… (objectui `15d8ca6da`) + - **minor** — Home: "Recently accessed" brings you back to the records and list views you opened, beside the activity feed (objectui#12604). (objectui `f0e760afe`) + - **minor** — The details grid passes a pointer field's `referenceDeletedWhen` to `resolveRecordPointer` (objectui#12489). (objectui `023087f63`) + - **minor** — The record details grid draws a platform-deleted pointer target as a tombstone in the declared option's label (objectui#12489). (objectui `023087f63`) + - **minor** — `resolveRecordPointer` reads `referenceDeletedWhen`, so a pointer pair can say the platform deleted its target (objectui#12489). (objectui `023087f63`) + - **minor** — The console top bar is 48 px (`h-12`) in every frame, the height both approved console artboards draw (objectui#12639). It was 56 px (`h-14`). (objectui `dcd104102`) + - **minor** — List grids show the record a pointer field names, and the Approvals Inbox says who else is waiting (objectui#12608). (objectui `f78efdd1e`) + - **minor** — Locale keys for the console home's "My work" section (objectui#12602), in all ten packs. (objectui `db8ce2f41`) + - **minor** — The console home's "Needs your attention" card becomes the "My work" section of the approved home artboard (objectui#12602), and the pending-drafts banner above the greeting moves… (objectui `db8ce2f41`) + - **minor** — Resolve `@objectstack/*` `18.0.0-next.2` (objectui#12626). (objectui `f62b6ff4e`) + - **minor** — A kanban under a list view puts every record of the view in its column, not the list's first page, and its cards carry the view's first columns (objectui#12619). (objectui `8614705c1`) + - **minor** — **BREAKING** — `page:header` no longer reads `showStar`, ahead of `@objectstack/spec` 18 retiring the key (objectstack#23650). (objectui `608033b82`) + - **minor** — The console home's top half follows the approved home artboard (objectui#12601): a search field in the top bar, a greeting with a one-line work summary, two compact builder chips,… (objectui `6c4d00da0`) + - **minor** — `DefaultLoginPage` passes the translated names of `LoginForm`'s new password reveal toggle, `auth.login.showPasswordText` and `auth.login.hidePasswordText` (objectui#12600). It ke… (objectui `8ed7e5a8f`) + - **minor** — `LoginForm` gains an optional `hideHeader` prop and a password reveal toggle, and the shared auth form styles take the console login artboard's sizes (objectui#12600). (objectui `8ed7e5a8f`) + - **minor** — The console's login page follows the approved "refined centred" artboard (objectui#12600). (objectui `8ed7e5a8f`) + - **minor** — New `auth.login` strings for the console login page, in all ten language packs (objectui#12600): `welcomeTitle`, `welcomeDescription`, `showPasswordText`, `hidePasswordText`, `foo… (objectui `8ed7e5a8f`) + - **minor** — Every wide centred dialog keeps a 1rem gutter on each side of the window from `sm` (640px) up, by one shared class (objectui#12603). (objectui `726d8c9fc`) + - **minor** — A grouped grid now draws one table with one sticky column header. Each group is a 36 px band row between the rows: a chevron, the group value and its count, in one cell that spans… (objectui `619e3f7a4`) + - **minor** — The Studio's Publish and Changes buttons no longer open the pending-changes sheet the same way. (objectui `92c2a6976`) + - **minor** — **A record dialog with no authored layout opens bounded, and draws two fields per row wherever no `columns` is declared** (objectui#12521). Three defaults of `ModalForm` change. N… (objectui `7b5e55893`) + - **minor** — **BREAKING** — The console's list toolbar no longer has a write into the shared view, and the data-source method that write used is retired (objectui#12504). (objectui `3eb3ec138`) + - **minor** — Studio's Add field asks for the label and type before it creates the field, and the field type picker also searches descriptions (objectui#12516). (objectui `1b3b5b102`) + - **patch** — Action-param and bulk-action dialogs draw `date` and `datetime` params with the platform date picker, the same face record forms draw since objectui#12622 (objectui#12642). (objectui `c6b7d8203`) + - **patch** — The cloud-connection panel's "Not connected" card no longer repeats the page subtitle (objectui#12655). (objectui `efa9cb4fa`) + - **patch** — Record page sections: a collapsible section's header is now a real button named by the section title (objectui#12625). It carries `aria-expanded`, is reachable by Tab, and Enter o… (objectui `4be76e030`) + - **patch** — Studio's permission-set editor no longer offers the two delegated-admin switches that `@objectstack/spec` 18 retired (objectui#12406, the objectui half of objectstack#23488). (objectui `a98a23c78`) + - **patch** — Inside an app, the product mark stays in the top bar as the way home, and the app menu gains Back to home, All apps and Settings (objectui#12605). (objectui `a5bb7ac2f`) + - **patch** — Setup › Packaged automation keeps a flow row to the table's height when the platform says why the flow is not armed (objectui#12609). (objectui `8a8bcf3dd`) + - **patch** — Record forms draw `date` and `datetime` fields with the platform date picker instead of the browser's native date input (objectui#12622). (objectui `ca7a4c316`) + - **patch** — The record header draws one primary action and the rest as secondary buttons (objectui#12617). (objectui `ef71a09f9`) + - **patch** — **`marketplace.installedAdditiveNote` is plain text in all ten packs** (objectui#12610). Each value dropped its leading `Note:` markup (in that pack's language);… (objectui `f74638c1b`) + - **patch** — **Installed Apps' kernel note is the tooltip of the Uninstall it qualifies** (objectui#12610). The "kernel API is additive only" note was a paragraph at the foot of the list, show… (objectui `f74638c1b`) + - **patch** — **A settings action row draws its label once, on the button** (objectui#12610). The settings renderer's `action_button` row printed the manifest's `label` as the row label and aga… (objectui `f74638c1b`) + - **patch** — Marketplace cards show the whole app name, and the id keeps its package name (objectui#12616). (objectui `07b063249`) + - **patch** — Studio at a narrow width keeps the words that matter readable (objectui#12611). (objectui `a8560b228`) + - **patch** — The list toolbar's Display menu now closes once a layout is chosen (segmented or dropdown switcher), returning focus to the Display button, as Escape already does; the panel's tog… (objectui `79c199692`) + - **patch** — The Studio's Automations rail orders its object groups the way the package's app presents its objects (objectui#12612). The groups used to follow the alphabet of the object labels… (objectui `0a41305b0`) + - **patch** — Metric tiles keep their title readable in narrow tiles. The title now owns the first line (up to two lines, with an ellipsis on the second), the value never wraps and steps down o… (objectui `63d00da3d`) + - **patch** — The pending-changes sheet no longer lists the package stamp as one of the author's own changes (objectui#12594). Expanding a new object draft showed `_packageId` under "Also chang… (objectui `33fbc861d`) + - **patch** — An `lg` or `xl` `ModalForm` dialog keeps a 1rem gutter on each side of the window from `sm` (640px) up (objectui#12593). (objectui `85e8961f2`) + - **patch** — A boolean cell's read-only checkbox is named by its field (objectui#12584). (objectui `7d0c174ba`) + - **patch** — Studio's way home leads back to the Studio landing, and the two links that leave for Setup say so (objectui#12513). (objectui `f5eec6383`) + - **patch** — **The console's New and Edit dialog on a field-heavy object opens bounded on desktop** (objectui#12521). For an object with twelve or more authorable fields, `@objectstack/spec`'s… (objectui `7b5e55893`) + - **patch** — Studio: the draft status chip sits in the same place in every pillar's item header (objectui#12524). "Unpublished draft" in Data, Automations and Interfaces, and Access's "saved a… (objectui `ae923ece2`) + + ⚠️ 3 of these carry a breaking change: 3 by the author's own breaking annotation in the changeset body — objectui declares no `major` inside a launch window (`scripts/check-changeset-no-major.mjs`). Each is marked **BREAKING** in the list above — read them before compiling the release record. + + **In this console build, declared nowhere** — objectui merged 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)_ fix(live-e2e): the backend cold install writes overrides.vitest so npm 10 can install the published graph (objectui#12649) (#12674) (objectui `d66ceea5a`) + - _(no changeset)_ docs(skills): point two published member counts at their declarations instead of numbers (objectui#12588) (#12591) (objectui `8415d9f11`) + - _(no changeset)_ ci: delete the spec-main-shape-gate copy, the spec shape gate runs in objectstack (objectui#12352) (#12596) (objectui `c6a42be39`) + + Clause-②: no + + + + objectui range: `daeb77fc84dd...f8267df93aba` +- 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` + +### Patch Changes + +- b53b949: Console (objectui) refreshed to `47b1f0bb7174`. Frontend changes in this range: + + Derived from the changesets objectui declared over the range — 13 releasing of 14 changesets added across 14 non-merge commits; omitted: 1 release-nothing changeset (they ship no package code). + + - **patch** — The console's sign-in page no longer reads `/api/v1/i18n`, and the application's translations and locale list load with the session's credentials once signed in (objectui#12034). (objectui `47b1f0bb7`) + - **patch** — The record approvals panel draws its decision-progress tally through one module-internal indicator, `DecisionProgressIndicator` (objectui#12033, part of objectui#2763). The tally… (objectui `ba9e82026`) + - **patch** — The Studio header's *More* trigger keeps its own name (objectui#11794). While *Access*, the pillar it holds, was open, the trigger renamed itself to "Access", which hid the word t… (objectui `8de8ba280`) + - **patch** — The embedded item editor ("Save into object", opened from a metadata item's Related drawer) now saves into the parent's draft (objectui#12027). (objectui `ea79b7777`) + - **patch** — A compact record-preview card for any `(object_name, record_id)` pair, kept inside the package for the approval surfaces to compose (objectui#12029, the first child of objectui#27… (objectui `049012bf0`) + - **patch** — Studio navigation details (objectui#11794): (objectui `8f815f4fe`) + - **patch** — Three more controls pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the list view and the chatbot): `ListView`'s "Color by field" an… (objectui `8f8f760fa`) + - **patch** — Studio saves one way on a package: the permission matrix and hooks autosave to the package draft like the other pillars, every create dialog says *Save as draft*, and the Changes… (objectui `6694abe75`) + - **patch** — Five of the Studio design surface's pickers use the shared `Select`, the control the rest of Studio picks with (objectui#11865, the design surface's part of that card): in the nav… (objectui `5382a865f`) + - **patch** — Four plugin controls pick with the shared `Select`, the control the rest of the console picks with (objectui#11865, the plugins' single selects): `SharedViewLink`'s "Expires after… (objectui `2063f7a96`) + - **patch** — A quick-filter value restored from the URL now gets its field's type once the object definition loads, when the field is declared without its type (objectui#12008). (objectui `16b9d440d`) + - **patch** — Four metadata-admin pickers use the shared `Select`, the control the rest of the console picks with (objectui#11865, the metadata-admin previews and inspectors' part of that card)… (objectui `f2bff5ce8`) + - **patch** — The Create View dialog, the AI build panel's Excel import bar and the API console's method selector pick with the shared `Select`, the control the rest of the console picks with (… (objectui `869d0bfdf`) + + objectui range: `f0268ad78485...47b1f0bb7174` + ## 18.0.0-next.5 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index 345ad67daa0..d3a3ca03c10 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "18.0.0-next.5", + "version": "18.0.0", "description": "Prebuilt Console SPA pinned to this framework release, installed as a dependency of @objectstack/cli. Source of truth: @object-ui/console (https://github.com/objectstack-ai/objectui).", "license": "Apache-2.0", "homepage": "https://github.com/objectstack-ai/objectstack/tree/main/packages/console", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 39d00ae5c39..348c29826b3 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,1165 @@ # @objectstack/core +## 18.0.0 + +### Minor Changes + +- b2e7870: fix(security): under the `single` tenancy posture the first-user platform-admin grant is owned by the Default Organization, and every reader of platform standing reads that row + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + - **FROM** (`@objectstack/plugin-security`): a `single` deployment holding several organizations, none slugged `default`, promoted its first user with an organization-less `admin_full_access` grant. **TO:** it is not promoted; the bootstrap logs one `error` line, returns `reason: 'default_organization_unresolved'` and writes no grant row. **Fix:** give the intended organization the slug `default` (or keep one organization, or declare a walled posture); the next `kernel:ready` or sign-up pass promotes. + - **FROM** (`@objectstack/plugin-auth`): a `sys_organization` `slug` change or delete was never refused. **TO:** it is refused with `403 PERMISSION_DENIED` when that organization is the Default Organization whose `admin_full_access` grant is the last unbanned administrator's anchor. **Fix:** give another account administrator standing first, then make the change. + - **FROM** (`@objectstack/plugin-auth`): `ensureDefaultOrganization` under `single`, with a sole organization of another slug, created a second, `default`-slugged organization and bound the administrator there. **TO:** it reuses the sole organization; no second organization is created. **Fix:** none needed; an install that wants a `default`-slugged organization re-slugs its existing one or creates it explicitly. + - **Widening** (`@objectstack/core`): under `single` only an organization-less `admin_full_access` grant conferred `PLATFORM_ADMIN`. Now a grant owned by the Default Organization confers it too and applies in every organization; a pre-existing Default-Organization-scoped `admin_full_access` grant gains the rung on upgrade; and `sys_organization` becomes a standing-deriving table (the resolver reads it only under `single` for a holder of an organization-owned `admin_full_access` grant — one or two small reads per uncached request; the last-administrator guard adds three to six small reads per guarded write). + + - **The promotion writes the Default Organization (`@objectstack/plugin-security`).** A `single` deployment with no configuration still promotes its first user that can sign in. The `admin_full_access` grant row that promotion writes now carries the Default Organization as its `organization_id` (ADR-0131 D5), never NULL. The Default Organization is the one with `slug = 'default'`, else the only organization: the organization the auth plugin creates at boot. A deployment that names no Default Organization (several organizations, none with the slug `default`) is not promoted. The bootstrap logs one `error` line saying why and returns `reason: 'default_organization_unresolved'`. It writes no grant without an owner. `OS_PLATFORM_OWNER_EMAIL` is still the walled-posture channel and is not required under `single`. + - **The authorization resolver reads that row (`@objectstack/core`).** Under `single`, `PLATFORM_ADMIN` derives from an `admin_full_access` grant owned by the Default Organization, and still from the organization-less row an earlier release wrote. The anchoring grant applies in whichever organization the user acts in, as the organization-less row always did. A grant of the set owned by any other organization confers the set in that organization, never platform standing. Under a walled posture no grant anchors standing (unchanged). One predicate decides both postures, with the posture as its parameter. The resolver reads `sys_organization` only for a user who holds an organization-owned `admin_full_access` grant under `single`. + - **An upgraded database keeps its administrator.** `os migrate organization-ownership --apply` gives a `single` database's organization-less grants to the Default Organization, the platform admin's grant included. Before this change the resolver then stopped grading that admin `PLATFORM_ADMIN`, and every later boot found no organization-less holder and promoted again. The holder scan now reads the Default Organization's grant, so a migrated database keeps its admin and promotes nobody else. A user who holds both rows counts as one holder. + - **The last-administrator guard and the default-organization bootstrap read it too (`@objectstack/plugin-auth`).** The break-glass guard counts the holder of the Default Organization's grant as a platform admin and treats that grant as evidence that the environment once had one. It also judges the two organization writes that move the anchor off that grant: changing the Default Organization's `slug` while another organization exists, and deleting it. Either is refused when it would leave no administrator who can sign in. `ensureDefaultOrganization` finds the admin through the same grant. Under `single` it also reuses the Default Organization when that is a sole organization with another slug, where it used to create a second, `default`-slugged organization. +- c28f317: feat(core): one by-name read of the security catalog (`createSecurityCatalogReader`) + + Clause-②: yes (widening) + + - **What is new.** `createSecurityCatalogReader({ registry, metadata })` returns a reader with two members: `resolve(type, name)`, the definition a position, permission set or capability name resolves to (or `undefined`), and `list(type)`, one entry per name. `type` is `'position' | 'permission' | 'capability'`. Each entry is `{ type, name, definition, source, packageId? }`. The types `SecurityCatalogType`, `SecurityCatalogSourceName`, `SecurityCatalogRegistry`, `SecurityCatalogMetadataService`, `SecurityCatalogSources`, `SecurityCatalogEntry` and `SecurityCatalogReader` are exported with it. + - **Where it reads.** ObjectQL's `SchemaRegistry` (`engine.registry`) first, then the kernel `metadata` service for the names the registry does not hold. Neither holds the whole catalog: the engine registry carries the platform's own permission sets and every package manifest's catalog items, stack-declared positions included, and the metadata service carries the security collections an app registers in memory but not the platform's permission sets. Both are required; construction refuses a missing one. + - **A name two packages ship** resolves the way the registry's by-name read does today: a stored override first, else the first-registered package's body. + - **What it does not answer.** Whether an item is in effect: the row `active` flag stays the authority, and no definition carries it. The position → permission-set binding. Organization scope: the catalog is environment-level. + - **Failures are loud.** A reader that throws, or a metadata read that lost a loader and found nothing, raises `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503) instead of answering "no such item". A definition owned by a disabled package answers neither member. + - Nothing calls the reader yet; no grant changes. +- 2e10c9a: feat(core)!: the authorization resolver reads which permission set a user grant holds from the grant's name, `sys_user_permission_set.permission_set` (ADR-0131 D4) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **What the resolver reads now.** `resolveUserAuthzGrants` (and every surface built on it: `resolveAuthzContext`, `hasPlatformAdminStanding`, the explain engine, `runAs: 'user'` automation) finds a user grant's permission set by the grant's `permission_set` name. The set row is the grant's own organization's row of that name, or else the organization-less row of that name. `permission_set_id` is no longer read for this. Deactivation is still read from the set row. Sets a principal holds through a position are still reached through the position binding's id. + + - **Platform standing.** An unscoped grant is read against the organization-less `admin_full_access` row only. An organization's copy of `admin_full_access` gives that organization's grants its capabilities, never platform standing. + - **Nobody's resolved permissions change** where a grant's name and id agree. The platform's grant writers store both, and the one-time backfill names grants stored before the name column existed. Goldens for the platform administrator, an organization administrator, a member and an agent are unchanged in `single`, `group` and `isolated`. + + **What stops conferring.** + + - **A grant that names nothing.** This is a grant the backfill has not named yet, or one it could not name: its id names no set row, its id names another organization's set row, or the name is not in the security catalog. Such a grant now confers nothing through the resolver. The backfill runs at `kernel:bootstrapped`, before any server opens its socket, so on the first boot after upgrading no request is answered before the grants it can name are named. The grants it cannot name are listed in its boot report. + - **A grant whose id names another organization's set.** Before this change, such a grant conferred that set. It now confers nothing. + - **Remedy.** Read the backfill's boot report, which lists each grant by row id. Re-point each listed grant at a permission set of its own organization, or at an organization-less one, with an update of `permission_set_id`. The platform stamps the name, and the grant confers again. + + **What the grant name hook refuses now** (`@objectstack/plugin-security`). The name is taken only from a set row of the grant's own organization, or from an organization-less one. A tenant-less system writer can read every organization's sets, and it could name an organization-less grant after another organization's set. Now such a grant is stored without a name. A write that supplies that name is refused, from a system writer too, with the hook's usual `400 VALIDATION_FAILED` and `invalid_value` at `permission_set`. An update that re-points a grant at such a set, or moves a grant to an organization its set does not belong to, clears the name. + + **What the last-administrator guard judges now** (`@objectstack/plugin-auth`). `organization_id` on `sys_permission_set` is a standing column. Moving the organization-less `admin_full_access` row into an organization takes away every grant-anchored platform administrator, as deleting it does, so the guard refuses it when it would leave none. An organization's copy of `admin_full_access` no longer counts as the set being in effect. + + **Nothing to migrate** in code or metadata. +- 67b669e: The security catalog's activation door switches a position or permission set the environment authored, and a ledger write it refuses answers with the status its code means + + Clause-②: yes (widening) + + - **The door accepts environment-authored items** (`@objectstack/runtime`). `POST /api/v1/security/_activation/:type/:name` switched a position or permission set off and on only when a package shipped it. FROM: an item saved through the metadata door (`PUT /api/v1/meta/position/:name`, `PUT /api/v1/meta/permission/:name`) was refused `503` with `{ code: 'VALIDATION_FAILED', message: 'Package is required' }`, and nothing was written. TO: it is switched like a packaged one, `200`, and the resolver honours the row: a switched-off position stops granting its permission sets, and a switched-off set stops granting by every path. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared name `404`, a type with no switch or an audience anchor `400`, the last administrator `403`). + - **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, a hook's refusal keeps its own pair (the last-admin guard's `403 PERMISSION_DENIED`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. + - **The ledger row of an item no package ships carries no package** (`@objectstack/platform-objects`, `@objectstack/core`). `sys_metadata_activation.package_id` is no longer required. It is empty exactly when no package ships the item. It is not part of the row identity (`metadata_type`, `name`), and no reader decides anything from it. The column was already nullable on disk (ADR-0113: `required` is a write-time check), so there is nothing to migrate. + - **One spelling for "no package"** (`@objectstack/core`). `MetadataActivationRow.packageId` is optional. FROM: `packageId: string`, with `''` the only way to say "no package". TO: omit `packageId` for an item no package ships, and the store writes no value. `ObjectStoreMetadataActivationStore.setActive` refuses `packageId: ''` with a `TypeError` before it reads or writes anything, and `list()` returns `packageId` only when the row names a package. If your code passes `''`, omit the key instead. + - **The flow row follows the shared row** (`@objectstack/service-automation`). `FlowActivationRow.packageId` is optional to match. Every flow row still names its package, because the flow toggle refuses a flow no package ships before it writes. + - **The action activation door's answer for an action no package ships changes; the action is still refused.** `POST /api/v1/actions/_activation/:object/:action` still passes `''` as the package of such an action. The write is still refused and still writes nothing, but the store's `TypeError` refuses it now, with the store's sentence. FROM: `503 VALIDATION_FAILED`. TO: `503 SERVICE_UNAVAILABLE`. An environment-authored action could not be switched on `main` before this change and cannot after it. +- bfc15d2: feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4) + + Clause-②: yes + + + + **BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes. + + **FROM → TO.** A position's permission sets were `sys_position_permission_set` rows. They are now declared in `permissionSets` on the position's definition; junction rows no longer grant. + + ```ts + definePosition({ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }); + ``` + + The one-line fix for an app that bound sets to its positions with a boot-time binder: name the sets in `permissionSets` on each position. + + **`@objectstack/spec`.** `PositionSchema` accepts `permissionSets`: a list of permission-set names (lowercase snake_case). Before, the key was refused with a pointer to the junction. Each name is resolved in the environment catalog when a holder's grants are resolved. A name the catalog does not hold confers nothing. + + **`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`): + + - A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. + - A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing — including a row inserted directly (a script or a harness, outside the product's doors). + - **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. + - The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition. + - `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. + - Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. + - An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. + - `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot. + + **`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it). + + **`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A ledger write that switches `admin_full_access` off is refused only when it would leave no administrator who can sign in, and switching it back on is never refused. The guard no longer judges `sys_permission_set` writes, because the resolver no longer reads that row. + + **`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES. + + **`@objectstack/plugin-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row. + + **What stops granting at upgrade, and what to do.** + + - **A deactivation made in Setup.** The Deactivate action on a position or a permission set writes the row's `active` column, which no longer switches anything off. ⚠️ Until the upgrade ceremony converts the rows (`convertDeactivatedCatalogRows`), a position or set deactivated that way **grants again**. No enable/disable door accepts the `permission` or `position` type yet, so a new deactivation has no in-product path. To revoke now, remove the assignments or the binding. + + - **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code. + - **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today. + - **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate organization-ownership --apply`) under `single` only, where its `authored-conversion` step applies `convertPositionBindingRows`. Under a wall (`group`, `isolated`) the ceremony converts no binding (ADR-0131 D3): its `rewrite` step refuses each binding whose position's definition does not name the bound sets, and the ceremony stops there until the operator acts and re-runs it. A position no catalog declares is declared with those sets in `permissionSets` through one door only: the deployment's code, or the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator). A declared position the metadata door can edit takes the missing sets through that door. A definition a package ships is sealed (ADR-0131 D6), and no door on the database adds sets to it. A name that organizations bound differently is reported `conflicting` and is never merged. +- 2ff0825: feat(plugin-security)!: the security plugin's own readers (set resolution, the delegated-administration gate, the permission explainer and the position-assignment refusal) read the security catalog and the activation ledger, not the catalog rows (ADR-0131 D3/D4) + + Clause-②: yes + + + + **BREAKING** (enforcement stops reading `sys_permission_set` rows), shipped as `minor` under the launch-window convention for breaking changes. This is the second step of the cutover that `position-permission-sets-declared` registers: the authorization resolver already reads the catalog; now the security plugin's readers read the same catalog. + + **Permission-set resolution (`@objectstack/plugin-security`).** + + - FROM: a set name the metadata service and the bootstrap sets did not answer was loaded from a `sys_permission_set` row (the caller's organization's, else an organization-less one), its object map and field map included, and dropped when the row's `active` was false. + - TO: it is loaded from the security catalog (the environment registry the security plugin binds to its engine): every package's sets and every set an author saved through the metadata door. A set that only a row carries resolves nothing. The resolver already granted nothing through such a set; now its object and field permissions stop applying too. + - Fix: declare the set through the metadata door (`PUT /api/v1/meta/permission/NAME`) or in a package. + - A permission set that resolves only because a POSITION of the same name was folded into the request is dropped when the activation ledger (`sys_metadata_activation`, type `permission`) switched it off. The row's `active` column is not read. + + **Delegated administration (ADR-0090 D12).** + + - FROM: the sets a position distributes were its `sys_position_permission_set` rows in the caller's organization; `delegatable` was the organization's `sys_position` row's; the assignable-positions listing (`describeDelegableScope`) listed `sys_position` rows; a direct grant's set (and its `adminScope`) was its `sys_permission_set` row. + - TO: all four read the security catalog by name. A position distributes the sets its definition's `permissionSets` names, so the containment check and the listing see a binding declared only on the definition. `delegatable` is the definition's. The listing offers every position the catalog holds. A direct grant's set is the catalog's definition of the name the grant carries; a grant naming a set the catalog does not hold is refused for a delegate (a tenant administrator is not judged). + - A name a position's `permissionSets` carries that the catalog does not hold must be on the delegate's allowlist too. It grants nothing today, but it starts granting as soon as a set of that name is authored. + - A catalog read that fails refuses the delegate's write. Before, it approved it as "distributes nothing". + + **The permission explainer.** "deactivated" is read from the activation ledger through the resolver's own read, so explain and enforce agree. A direct grant whose set the catalog does not hold is reported neither as expired nor as deactivated: there is no set to lose. + + **Position assignments (`sys_user_position.position`).** + + - FROM: refused `400 VALIDATION_FAILED` unless a `sys_position` row (the writer's organization's, or an organization-less one) carried the name. A position declared only in a package or saved only through the metadata door was refused. + - TO: refused unless the security catalog holds a position of that name. A registry-declared position is accepted. A name only a `sys_position` row carries (with no definition) is refused, because it grants nothing. A deactivated position is still accepted. + + **One position name per deployment under `single`.** + + - FROM: a Setup create of a position, or a rename into a name, that an environment definition already held overwrote that definition (another organization's label and description, an empty `permissionSets`). + - TO: refused `409 UNIQUE_VIOLATION`, the answer a second row of the name in one organization already gets, and the row write is undone. This covers a definition another organization's row wrote, one saved through the metadata door, and one an application stack declares. A name a package or a built-in holds keeps its `403 NOT_OVERRIDABLE`. + + **`@objectstack/core`.** `readDisabledCatalogNames(ql, positions, sets)` is exported: the resolver's one read of the activation ledger. It returns the names switched off for this deployment and is now shared by the explainer and the security plugin. +- 66949b7: feat(plugin-security)!: the four security-catalog row objects are retired — `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` (ADR-0131 D3) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Positions, permission sets and capabilities live in the security catalog — the environment registry, fed by package declarations and by environment metadata saved through `/api/v1/meta` (ADR-0131 D3). The four row objects that mirrored them are no longer registered, so their data and REST routes and their Setup object pages are gone. No table is dropped and no row is deleted. + + FROM → TO: + + - `GET|POST|PATCH|DELETE /api/v1/data/sys_permission_set` → `GET|PUT|DELETE /api/v1/meta/permission/:name`; to copy a set, `POST /api/v1/security/permission-sets/:name/clone` with `{ name, label }`. + - `… /api/v1/data/sys_position` → `/api/v1/meta/position/:name`. A position's permission sets are its definition's `permissionSets`. + - `… /api/v1/data/sys_position_permission_set` → the position definition's `permissionSets` (`PUT /api/v1/meta/position/:name`). + - `… /api/v1/data/sys_capability` → read-only `GET /api/v1/meta/capability`; a package declares a capability with `defineCapability` (`stack.capabilities`). + - Setup → Access Control → Positions / Capabilities / Permission Sets now open Setup's catalog lists (`/apps/setup/metadata/?scope=environment`) instead of the object pages; the `sys_position` record page (`SysPositionDetailPage`) is removed. + - An object permission on one of the four names in a permission set grants nothing and can be deleted. + + What else changes: + + - `sys_user_permission_set.permission_set_id` is a plain text column now, not a lookup: same column, same stored ids, same indexes. It is not written and grants nothing; a grant names its set by `permission_set`. + - A permission set saved through the metadata door is written to no row: the `permission` mutation projector, the `sys_permission_set` and `sys_position` data-door write-throughs and the row-only position backfill are no longer registered. + - On a fresh `single`-posture database the first human is promoted to platform administrator without reading any catalog row. An unscoped grant written before `sys_user_permission_set.permission_set` existed (it names no set) still withholds the promotion until the organization-ownership migration names it. + - Uninstalling a package still revokes every grant naming a permission set the package declared, by the names the uninstalling door captures before the withdrawal (`declared`, `captureUninstallDeclared`), and now by those names alone: the cleanup reads and removes no `sys_permission_set` or `sys_position_permission_set` row, so the `sets` and `positionBindings` counts on its outcome are always 0. A grant written before `sys_user_permission_set.permission_set` existed names no set and is not reached; the organization-ownership migration names it. + - On a `group` or `isolated` deployment the data-door half of the walled catalog refusal goes with the objects: a data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability` answers `404 OBJECT_NOT_FOUND` for every caller instead of `403 PERMISSION_DENIED` below the platform operator. The refusal at the `/meta` write doors, the permission-set clone door and the `/packages` write doors is unchanged. + - `capability-reference-unknown` no longer counts a `sys_capability` seed row as a declared capability — no runtime reader consults one. Declare the capability with `defineCapability`. + - On an existing database `os migrate plan`'s unmanaged-table sweep now lists the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` tables: they keep their rows and no object declares them any more. The sweep only reports; nothing is dropped, and the tables stay for the organization-ownership migration. + + Removed exports, no replacement: + + - `@objectstack/plugin-security`: the object definitions `SysPosition`, `SysPermissionSet`, `SysPositionPermissionSet`, `SysCapability` (from `./objects`); `securityObjects` lists the remaining three. + - `@objectstack/platform-objects/pages`: `SysPositionDetailPage`. + - `@objectstack/spec`: `SystemObjectName.POSITION`, `SystemObjectName.PERMISSION_SET`; the four names leave `PLATFORM_OBJECTS_BY_PACKAGE['plugin-security']` and `PLATFORM_PROVIDED_OBJECT_NAMES`. + - `@objectstack/core`: `isRowActive` and `ActivatableRow` — a catalog row's `active` column decides nothing; deactivation is the activation ledger's (`sys_metadata_activation`). + - `@objectstack/objectql`: the published `PLATFORM_OBJECT_TENANCY` record loses its `sys_permission_set` key (the other three names were never in it). A removed key of an exported constant is a narrowing, so `@objectstack/objectql` takes `minor` under the launch-window convention for breaking changes. +- 41d0d40: feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING** on a deployment whose `org-scoping` service declares `platformGlobalObjects`. Nothing changes on any other deployment. + + ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." Until now the declaration stood the organization wall down for the object while the object kept its `organization_id` column, so the SQL driver went on scoping a read by the caller's organization that the wall no longer scoped. + + - **The plan** (`@objectstack/spec`): `resolveInjectedSystemColumns(def, deployment?)` takes the deployment's validated `platformGlobalObjects` as an optional second argument. An object it names is planned with no `organization_id`, and nothing else in its plan moves. With no second argument, or an empty list, the plan is the same as before. Author-time callers (the linter, the import mapper, the tenancy census) have no deployment and pass none. + - **The registry** (`@objectstack/objectql`): `ObjectQLPlugin` reads the declaration at the top of `start()`, before the first schema sync, and installs it with the new `SchemaRegistry.setDeploymentPlatformGlobalObjects()`. A declared object is registered with no `organization_id`, no tenant index and `systemFields: { tenant: false }`, and its table is created without the column. An object registered earlier, inside another plugin's `init()`, is re-planned at that moment, before its table exists. The `/meta` read exits serve the same shape. On the way back in, the write path removes the recorded `tenant: false`, so a Studio save stores the body the author wrote. The plugin logs the declared list once at boot. + - **The order** (ADR-0116): every `init()` completes before any `start()`, so a provider that registers `org-scoping` in its `init()` has registered by the time the engine reads it. `OrganizationsPlugin` now declares `providesServices: ['org-scoping']` (`@objectstack/organizations`). A provider that registers the service later, with a different declaration than the one the columns were planned from, fails the boot at `kernel:ready` with an error that names both lists and the fix. + - **The stand-down is retired** (`@objectstack/plugin-security`): `getObjectSecurityMeta` no longer reads `platformGlobalObjects`. A declared object reaches the security layer as its own `systemFields.tenant: false`, the same way every deployment-level object does: Layer 0 composes no organization predicate on it, a wildcard `organization_id` policy does not apply to it, and the ADR-0123 D2 no-active-organization write refusal does not fire on it. The `[security] deployment declares N platform-global object(s)` boot line is gone; the engine logs the list instead. + - **One reader** (`@objectstack/core`): `readDeploymentOrgScopingEntitlement` moved from plugin-security into `@objectstack/core` and is exported there, with its rules unchanged. An absent key declares nothing. A malformed key is refused whole: no object is declared, and the consumer of that key warns once. The engine warns for a malformed `platformGlobalObjects`, and plugin-security for a malformed `suppressUnboundedOrgAdminGrant`. `suppressUnboundedOrgAdminGrant` and its behaviour are unchanged. + + **What moves for consumers.** + + - **On the declaring deployment**, a declared object has no `organization_id`. FROM an authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on that object, TO the same reference with that field removed. A write that still names it is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. The object is governed by object permission, not by the organization wall: a reader the object permission admits reads every row, where the SQL driver used to narrow a read to the caller's organization and the rows with no organization. + - **An object that declares its own `organization_id`** keeps that column, because it is the author's and not the platform's, and the organization wall keeps scoping it. The engine warns once at boot and names the object. Remove the authored field, or drop the object from the declaration. + - **Author-time tools** have no deployment, so they still list `organization_id` among a declared object's columns. Only the declaring deployment differs, and it refuses the name at runtime. + - **Existing databases: nothing moves automatically** (ADR-0131 D14). Schema sync is additive, so on a declaring deployment the physical `organization_id` column stays and the boot drift report names it orphaned. The operator removes it once the rows need nothing from it. No boot step reads or writes it. +- eebaf3b: feat(objectql,core): the driver's tenant read scope is the organization wall's verdict — one predicate for Layer 0 and every driver (ADR-0131 D8) + + Clause-②: yes (widening) + + - **What changed.** The engine no longer derives `DriverOptions.tenantId` / `tenantIds` beside the organization wall. `buildDriverOptions` projects the wall's own verdict onto them: the verdict plugin-security records on the operation (`OperationContext.tenantLayer0Verdict`) where its wall ran, and otherwise the same function over the engine's own facts (the posture in force, the context's organization and membership set). The function, `computeTenantLayer0Verdict` with its input type `TenantLayer0Input`, moves to `@objectstack/core` (new export); `@objectstack/plugin-security`'s `tenant-layer.ts` re-exports it and its Layer 0 is byte-identical. + - **FROM → TO, per posture, for a read, count, aggregate, update or delete on an object with an organization column:** + + | posture / caller | driver scope before | driver scope now | + |:--|:--|:--| + | `single`, any caller naming an organization | that organization (plus organization-less rows) | **none** | + | `isolated`, member | the active organization | unchanged | + | `group`, member | the membership set | unchanged | + | walled, exempt `PLATFORM_ADMIN` on a posture-permitting object, or the verified platform owner's read | the active organization | **none** — what Layer 0 already admitted | + | walled, system context naming an organization | that organization | unchanged | + + - **Not changed.** Inserts and upserts keep the active organization as `tenantId` in every posture: the row is stamped with it, its counter scopes by it, and an upsert that would merge into another organization's row is still refused. Both `orWhereNull` compatibility arms in `SqlDriver.applyTenantScope` stay (their retirement is a later stage of ADR-0131 D13). + - **Who notices.** A `single` deployment that holds more than one organization was kept apart by the driver only by accident; postures now differ only in enforcement (ADR-0131 D11), and `single` enforces no wall. To keep organizations apart, run the `isolated` or `group` posture. Code that relied on a context organization to narrow a read on `single` names the organization in its own `where`. + - **Migration.** ADR-0087 semantic entry `driver-read-scope-follows-tenant-wall`. A deployment's `platformGlobalObjects` carve-out already reaches the driver through the column plan (ADR-0131 D7): a declared object carries no organization column, so neither wall has anything to scope. +- 4578c56: fix(import): `createMissingOptions` now keeps an unmatched option value through the engine's option check + + Clause-②: yes (widening) + + - **What was wrong.** With `createMissingOptions: true` on an import (objectui's Import Wizard sends it for *Keep unknown option values*), the import coercion kept a `select` / `radio` / `multiselect` cell that matched none of the field's options. The engine's write-path option check then refused the same value ("Priority must be one of: high, low"), so on every writable field the row still failed. Only the stage that refused it changed. + - **What happens now.** The import stores the kept value: a single-value cell trimmed, and each unmatched item of a multi-value cell. The dry run admits the same rows the write does. The engine admits exactly the values the import kept, on that import's writes. Every other value outside the options is still refused, and a field bound to a shared picklist that did not resolve is still refused. + - **The field's options do not change.** No option is added to the field. A later write that sends the field is judged against the options again, so sending the kept value back is refused and picking an option is accepted. A write that leaves the field out, such as an edit form saving only the fields it changed, is not judged on that field. + - **`@objectstack/spec`.** `ExecutionContext` declares `keptOptionValues`: per field name, the option values an import kept. It is server-constructed only, like `skipStateMachine` and `preserveAudit`, and is never client-supplied. The `ImportRequest.createMissingOptions` description now states exactly the behaviour above. + - **`@objectstack/core`.** `coerceRow` returns `keptOptionValues` beside `data` and `errors`, and `coerceFieldValue` reports the values it kept. `runImport` puts exactly those values on each write's context. A per-row write carries its own row's values, and a batched create carries the union of its rows' values. A row that kept nothing is written with the import's context unchanged. + - **`@objectstack/objectql`.** `ValidateRecordOptions.keptOptionValues` lets the single-value and multi-value option arms admit a listed value. The engine fills it from the write's `ExecutionContext.keptOptionValues` on insert, update and `validate`. +- f6e1d49: feat(core): `ObjectKernelConfig.bootPhaseHooks`, a declared option to bootstrap without dispatching `kernel:ready`, `kernel:bootstrapped` and `kernel:listening` + + Clause-②: yes (widening) + + - **What is new.** `new ObjectKernel({ bootPhaseHooks: false })` runs dependency ordering, every plugin's `init()`, the core-service fallbacks, every plugin's `start()` and the system-requirement check exactly as before. It then stops without dispatching the three boot-phase hooks, and the kernel is `running`. Only an explicit `false` withholds them. An absent option or `undefined` keeps today's boot, unchanged. `Runtime` forwards the option through its existing `kernel` config. + - **Who it is for.** A host that needs a kernel's registered definitions and started services, and nothing the boot phase does. The example is a repair kernel for an environment whose normal boot cannot finish because a boot-phase handler throws, never settles, or outlasts the host's timeout. Such a host no longer has to wrap the kernel's private `context.hook` to drop those names. + - **What it guarantees.** Every service and every definition a plugin registers in `init()` or `start()` is present, a driver it connects there included. `shutdown()` is unchanged: `kernel:shutdown`, every `destroy()` and every `onShutdown()` handler run. + - **What it does not guarantee.** That the definitions equal a full boot's. A definition a plugin registers in a boot-phase handler is absent, and nothing a plugin does in those handlers runs: seeds, reconciles, schedulers, audits, and the HTTP listener a server plugin opens on `kernel:listening`. Whether a given set of plugins registers anything there is a reading of that set, not a property of the option. The option withholds the kernel's own dispatch, not the hook names. + - **Loud, once.** A withholding boot logs one `warn` naming each withheld hook and how many registered handlers it left uncalled, with the counts also in the line's `withheldHandlers` field. Its completion line says the hooks were withheld. +- 7098aca: `bootStack` mounts the always-on capability slate `objectstack serve` mounts for every app, and builds each provider from the app's configuration the way `serve` builds it + + Clause-②: yes (narrowing: a configuration whose mail or SMS settings, or the OS_EMAIL_* / OS_SMS_* environment, name a transport that cannot deliver, now fails bootStack where it booted; widening: bootStack mounts the always-on slate objectstack serve mounts for every app and hands each provider the app's configuration, so the app's analytics cubes now reach the registry, where it mounted fewer and built them with defaults) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — the composition `serve` mounts, built the way `serve` builds it.** For one configuration, `bootStack(config, opts)` already mounted the providers the app's `requires` names and the plugins in its own `plugins` array. It now also: + + - **Mounts the always-on slate** `serve` mounts for every app, whether or not `requires` names it: `queue`, `job`, `cache`, `settings`, `email`, `storage`, `sms`, `sharing`, `messaging`, `analytics` and `package-registry`. Before, a slate provider was mounted only where a mounted plugin hard-depended on it, so an app's tests ran without the inbox delivery, the mail service, the file storage and the package registry its users' server has. + - **Builds each provider from the app's configuration**, by the same rule `serve` reads: the analytics service gets the app's `analyticsCubes` (the top level, then each package body's), the email service the app's `email` block with `OS_EMAIL_*` over it, the SMS service the `sms` block with `OS_SMS_*` over it, and the storage service the `OS_STORAGE_LOCAL_ROOT` root (`.objectstack/data/uploads` under the working directory by default). Before, each was built with its own defaults: no cubes, no mail configuration. So the app's cubes now reach the analytics registry; a cube the service refuses (one over an object the API does not serve) is warned and skipped by the service, under `bootStack` as under `serve`, and no boot fails on it. + - A caller's `extraPlugins`, `security` and `analytics` instances still take precedence by identity, and are not handed the configuration. A suite that needs a provider configured its own way (a temporary storage root, a mail transport) passes its instance in `extraPlugins`. + - Not mounted, because they are decisions about a server process rather than about the configuration: `serve`'s MCP endpoint (`OS_MCP_SERVER_ENABLED`) and its pinyin search (`OS_SEARCH_PINYIN_ENABLED`). A suite that exercises either passes the provider in `extraPlugins`. + + **What now fails that booted before (the narrowing).** A provider `bootStack` constructs and cannot build fails the boot, naming the capability token and the package — whether the app declared the token or the slate appended it. `serve` logs a slate provider it cannot build and boots on; a test boot does not, so an app's tests never pass on a composition its server does not run. In practice: + + - a configuration whose `email` or `sms` settings, or the `OS_EMAIL_*` / `OS_SMS_*` environment of the test process, name a transport that cannot deliver (`provider: 'smtp'` with no host, `resend` / `postmark` with no API key, an unknown provider tag) is refused with the reader's own remedy. Set `OS_EMAIL_PROVIDER=log` / `OS_SMS_PROVIDER=log` in the test environment if it is not meant to send mail or SMS. + + **`@objectstack/core`** exports the rule both boots read: `resolveServedCapabilities` (given the tokens a stack declares, which capability tokens a served boot mounts providers for — those, `email` for a declared `auth`, the host's own defaults, the always-on slate unless the preset is `minimal`, and `job` / `queue` ahead of what schedules background work) and `resolveCapabilityArgument` (what each provider is constructed with), with their `ServedCapabilities`, `CapabilityArgumentInput` and `CapabilityArgument` types; and `resolveStorageCapabilityArg`, `resolveStorageLocalRootEnv` and `StorageCapabilityArg`, moved here from the CLI. + + **`@objectstack/plugin-email`** exports `resolveEmailCapabilityArg`, `resolveDeploymentAppName` and `EmailCapabilityArg`, and **`@objectstack/service-sms`** exports `resolveSmsCapabilityArg` and `SmsCapabilityArg` — the readers that build each provider from the deployment's configuration, moved from the CLI to sit beside the transport vocabulary they refuse against. `resolveCapabilityArgument` reads each off the provider module it is handed. + + **`@objectstack/cli`**: `serve` reads both rules from `@objectstack/core` and composes exactly what it composed before. `os migrate plan` / `apply`'s declaration boot reads the same token rule for the providers it composes (the same set as before; a declared `auth` now places `email` right after the declared tokens, as `serve` does). `os verify` boots through `bootStack`, so it now mounts the always-on slate and builds the providers from the app's configuration. The moved functions stay importable from the `serve` command module. +- 97610a5: `bootStack` composes what `objectstack serve` composes from the same configuration: the providers the app's `requires` names, and the plugins in the app's own `plugins` array + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — one composition rule.** For one configuration, `bootStack(config, opts)` now mounts what `objectstack serve` mounts from it, so an app's tests boot the composition its users get: + + - **The providers the app's `requires` names**, by `serve`'s own reader and table (top-level `requires`, otherwise each package body's), plus the always-on providers a mounted plugin hard-depends on. An app no longer lists them in `extraPlugins` by hand. + - **The plugins in the app's own `plugins` array**, by `serve`'s rule for an entry: an instance is mounted as written, a plain bundle is wrapped into `AppPlugin`, a package name is loaded from the app's root (`hostRoot`). + - **The caller wins by identity.** An `extraPlugins` instance takes precedence over a `requires` provider it is (exact `name` or class name) and over an app plugin with the same `name`; that app plugin is not mounted. `security` and `analytics` instances take precedence over an app plugin of the same `name` the same way. + - **`hostRoot` is the app's root** in the two places `serve` uses the config's directory: the automation service's `packageRoot` (where a declarative connector's package-relative file ref is read) — now also when `automation: true` asks for the service — and the root a string `plugins` entry is resolved from. A suite that does not run from the app's directory passes it. + - **Offline CI.** An app whose `plugins` array wires the marketplace-facing `@objectstack/cloud-connection` plugins gets them mounted, pointed at `OS_CLOUD_URL` (by default the public catalog). Set `OS_CLOUD_URL=off` in the test environment, before the configuration module is imported, to keep the suite offline. + + **What now fails that booted before (the narrowing).** + + - **A second live boot of the same configuration object is refused** with `code: 'RESOURCE_CONFLICT'`, `status: 409`, and so is a copy (`{ ...config }`) that carries an app-plugin instance a live boot mounted: the instances in a `plugins` array are module-level, and two kernels must not share them. Live means until `stop()` resolves. Remedy: `stop()` the first stack before booting again; or share one boot with `bootStackOnce(config, opts)`; or, to keep two stacks of one app live at once, boot the second on a configuration built again (call its builder once more, or import a fresh module instance of it). A `{ ...config }` spread is not a configuration of its own: a live boot keeps references into the configuration's nested definitions. + - **An app `plugins` entry that cannot be loaded or registered fails the boot**, naming the entry (`plugins[i]`) and its remedy. `serve` logs such an entry and boots on; a test boot does not, so a plugin the app declares is never silently absent from its tests. + - **A provider or app plugin that refuses to start fails the boot** where the fixed plugin set never mounted it — for example a declarative connector whose package-relative file ref does not resolve from `hostRoot`. + + **`@objectstack/core`** exports the pieces both boots read: `CAPABILITY_PROVIDERS`, `CapabilitySpec`, `CapabilityIdentities` and `providesCapability` (the `requires` token → provider table and its exact identity match); `stackDeclaredCapabilities`, `resolveStackCollection`, `declaredPackageEntries`, `stackPackageBodies` and `collectFromPackageBodies` (the package-owned collection reader); and `materializeStackPlugin` with `StackPluginLoaders` (what a `plugins` entry becomes). + + **`@objectstack/cli`**: `os verify` boots the app anchored at the directory holding its config (`hostRoot`), as `serve` anchors it, so `os verify --app path/to/objectstack.config.ts` run from another directory reads the app's package-relative files (a declarative connector's spec, a string `plugins` entry) and resolves `--multi-tenant`'s organizations package from the app, not from the working directory. `serve` itself does not change: `Serve.CAPABILITY_PROVIDERS` and `Serve.providesCapability` are handles over the `@objectstack/core` declarations, the stack-collection readers are re-exported from there, and `serve`'s `plugins` loop reads the entry rule from there. +- 3089848: fix(core)!: a `gracefulShutdown: false` kernel no longer exits the process when its teardown times out + + Clause-②: no + + + + **BREAKING** for a host that builds `ObjectKernel` with `gracefulShutdown: false` and counted on the kernel to end the process when a teardown hung: a behaviour change, shipped as `minor` under the launch-window convention for breaking changes. + + - **What changes.** When `shutdown()` overruns `shutdownTimeout`, a kernel built with `gracefulShutdown: false` no longer calls `process.exit(1)`. It logs the timeout at `error`, marks itself `stopped`, and `shutdown()` resolves, so the host decides whether the process exits. The hung teardown is not cancelled: it keeps running in the background and holds whatever it has not released until it finishes or the process ends. + - **Why.** `gracefulShutdown: false` already meant that the host, not the kernel, handles the process signals, and the kernel lifecycle docs say such a kernel stays out of the process-management business. The timeout branch did not read the option. So a host that runs several `false` kernels in one process, one per environment, lost all of them when one environment's teardown hung. + - **Unchanged.** With `gracefulShutdown: true` (the default; the kernel installs its own SIGINT, SIGTERM and SIGQUIT listeners) a genuine timeout still logs `Shutdown timed out — forcing exit` and calls `process.exit(1)`. A teardown that throws instead of hanging still never exits the process, under either setting. `shutdown()` still never rejects, and no option, key or export is added. Every kernel `@objectstack/cli` builds takes the default, so its commands behave as before. + - **Who must act.** A host that sets `gracefulShutdown: false` (a server framework with its own signal handlers, or a host running one kernel per environment in one process) and relied on that exit to end a process whose teardown hung. Such a host already handles the signals itself; it now also ends the process itself. Call `process.exit()` in that host once `await kernel.shutdown()` returns, instead of waiting for the event loop to drain: `shutdown()` returns within `shutdownTimeout` whether or not the teardown finished, but a hung teardown can keep the loop alive after it. The timeout is reported in the kernel's `error` log line. +- db9cf80: feat(cli): `os migrate security-catalog-overlays` lists, and with `--apply` deletes, the environment-wide rows a v18 cold boot refuses — run it before the first v18 boot + + Clause-②: yes (widening) + + - **What it is for.** A v18 cold boot refuses to start when the environment catalog holds a permission-set or position name that a configured package also declares (ADR-0048 N.3). A deployment upgraded from 17.x can carry such rows: a set or position saved in the environment over a package's name before the packaged locks refused that, or over one of the platform security plugin's own sets (`member_default`, `admin_full_access` and the rest). The server cannot clear them, because it does not start. This step is the offline remedy. + - **What it lists.** Every active, environment-wide `sys_metadata` row (`organization_id IS NULL`, `state = 'active'`) of type `permission` or `position`, the legacy plurals `permissions` and `positions` included, whose name a configured package holds. That is the population the cold boot refuses: the list is computed from the engine's own reading of who holds a name, not from a copy of it. A draft row and an organization-scoped row are not loaded at boot, so they are never listed. Each row is shown with its stored type, its `package_id` binding and the package that holds its name. + - **How it gets there without the refusal.** It composes the deployment as `os serve` does: the host config's plugins, the application or the compiled artifact, and the security plugin behind `serve`'s auth gate. It runs the kernel's first phase for them only, and boots without reading `sys_metadata` back into the registry, so the cold-boot check meets nothing and the boot comes up. No server starts. The security plugin's shipped sets are package-held names only where `serve` composes that plugin, so the step reads the environment `serve` reads. With `OS_AUTH_SECRET` (or `AUTH_SECRET` / `BETTER_AUTH_SECRET`) set, or on a development boot, the plugin is composed and its sets are held, unless the `auth` tier is off. The step takes `os serve`'s `--preset` and `--dev` with `serve`'s meaning, read through the rules `serve` reads them by: `--preset` names the tier preset the auth tier falls back to, and `--dev` composes the config's `devPlugins` and makes the boot a development one (as does `NODE_ENV=development`). The report says which way it went. **Run the step with the flags and environment the deployment boots with.** + - **Preview by default.** The preview boots read-only, like `os migrate plan`. It writes nothing, creates no database file, and exits `1` when it lists a row: the next boot would be refused. `--apply` deletes the listed rows, after a `[y/N]` prompt or `--yes`, and prints one audit line per row: what was deleted, through which write path, or why not. It exits `0` when every listed row is gone. `--json` prints one document (`listed`, `deleted`, `failed`, `rows[]` with `outcome`, `securityPlugin` and `serveFlags`). `--force` gets past a busy SQLite file. `--database-url` / `$OS_DATABASE_URL` name the target. + - **How it deletes.** A row stored under `permission` or `position` goes through the metadata protocol's own delete, the one `DELETE /api/v1/meta/:type/:name` uses. That writes a `sys_metadata_history` tombstone and a `sys_metadata_audit` row, with actor `os migrate security-catalog-overlays`. A row stored under a legacy plural is out of that door's reach, so it goes through the `sys_metadata` repository beneath it. That delete is addressed by the row's stored type, name, `package_id` and checksum, and writes the same history tombstone. + - **The refusal names it.** The cold boot's `NAMESPACE_CONFLICT` message now points at `os migrate security-catalog-overlays` for the environment's rows, in place of "through the metadata API on a boot that leaves the package out of the configuration, or in the database". The envelope (`code`, `status`, `conflicts[]`) is unchanged. + - **What it never does.** It adopts nothing: `managed_by` and `package_id` are never rewritten, and no row is renamed. A row the environment needs under its own name must be re-created under a name no package holds. A host config that exists and cannot be loaded is refused before any row is read, because the held names would be incomplete. + - **New exports it is built on.** + - `@objectstack/objectql` exports `findPackageHeldSecurityCatalogNames(registry)`: every package-held permission-set and position name, with its holder packages. The cold-boot check now reads the same list. + - `@objectstack/core` exports the auth gate `os serve` and the step share. `resolvePlatformAuthComposition` answers whether the platform composes `AuthPlugin` and the security plugin beside it, or why not. With it come `resolveStackTiers`, `STACK_TIER_PRESETS`, `CAPABILITY_TO_TIER`, `resolveAuthSecret`, `isDevelopmentBoot`, `DEV_AUTH_SECRET_FALLBACK`, `stackSuppliesAuthPlugin`, `isHostKernelComposition` and the `PlatformAuthComposition` / `PlatformAuthSkipReason` types. `Serve.TIER_PRESETS` and `Serve.CAPABILITY_TO_TIER` are now handles over the core declarations, and `os serve` composes exactly what it composed before. + - `@objectstack/runtime`'s `createStandaloneStack` accepts `hydrateMetadataFromDb: false`, which skips the `sys_metadata` read-back. The default stays on. + - `@objectstack/spec` registers the ADR-0087 D3 entry `security-catalog-environment-overlay-refused` in its migration registry and `spec-changes.json`, so `os migrate meta --from 17` names the refusal and this step as its remedy. Its projection is the entry under protocol 18 in the upgrade guide (`docs/protocol-upgrade-guide.md`). +- 35ef501: An `update`-mode `validate()` preview judges the stored row merged with the patch, as the by-id update does, so an import dry run of a matched row admits the rows the import's update admits (#22445) + + Clause-②: yes + + The preview's accept set widens: rows it refused, and the write admits, are now admitted. No key, export or error code is added, removed or renamed. + + - **Before.** An `update`-mode preview (`engine.validate(object, rows, { mode: 'update' })`, `validateData`, and the import dry run of a row that matched a stored record) read no stored row, so the record its rules judged held only the patch. A `requiredWhen`, an option `visibleWhen` or a `validations[]` rule that reads a column the patch omits faulted there, and the preview refused the row with `rule_violation` / `unevaluable`, although the real update reads the stored row first and admits it. The refusal also said the omitted column was one "which this object does not declare", sending the author looking for a field that exists. + - **Now.** A row that carries its `id` (the address every update door already folds into the payload) is judged against the row that id names: the stored row is the rules' `previous`, and their record is the stored row merged with the patch, exactly as the by-id update judges it. A reference field a traversing rule reads is resolved from that merge too. The import dry run of a matched row sends the matched record's id this way, so nothing new crosses the protocol. + - **Read under the caller's access.** The engine's read door, under the caller's own context, decides whether there is a row to judge: a row the caller cannot read gets the verdict a missing row gets. The values judged are the row as stored, read the way the by-id update reads its prior row (so a formula column, which has no stored value, is judged as the update judges it). A stored column is judged only where the read door served this caller that very value. A column the read door hid, or served transformed (a partially masked field, a masked secret), is judged as empty, so the verdict never depends on a value the caller could not read in full. File fields are compared in their stored form, so a readable file reference is judged as the id it holds. Measured on `driver-sql` (SQLite) and `driver-memory`, a plain readable column (number, currency, percent, boolean, date, datetime, JSON, multiselect, select, text) reaches the rules as its stored value. + - **Unchanged.** A row with no `id`, or whose id names no row the caller can read, is judged on the patch alone, as before. `required` and value checks still look only at the supplied keys, matching a PATCH. A merge that really violates a rule is refused, as the write refuses it. The preview still runs no `readonlyWhen` or primary-key strip, so it can report fewer dropped fields than the update. + - **The refusal names the real cause.** Where a rule reads a column the object declares and the judged record does not hold it (a preview with no stored row), the refusal now says the value was not supplied and no stored row was read, instead of saying the object does not declare the column. A column the object really does not declare keeps the old sentence. + + `@objectstack/core`: the import runner's dry run passes the matched record's id with an update row. `@objectstack/spec`: the `ValidateDataRequest.mode` description and the `ValidateDataResponse` note say what an `update` preview now reads. + + The pending entry for the option-gate change in this release says an `update`-mode preview reads no stored row and refuses a cascade pick whose parent column the patch omits. That describes the preview before this change: with the row's `id`, the preview now reads the stored row and judges the pick as the write does. +- 874a38d: feat(core): the ADR-0069 auth-gate allow-list admits the ADR-0043 approval action route, `approvals/act`, as one exact route + + Clause-②: yes + + - **What the allow-list admits now.** `isAuthGateAllowlisted` answers `true` for the approval action route `approvals/act`, at the mount bases the gate already recognises (`/api/v1/approvals/act`, `/api/approvals/act`, and the dispatcher's prefix-stripped `/approvals/act`), unscoped or under one `/environments/` (or legacy `/projects/`) scope, with the query string and trailing slashes stripped as for every other entry. Nothing else moves: `/approvals/act/`, `/approvals/actx`, the approvals base and every `/approvals/requests/…` route stay gated. + - **Why this is not a loosening.** ADR-0043 authenticates the action page by its single-use token alone, and `plugin-approvals`' self-hosted mount (`GET` renders, `POST` redeems on the host app) has never passed through this gate. The entry gives the dispatcher's spelling of the same route the same token-only authentication, so a session held by an authentication policy (expired password, required MFA) is no longer refused there with that policy's `403` before the token is read. + - **Effect on a kernel whose dispatcher serves no `/approvals/act` domain.** A held session that reaches the route gets the dispatcher's `404 ROUTE_NOT_FOUND`, the answer every other caller already gets there, instead of the policy's `403`. Nothing on the REST surface is affected: no REST route serves `approvals/act`, and no shipped seam passes a path to `shouldDenyAnonymous`. +- c74d843: fix(core)!: an import's reference resolution asks the lookup TARGET its declared exposure before matching a cell (#22739) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `POST /api/v1/data/:object/import` and the async `POST /api/v1/data/:object/import/jobs` judge the ADDRESSED object's `enable` block. A lookup cell is resolved by matching its text against the lookup's TARGET object — an object nobody addressed. + + **FROM.** + + - For an administrator and a member alike, a cell was matched against a target whose declaration refuses the read on every data route (`enable.apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `list`): a cell naming an existing record stored its id, and a cell naming none answered `reference_not_found` — so the row report told the two apart. + - A field of the legacy type spelling `'reference'` (not a `FieldType`, so `ObjectSchema` refuses it, but still listed in the import, export and import-template doors' own type tables) took its target from the raw `reference` key: import matched its cells against that object and export expanded it. + + **TO.** + + - The runner asks the spec's one decision (`canServeApiOperation`) of the TARGET's `enable` block, for `list` — matching a cell is a predicate read over the target, the runtime `find` the spec maps to `list`. A target the decision does not serve answers every cell, naming a record or not (a pasted id included), `reference_not_found` — the answer a target the caller cannot read (no read permission, or a record its row scope hides) already gets. Nothing of the target is read. A target with no `enable` block, or a whitelist that grants `list`, resolves exactly as before. A declaration that cannot be read withholds the target too (fail-closed). + - A field is a reference on those doors exactly when it declares a `reference` string, as before, and its target is read through the spec's arbiter `referenceTargetOf`, which names none for a type outside `lookup` / `master_detail` / `user` / `tree`: such a `'reference'`-typed field's cell is stored as written, and export leaves its stored id. Every other non-reference type was never resolved by these doors and is unchanged. Measured over every git-tracked non-test source under `packages/` and `examples/` (`.ts`, `.js`, `.json`; fixtures excluded) at `0d326bfb12`: no shipped field declares `reference` on a non-reference type (0 object literals, 0 `Field.*` helper calls; the one literal pairing `type` with `reference` is a key-classification table, not a field), against a control of 30 reference-typed literals and 80 `Field.lookup` / `masterDetail` / `user` / `tree` calls; a planted literal and a planted helper call were both found. + + **Bindings.** + + - **Every caller of `runImport` whose protocol reads object declarations.** The decision takes no user, so an administrator, a member and a system context (the connector pull) are answered the same. The target's declaration is read through the protocol's `getMetaItem`, a new optional member of `ImportProtocolLike`; a protocol without it is not judged (plugin-auth's identity import carries the reader since #22800). + - **No new error code, no status change.** The refusal is the per-row `reference_not_found` the resolver already answers. + - **Which fields count as references does not widen.** A `user` field written without `reference` still has no target on these doors (its cell reaches the write as written), although the arbiter gives it `sys_user`; adopting that target would widen what the import accepts and is not this change. + + **Fix, if an import relied on the old answer:** the target's own declaration decides — grant `list` in its `enable.apiMethods`, or drop `enable.apiEnabled: false`, if the object is meant to be matched by import. + + **Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` (spec and qa excluded) and `examples/`: twelve objects refuse `list` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (its own import door already answers 404); the control count of lookups into `sys_user` is 75. So no shipped object's import answer changes. Deployed and cloud-held object definitions were NOT MEASURED. +- 7b0a9c9: feat(core,trigger-api): a timestamped `X-Objectstack-Signature` form with a five-minute tolerance window, accepted by the inbound flow hook beside the body-only form + + Clause-②: yes + + - **The new form.** `x-objectstack-signature: t=,v1=`, where the hex is the lowercase HMAC-SHA256 of `.` under the flow's secret. The timestamp is inside the signed material, so it cannot be changed without the secret. The inbound hook (`POST /api/v1/automation/hooks/:flowName/:hookId`) refuses the value once `t` is more than 300 seconds from the server's clock, in either direction, so a captured post stops verifying five minutes after it was signed. + - **Refusals.** A value that starts with `t=` is judged by the timestamped form alone. If it is malformed, carries the wrong HMAC, carries the body-only HMAC, or has a `t` outside the window, the answer is the same `401 INVALID_SIGNATURE` a bad signature gets, and nothing is enqueued. + - **Body-only signatures keep working.** `sha256=` is still accepted, so no existing sender breaks. It signs no time, so a captured post can be replayed, and it is now deprecated: the trigger logs one `warn` line per armed hook that names the flow and the hook, the first time that hook accepts a body-only post. + - **`@objectstack/core` exports.** `signHttpBodyAt(body, secret, timestampSeconds)` signs the new form. `verifyHttpSignature(body, secret, header)` verifies both forms and returns an `HttpSignatureVerdict` (`{ valid: true, scheme: 'v1' | 'body-only' }`, or `{ valid: false, reason }`). `HTTP_SIGNATURE_TOLERANCE_SECONDS` is `300`. They sit beside `signHttpBody` and `HTTP_SIGNATURE_HEADER` in the one definition of the scheme. `@objectstack/trigger-api`'s `verifySignature` keeps its signature and now verifies through it, so it accepts both forms. + - **Unchanged.** ObjectStack's own senders, the messaging HTTP outbox (webhooks) and the flow `http` node, still send the body-only form. Receivers outside ObjectStack verify exactly that today. + + **To adopt it as a sender.** Sign at send time, not at enqueue: `t = Math.floor(Date.now() / 1000)`, then send `t=,v1=.">` in `x-objectstack-signature`. From TypeScript, call `signHttpBodyAt(rawBody, secret, t)`. +- fe58bbc: feat(runtime,core): inbound automation hooks work on a kernel with no raw HTTP app — the dispatcher admits `POST /automation/hooks/:flowName/:hookId` with no session, through to the trigger's own signature check + + Clause-②: yes + + On a self-hosted server, `@objectstack/trigger-api` serves a flow's inbound hook by mounting `POST /api/v1/automation/hooks/:flowName/:hookId` on the raw Hono app, where a third-party sender (a payment provider, a code host) posts a signed event with no session. A hosted tenant kernel has no raw app, so on that shape the path belonged to the dispatcher's `/automation` domain, whose anonymous floor refused every sender `401 UNAUTHENTICATED`. + + **What widens: one route passes the anonymous floor and the ADR-0069 gate with no session.** `@objectstack/core`'s auth-gate allow-list gains its first parameterised row: exactly `POST /automation/hooks/:flowName/:hookId` in the dispatcher's spelling (four segments, optionally under one `/environments/:id` scope), and nothing else. `/automation/hooks`, `/automation/hooks/x`, `/automation/hooks/x/y/z`, every other method on the route and every other `/automation` path stay gated exactly as before. + + - `@objectstack/core`: `isAuthGateAllowlisted(path, method?)` and `evaluateAuthGate(user, path, method?)` take an optional method. The existing rows are exempt for every method, as before, so a caller that passes no method gets the answer it always got. The new row is exempt for `POST` only, and only when a method is passed. `shouldDenyAnonymous` now passes its `method` to the allow-list beside its `path`. New export: `matchInboundHookRoute(path, method)`, the one reading of the route that the row matches through, returning the two path segments raw. + - `@objectstack/runtime`: the `/automation` domain's anonymous floor now asks the allow-list with its path and method. On the very next statement it hands the admitted route to the request kernel's `trigger-api` service (`ITriggerApiService.handleInboundHook`), ahead of every other `/automation` gate and service probe. The request goes with its body unread and the two path parameters decoded, and the service's `Response` comes back as it is. The service verifies the HMAC signature, so a correctly signed post is accepted and a wrongly signed or unsigned one is refused by that service, with its own answer. The membership gate skips the route for `POST` only, so a signed-in caller is judged exactly as an anonymous one, as on the self-hosted mount. + - No `trigger-api` service registered, or one without `handleInboundHook`: `501 NOT_IMPLEMENTED`, naming which, never `ROUTE_NOT_FOUND`. A service that throws, or a request whose body a transport already read: a sanitised `500 INTERNAL_ERROR`, logged. + + Nothing changes on a self-hosted server: the trigger's raw-app mount still serves the path there and never reaches the dispatcher, and the dispatcher mounts nothing that answers it. +- c4e7fa5: feat(core): export `relationConditionSites`, the one walk that finds a filter's nested-relation conditions and each one's target object (#22777) + + `relationConditionSites(fields, filter)` returns every `{ : { : … } }` condition a filter holds, at the top level and under `$and` / `$or` / `$not`, with the reference field, the target object (resolved by the spec's `referenceTargetOf`) and the condition. An id comparand, an operator bag and a non-reference field are not conditions. + + It moved unchanged from `@objectstack/metadata-protocol`, where it was module-private, so that the MCP stdio data bridge (`@objectstack/mcp`) and the generic data door ask each condition's target its exposure over the same sites. `@objectstack/metadata-protocol` now imports it; its behaviour does not change. +- b7cd1af: fix(core)!: an import reads WHICH fields are references through the spec's arbiter, so a `user` field written without `reference` resolves against `sys_user` (#22785) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing and a change to published output), shipped as `minor` under the launch-window convention for breaking changes. Two populations are affected, both only for a `user` field written without `reference`: + + - an import row whose cell holds the id of a user the caller cannot see (a user outside the caller's organizations): stored before, `reference_not_found` now, as on a `Field.user` field; + - a downstream reader of that column in `GET /api/v1/data/:object/export`: the cell held the stored user id and now holds the user's name, as a `Field.user` column already does. + + A `user` field's target is fixed by its type: the spec's arbiter `referenceTargetOf` gives `{ type: 'user' }` the target `sys_user` whether or not `reference` is written (`Field.user()` writes it; hand-written JSON, a form or an AI author may not), and the data door's `$expand` already reads it that way. `buildFieldMetaMap`, the field map behind the import, export and import-template doors, counted a field as a reference only when it carried a `reference` string, so those doors disagreed with the arbiter for such a field. It now asks the arbiter both whether a field is a reference and what its target is. + + **Door by door, before and after.** Measured on a real boot, as an administrator and as a member, on a fixture object holding a `user` field written without `reference` beside a `Field.user` one; the connector pull through its executor over a stub protocol. + + - **Import: `POST /api/v1/data/:object/import`, `POST /api/v1/data/:object/import/jobs`, and a mapping's `connectorSource` pull (same runner).** + - FROM: a cell holding a user's email or name was written as the field's id, so the row failed `reference_not_found` ("no sys_user record has id …") and nothing was stored. + - TO: the cell is matched against `sys_user` exactly as on a `Field.user` field, so an email or a name stores the user's id. A pasted id of a user the caller can see still stores. A value matching no user still fails `reference_not_found`, now in the import's own words ("no record matches …"), and a name matching several users fails `reference_ambiguous`. + - `sys_user` is asked its declared exposure first, as every lookup target is: where its `enable` declaration refuses `list`, the cell is refused. + - **The one population that narrows, to `Field.user`'s answer.** A pasted id of a user the caller's row scope hides (a user outside the caller's organizations) used to store, since the write's own reference check runs elevated. It now fails `reference_not_found`, as it already did on a `Field.user` field. The data door's `POST /api/v1/data/:object` is unchanged and still accepts that id. + - **Export: `GET /api/v1/data/:object/export`.** FROM: the column carried the stored user id (JSON and CSV). TO: it is expanded like a `Field.user` column and renders the user's name, so an exported file imports back through the name. + - **Import template: `GET /api/v1/data/:object/export?template=true`.** FROM: the column's instructions named no target ("The name of a record, or its id."). TO: they name `sys_user` by its label ("The name of a User record, or its id."). + - **Unchanged:** + - `Field.user` and every field declaring `reference`. + - A `lookup`, `master_detail` or `tree` field without `reference`: the arbiter names no target for it. + - A non-reference type declaring `reference`: still no target. + - A present `reference` that is not a string: still no target, and no throw. + - plugin-auth's identity import, which reads `sys_user`'s own map. That map has no reference-typed field without `reference`, so it is identical. + + **Fix, if a pipeline relied on the old answer:** none is needed for one that wrote visible user ids. A pipeline that imported ids of users outside the caller's organizations runs as a caller who can see them, as a `Field.user` field already required. A reader that needs the stored user id takes it from the record read (`GET /api/v1/data/:object`, without `expand`), not from the export. +- 51f36e7: The action activation door switches an action no package ships, its row names no package, and a ledger write it refuses answers with the status its code means + + Clause-②: yes (widening) + + - **The door accepts an action the environment authored** (`@objectstack/runtime`). `POST /api/v1/actions/_activation/:object/:action` declares that it does not require the action to be packaged. FROM: an object-less action saved through the metadata door (`PUT /api/v1/meta/action/:name`), or one bound to an object no package ships, was refused `503` with `{ code: 'SERVICE_UNAVAILABLE' }` and the store's sentence about an empty package id, and nothing was written. TO: it is switched like a packaged one, `200`, its row carries no package (`package_id` is `null`), and dispatch then refuses it `409 ACTION_DISABLED` like any switched-off action. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared action `404`, an ambiguous name `409`, an engine with no ledger `501`). + - **An action is credited only to the package that ships it** (`@objectstack/runtime`). FROM: an action with no package of its own was credited to its route object's package, so an action the environment saved against a packaged object wrote that object's package into its row. TO: the row names the action's own package; for an action declared inside its object's `actions`, the object's package; otherwise no package. A `sys_metadata` placeholder or a tenant-authored action's package binding is not a shipping package. Rows of shipped actions are unchanged, including the platform's own actions declared inside their objects. No reader decides anything from `package_id`, so nothing that is switched on or off changes. + - **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, an error that declares its own pair keeps it (no ledger attached: `503 SERVICE_UNAVAILABLE`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. + - **The in-memory activation store refuses what the durable store refuses** (`@objectstack/core`). FROM: `InMemoryMetadataActivationStore.setActive` (the base of `InMemoryActionActivationStore` and `InMemoryFlowActivationStore`) accepted `packageId: ''`, which `ObjectStoreMetadataActivationStore.setActive` refuses. TO: it refuses `packageId: ''` with the same `TypeError` and the same sentence, before it keeps anything. If a test passes `''`, omit the key instead. +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. +- ed03834: feat(core): `emailTemplateDefinitionFromRow` — a `sys_email_template` row as the `email_template` definition it carries (ADR-0131 D10, ruling C on §6 Q1) + + Clause-②: yes (widening) + + A new pure export beside `permissionSetDefinitionFromRow` and `positionDefinitionFromRow`: one row in, one body out, with no engine and no I/O. Its one import is the spec's floor-locale constant. The upgrade ceremony (`os migrate organization-ownership --apply`) uses it to promote a customized or admin-authored template row to an environment `email_template` item through the metadata door before the row is deleted. It runs only in that ceremony, never at boot. + + The mapping: `body_html` → `bodyHtml`, `body_text` → `bodyText`, `from_address` / `from_name` → `fromOverride`, `reply_to` → `replyTo`, `variables_json` → `variables`, and `active` keeps its stored meaning. `label` falls back to the name and `locale` to `EMAIL_TEMPLATE_FLOOR_LOCALE` (`en-US`). The retired `category` / `is_system` columns and every provenance or state column are left out. It does not validate; the door does. + + Nothing existing changes. +- e1deaef: feat(core): two pure row → definition mappers for the ADR-0131 upgrade ceremony (`permissionSetDefinitionFromRow`, `positionDefinitionFromRow`) + + Clause-②: yes (widening) + + - **What is new.** `permissionSetDefinitionFromRow(row)` turns one `sys_permission_set` row into the permission-set definition it carries, and `positionDefinitionFromRow(row)` turns one `sys_position` row into the position definition it carries. The row types `PermissionSetRowForDefinition` and `PositionRowForDefinition` are exported with them. Both sit beside `convertPositionBindingRows`. The upgrade ceremony (`os migrate`) is to apply them, to save Setup-authored rows through the metadata door. ⛔ Neither runs at boot. + - **The permission-set mapping.** The JSON-text facet columns become the camelCase body keys: `object_permissions` → `objects`, `field_permissions` → `fields`, `system_permissions` → `systemPermissions`, `row_level_security` → `rowLevelSecurity`, `tab_permissions` → `tabPermissions`, and `admin_scope` → `adminScope` only when the column is truthy and reads to a truthy value. `label` falls back to the name only when the column is `null` or absent; an empty-string label is kept (unlike the position rule below). A facet column that is `null`, absent, an empty string, JSON `null` or unparseable text answers its default; a facet a driver already parsed is kept as it is. Only the definition columns are read, so `active`, `managed_by`, `customized` and the timestamps never enter the body. The body is then filtered to the keys `PermissionSetSchema` declares. + - **The position mapping.** `{ name, label, description, delegatable }`: `label` falls back to the name unless it is a non-empty string (so `''` is replaced by the name). `description` travels only when it is a non-empty string: `null`, `''`, an absent column and a non-string are absent, and a whitespace-only string travels as stored. `delegatable` is `true` only for a stored `true`, `1`, `'1'` or `'true'`. `active` and `is_default` are row state and never travel. + - **No behaviour changes.** Both functions are pure (one row in, one body out). They produce the same output as the security plugin's own copies, `permissionSetBodyFromRow` and `positionBodyFromRow`, and nothing calls them yet. Neither validates: the metadata door still refuses a body or a name it does not accept. +- f5ef054: feat(core): export `resolveRequestExecutionContext`, the one composition that turns an inbound request into the caller's `ExecutionContext` + + Clause-②: yes (widening) + + - **New export from `@objectstack/core`'s `.` entry.** `resolveRequestExecutionContext(input)` reads the session through the auth service (the in-process rule: a request carrying a session cookie reads without renewal), classifies the tenancy posture with `classifyAdmissionTenancyPosture`, resolves the principal and its grants with `resolveAuthzContext`, resolves the reference localization, decides the ADR-0069 authentication-policy gate and assembles the envelope with `assembleExecutionContext` or `assembleExecutionContextOrGuest`. Its input type `RequestExecutionContextInput` and the types `RequestAuthService`, `EstablishedRequestSession` and `RequestAdmission` are exported with it. + - **Two choices, made once.** `anonymous: 'fail-closed'` yields no context for a request that resolves no principal (the door answers 401); `anonymous: 'guest'` serves it as the guest principal with the guest anchor's grants. `authGate: 'withhold' | 'resolved' | 'reread-when-active'` says whether the gate's posture rides the envelope, and how it is read. + - **Every other difference between doors is a named, required parameter:** `credentials`, `sessionApi`, `session`, `tenancy`, `localization`, `requestLocale`, `accessToken`, `oauth` and `onAdmission`. A door with no value passes `undefined` or the withholding literal, so no door can drop an input by omitting it. + - **First caller.** The runtime dispatcher's identity door (`@objectstack/runtime`'s `resolveExecutionContext`) now calls it. What that door answers is unchanged: the same guest envelope, the same 503 for a tenancy service that failed to build, the same OAuth agent principal on `/mcp`. + - **Faults propagate.** It catches nothing around the resolver: an unreadable permission store or tenancy service (`AuthzStoreUnavailableError`, 503) and an API key its own lifecycle refuses (`ApiKeyLifecycleRefusalError`, 403) reach the caller, which answers them at its own net. +- 37510aa: feat(security): the guest's grants channel — an anonymous request at a guest-entry door holds the sets bound to the `guest` anchor, in the one organization the deployment resolves for it (ADR-0138 E1, D3/D4) + + ADR-0087 disposition: none. No authorable key, accept set or stored shape moves: no spec schema, metadata type, stored row or published response field is added, removed, renamed or re-shaped. A declared binding that was not enforced becomes enforced (ADR-0090 D9, as ADR-0138 D3 amends it). + + **The bindings that start granting.** On the reference apps, none: `app-showcase`, `app-crm`, `app-multi-package` and `app-todo` declare no definition of the `guest` anchor that names a set. `app-showcase` ships `showcase_guest_portal` as a capability an administrator may bind and binds it to no anchor (its `client_portal_user` position is an ordinary position), and `app-crm`'s `guest_portal` is the public form doors' set, which keeps its own posture. No reference app declares a permission set named `guest`, so no grant stops either. A set starts granting only where a deployment holds an environment definition of the `guest` position that names it. + + What changes: + + - **`@objectstack/core`** — the anonymous branch of `resolveAuthzContext` resolves the guest's grants on a face that takes the explicit guest entry (the new `guestEntry` input). It reads the sets the `guest` position's definition names, through the same position-bound read every position uses, and the activation ledger as for any held set. It resolves them only in the organization the guest entry's organization step derives (`resolveGuestOrganization`), which asks ADR-0131 D9's predicate and no second spelling of it. With nothing bound, the guest holds no set and no read is issued. The predicate itself, `resolveSystemWriteOrganization`, moves to `@objectstack/core` so both of its askers share it. New exports: `resolveGuestOrganization`, `resolveSystemWriteOrganization` (with `SystemWriteOrganizationDecision` and `SystemWriteRefusalReason`), and `GuestOrganizationUnresolvedError` with `isGuestOrganizationUnresolvedError` and its constants. + - **`@objectstack/objectql`** — `resolveSystemWriteOrganization` and its two types are re-exported from `@objectstack/core` as the same bindings. Nothing a caller imports changes. + - **`@objectstack/plugin-security`** — the set resolution treats the guest's sets as any requested set, and no longer folds the guest's position into the request. A permission set that merely shares the anchor's name no longer reaches an anonymous caller, and the baseline and `everyone` never did. At the CRUD gate, a guest that reaches a data operation acting in no organization, on a deployment where the organization step refuses, is refused before the operation runs. + - **`@objectstack/plugin-sharing`** — the read filter answers deny-all for the guest on every object that record sharing scopes, before the sharing-model branch: the guest reaches rows only through share links, never through the object's sharing model (ADR-0138 D3 item 6, as the maintainer's ruling C amends it). `externalSharingModel` is still not read for any caller. Share links are redeemed at their own door and are unchanged. + - **`@objectstack/runtime`** — the runtime face, which serves the guest, asks for the guest's grants. + + What an anonymous caller sees at an `authRequired: false` endpoint of `type: 'object_operation'`: + + - **One organization, a set bound to `guest`:** the operation runs as the guest, under that set, in that organization. A read the set grants is admitted and returns no row, whatever the object's sharing model (`private`, `public_read`, `public_read_write` or `controlled_by_parent`): the guest reaches a record only through a share link. A create the set grants lands in that organization with no owner. + - **Nothing bound:** `403 PERMISSION_DENIED`, as before. + - **A walled deployment, several organizations, or none:** `501 NOT_IMPLEMENTED`, naming the missing site binding (ADR-0138 D5), where it used to be `403 PERMISSION_DENIED` with nothing granted. Nothing that was served stops being served. The operator's log names the reason once. + + Unchanged: share links, which still serve an anonymous caller the one record they name, with `publicSharing.eligibility` checked at the mint and at every redemption; the public form doors, which keep their own posture and organization; the flow door, which runs under the flow's own declared posture; every surface that answers an anonymous request `401`; and every signed-in caller. + + Level: `minor` for `@objectstack/core`, because new exported symbols widen its published index; `patch` for the other four, whose exports do not move. +- 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. +- e246f06: feat(plugin-auth,platform-objects,core)!: an API key an administrator's forced password reset revoked is restored by a platform administrator only; its owner creates a new key (#23993) + + **BREAKING** (an accept-set narrowing at the `sys_api_key` write door), shipped as `minor` under the launch-window convention for breaking changes. For an operator: a key revoked by `POST /api/v1/auth/admin/set-user-password` (the forced reset, which revokes the user's keys by default) can no longer be restored by its owner; a platform administrator restores it with the Restore API Key action, or the owner creates a new key. For an integrator: `PATCH /api/v1/data/sys_api_key/{id}` with `{ revoked: false }` on such a key now answers `403 PERMISSION_DENIED` for its owner, where it answered `200`; and `sys_api_key` gains a readonly `revoked_reason` column. + + Clause-②: yes (narrowing) + + What widens: `sys_api_key` declares one more field, `revoked_reason`. What narrows: the owner's restore of a key whose `revoked_reason` is `admin_password_reset` is refused, where it was admitted. + + + + #23287 ruling C′ makes the forced reset revoke the user's API keys by default, for a suspected compromise: a key that outlives the reset is the hole the reset was meant to close. Its changeset names who acts afterwards: a platform administrator restores such a key, or the owner mints a replacement. The owner could nevertheless restore it through the same door as any revoked key, because the row recorded no reason. Now it does. + + - **`sys_api_key.revoked_reason` (`@objectstack/platform-objects`).** A readonly `select` whose one value is `admin_password_reset`, set when the forced reset revoked the key and cleared when the key is restored. Empty for a key revoked from the API Keys list. Translated in `zh-CN`, `ja-JP` and `es-ES`. + - **The stamp (`@objectstack/plugin-auth`).** The forced reset's revocation writes `revoked_reason: admin_password_reset` beside `revoked: true`, the one place the reason is set. A key already revoked before the reset is not written, so it keeps no reason. + - **The restore guard (`@objectstack/plugin-auth`).** One `beforeUpdate` hook on `sys_api_key`, registered after the ADR-0092 D2 identity write guard. A user-context restore of a key carrying the reason is refused `403 PERMISSION_DENIED` unless the caller has platform-administrator standing (the ADR-0068 D2 rung); the refusal names both roads. Every restore it admits, the administrator's included, clears the reason. Revoking an already-revoked key leaves the reason in place, and the column is not on the D2 whitelist, so no data-API write can clear it. On a multi-row update every row is judged by its own stored state. System-context writes (the mint, the last-used stamp, the forced reset) are never refused. + - **The key door's message (`@objectstack/core`).** `API_KEY_REVOKED` for a key the forced reset revoked now says only a platform administrator can restore it; any other revoked key keeps today's sentence. Code, status and every carried field are unchanged. + + What changes for you: + + - **Refused now:** the owner restoring a key the forced reset revoked: `403 PERMISSION_DENIED`, naming the platform administrator and the new-key road. The administrator's restore works as before and clears the reason. + - **Unchanged:** revoking and restoring a key from the API Keys list, by its owner or an administrator; a forced reset with `revokeSessionsAndKeys: false`, which revokes nothing. + - **Keys revoked by a forced reset before this release** carry no reason, so their owner can still restore them. Such keys exist only where `18.0.0-next.2` or a `main` build since 2026-10-14 ran a forced reset; nothing backfills them, because the reset's audit row records counts, not key ids. +- 2ac0616: feat(core): export the emptiness rule a `required` declaration is judged by — `isEmptyForRequired(def, value)` and `isMissing(value)` + + Clause-②: yes (widening) + + - **New exports from `@objectstack/core`'s `.` entry.** `isMissing(value)` is `true` for `undefined`, `null`, and a string that is empty or only whitespace. `isEmptyForRequired(def, value)` is `isMissing`, plus an empty list `[]` on a field whose DECLARED value is a set (`@objectstack/spec/data`'s `isMultiValueField`: `tags`, `multiselect`, `checkboxes`, or a `select` / `radio` / `lookup` / `user` / `file` / `image` field flagged `multiple: true`). `def` is a `ValueShapeFieldDef` (`{ type, multiple? }`). A structured-JSON field's `[]` is a document, not an empty value. + - **Why it moved.** It was private to `@objectstack/objectql`'s record validator, where the object-level `required` check runs. `@objectstack/rest`'s anonymous public-form submit route now judges a FORM's `required` with it, and rest does not depend on the engine at runtime. So the one rule lives in the package both depend on, and the two doors call the same field missing. + - **Unchanged:** the rule itself, byte for byte — the engine's object-level `required` refusal answers exactly what it did. +- 62e2b6d: fix(core)!: an API key whose `sys_api_key` read fails is answered as the store outage (`503 SERVICE_UNAVAILABLE`), never as "no key", so the request no longer falls on to a cookie sent beside the key + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `resolveApiKeyAdmission` (and `resolveApiKeyPrincipal` and `resolveAuthzContext`, which call it) read the key's row in a bare `catch` that answered "this request carries no key", and logged nothing. Two wrong answers followed on every door that admits API keys: + + - **A key-only caller got `401 UNAUTHENTICATED`** during the outage, where a session caller got `503 SERVICE_UNAVAILABLE` for the same fault. + - **A cookie sent beside the key answered for the request.** "No key" sent the request on to the session arm, so a request that led with a key was served as the cookie's user. Measured on the REST data door and the runtime dispatcher (served as the cookie's user) and on the settings door (`200`). + + The read now raises the branded `AuthzStoreUnavailableError` (object `sys_api_key`, the driver's error kept as `cause`), the answer the same function already gave when its lifecycle-policy or owner-posture source failed. Every door relays it as `503 SERVICE_UNAVAILABLE`, and the session is never read in the key's place. + + - **Unchanged.** A `sys_api_key` table that was never provisioned is still "no key": no key can exist there. This is the boundary `resolveAuthzContext` already draws for its permission reads (the shared `isMissingTableError`). No key header, no engine, no matching row and a row with no `user_id` also still answer "no key". + + What to do. Nothing, unless a client treated `401` during a database outage as "the key is bad": it now sees `503` and should retry, not rotate the key. + +### Patch Changes + +- 5a871e4: perf(core): an authenticated request resolves its grants in one round fewer, and a write to an unrelated object no longer retires the localization cache + + Clause-②: no (no accept set, served body or authz answer moves: fewer queries and rounds for the same answer; owes the seat's review of record before the queue, at `CONTRACT_REVIEW_TIER` by the seat's choice, since it changes when authz and localization caches are trusted) + + - **The Default Organization read and the activation-ledger read are one wave.** `resolveUserAuthzGrants` read `sys_organization` (by slug) and then `sys_metadata_activation`, each awaited after the other. Both depend only on the first wave of reads, so they now run together. While the organization read is still open, the ledger is asked about every permission-set name that may apply. In one case that adds the name `admin_full_access` to the ledger's `name IN` list: an organization-owned `admin_full_access` grant that turns out not to anchor platform standing. The envelope is unchanged in every case: positions, permissions, `org_user_ids`, `accessible_org_ids` and posture. The organization read's filter is unchanged, and so are the organization-less grant readings. + - **The localization success cache is retired only by a write that can change its answer.** That is a write to `sys_setting` or `sys_platform_setting`, observed after it settles by an engine middleware. A non-`write` epoch reason (a peer node's `remote` hint, `metadata`, `manual`), a `localization` settings change or `OS_LOCALIZATION_CACHE_TTL_MS` also retires it. Before, any engine write to any object retired it, so on an idle boot the outbox pollers' claim updates made warm requests read the settings again. A success is cached only on an engine that has both the write epoch and `registerMiddleware`. With `OS_LOCALIZATION_CACHE_TTL_MS=0`, nothing is registered on the engine. The failure memo for a missing `sys_setting` table is unchanged: it is retired only by its TTL. +- 9a7838e: perf(core): an administrator whose `admin_full_access` grant belongs to the Default Organization no longer pays a `sys_organization` read on every authenticated request + + Clause-②: no (a per-engine memo of an unchanged read: no accept set, standing verdict or served body moves; owes the seat's review of record before the queue, at `CONTRACT_REVIEW_TIER` because it is the platform-standing anchor) + + - **The Default Organization is read once per engine, not once per request.** Under `single`, `resolveUserAuthzGrants` asks for the Default Organization whenever the principal holds an organization-owned `admin_full_access` grant: one `sys_organization` read, or two when the only organization is not slugged `default`. The answer is now kept per engine across requests. On a booted composition the warm `get-session` costs 8 statements and a data read 10, the same as for an organization-less grant. Before, it cost 9 and 11, or 10 and 12. + - **What retires it.** Any `sys_organization` write through the engine (insert, update, delete), observed after it settles. With the `authz.invalidated` bridge attached (the grants cache on, `OS_AUTHZ_GRANTS_CACHE_TTL_MS` above 0, and a cross-process cluster driver), a peer replica's write arrives as a `remote` epoch bump and retires it; every other non-`write` bump (`metadata`, `manual`) retires it too. A 30-second bound covers what no seam sees: a peer's write with no bridge attached, which on the shipped default (the grants cache off) is every peer's write, so there the bound is the only cover for it; a write that started before the engine's observer registered; and a write inside a transaction that has not committed yet. A Default Organization that is deleted and recreated, or a `default` slug moved to another organization, is read again on the next request. + - **A failed read is not kept.** A read that fails with the authorization store unavailable propagates as before, and the next request reads again. "No Default Organization" (no organization, or several and none `default`) is a real answer and is kept. + - **Unchanged:** the two reads, their filters and limits, and the platform-standing predicate the answer feeds. An engine without the write epoch and `registerMiddleware`, or a registry that does not hold `sys_organization`, keeps reading every time. +- 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`. +- e9a1f5c: fix(core): a stopped `ObjectKernel` removes its SIGINT/SIGTERM/SIGQUIT listeners and never exits the process + + Clause-②: no + + - **What was wrong.** With `gracefulShutdown: true` (the default) the constructor added one listener per signal and kept no handle, so `shutdown()` never removed them. Every kernel a process built left three listeners behind, and each one called `process.exit(0)` on the next signal. A process that had built and stopped five kernels and still ran a sixth exited six times on one SIGTERM, and the first exit landed before the live kernel had drained. Eleven built-and-stopped kernels also tripped Node's `MaxListenersExceededWarning`. + - **What a kernel does now.** It holds its listeners from construction until it reaches `stopped`, and removes them at that point, whichever way it got there: `shutdown()` completing, the shutdown timeout, or a failed `bootstrap()`. A stopped kernel has no listener, so it cannot handle a signal. While a kernel is `stopping` its listener stays installed, so a repeated signal is absorbed and does not trigger Node's default action mid-drain. + - **Who exits the process.** Only the handler whose signal started the drain, and only once. A signal that arrives while the host is already stopping the kernel through `shutdown()` is logged and left to the host. The kernel no longer exits the process in that case. + - **Unchanged.** A running kernel still drains and exits with code 0 on SIGINT, SIGTERM or SIGQUIT (code 1 if `shutdown()` throws). `gracefulShutdown: false` still installs nothing. With `gracefulShutdown: true`, a genuine shutdown timeout still calls `process.exit(1)`; a `gracefulShutdown: false` kernel no longer exits (#22335). +- 91f46d0: An import row answers a sandboxed hook's refusal in the hook's own words, not the sandbox debug wrapper + + Clause-②: no + + When a hook body refused a row during `POST /api/v1/data/:object/import` (or the async `/import/jobs` job), the row's `error` read `hook 'NAME' threw: Error: SENTENCE`, while `POST /api/v1/data/:object` and `/createMany` answered the same refusal as `SENTENCE`. The import runner now reads the row's sentence the way those routes do: the hook's sentence, unchanged, with any `code` the body declared still on the row. A hook body that crashes (for example with a `TypeError`) is not a refusal, and its row reads as it did before. +- d7b26df: An import row for a sandboxed hook body that crashed, or that refused with a declared 5xx status, reads as a server fault instead of relaying the fault's text + + Clause-②: no + + When a hook body crashed during `POST /api/v1/data/:object/import` (or the async `/import/jobs` job), the row's `error` read `hook 'NAME' threw: TypeError: …`, and a hook refusal that declared a 5xx status relayed its own message. `POST /api/v1/data/:object` and `/createMany` answer both as server faults and withhold that text. The row now carries the same sentence those routes answer (`Internal server error`), and its `code` is unchanged: `IMPORT_ROW_FAILED` for a crash, the declared code for a declared 5xx. A hook refusal with no status or a 4xx status still reads in the hook's own words, and a database error's row text is unchanged. +- 6d5191e: An import row for the sandbox's own fault reads the create door's fault sentence, not the runner's diagnostic text (#22741) + + Clause-②: yes (widening) + + The QuickJS runner raises some `SandboxError`s itself: a hook or action body that runs past its CPU budget or past the wall-clock ceiling, calls a capability it was not granted, or meets a failed install or marshal. These carry no `innerMessage`. `POST /api/v1/data/:object` and `/createMany` answer them `500 INTERNAL_ERROR` `Internal server error`. The sync `/import` row and the `/import/jobs` stored row answered the runner's text instead, for example `hook 'guard' exceeded CPU budget of 50ms (after 3 pump iterations)`. + + - **`@objectstack/types`** exports `isSandboxOwnFault(error)`. It is `true` for an error whose `name` is `'SandboxError'` and whose `innerMessage` is not a non-empty string, which is how the runner marks the faults it raises itself. A body's refusal or crash carries `innerMessage` and answers `false`. + - **`@objectstack/core`**'s import row applies the rule a crashed body already gets: when the sandbox raised the error and the create door answers it with a 5xx, the row's `error` is the door's sentence. The row's `code` is unchanged (`IMPORT_ROW_FAILED`). A driver error, and an error a code-defined hook threw, keep the row text they had. +- 87e902d: feat(objectql): the engine composes a `ReadableIds` leaf at its executor boundary, on the final query immediately before the driver call, and refuses it by name wherever it cannot (ADR-0139 D2, D5) + + Clause-②: no + + - **Composed.** On `find`, `findOne`, `count` and `aggregate`, after the middleware chain and the `beforeFind` hooks, every `{ FIELD: { $in: ReadableIds.of(P) } }` in `where` is replaced by `ReadableIds.compose(leaf, scope)`, where `scope` is the registered security service's `getReadFilter(P, caller)`, marked `'policy'` (`undefined` composes with no row restriction). The comparand becomes `$in: []`, which matches no row, when: + - `canReadObject(P, caller)` is false or throws (a service without `canReadObject` is asked `explain`, and only `allowed: true` admits); + - `getReadFilter` answers `RLS_DENY_FILTER` (by identity) or throws; + - no security service is registered, or the registered one cannot answer both halves (the engine reads the kernel's `security` service through `ObjectQLPlugin` when a leaf is composed, so an engine used without that plugin has none); + - the registry holds no object `P`. + + Each parent object is asked once per read, and nothing is kept across reads. A leaf that a middleware or a `beforeFind` hook writes is composed the same way. + - **Refused, `INTERNAL_ERROR` / 500, before any driver is asked:** + - on `update` and `delete`: the caller's leaf at the comparand door, a middleware's at the executor's entry, before the prior-row reads; + - in `having` and in an aggregation's own `filter`; + - anywhere but a `$in` comparand, including a leaf the array form of `where` lowers into another position; + - a composed leaf the engine did not compose, written by a middleware or a hook (the caller's own, as a `$in` comparand, stays the comparand door's `INVALID_FILTER` / 400); + - ADR-0139 D5's three classes: a delegated (on-behalf-of) caller, a parent with an object-scoped read middleware, and a parent bound to another datasource than the object read. + - **Before:** the comparand door refused every `ReadableIds` leaf `INTERNAL_ERROR` / 500 ("this engine does not compose that leaf yet"), and a leaf that a middleware or a hook wrote reached the driver unrefused. + - **`@objectstack/core`:** `resolveFilterTokens` returns a `ReadableIds` leaf by reference. When the filter also held a placeholder, it used to rebuild the leaf as a plain `{ object }` and drop its brand. + - **Not yet:** no driver evaluates the composed leaf (ADR-0139 E3 and E4), and no code in the platform writes a `ReadableIds` leaf today. +- b4d573a: fix(spec,core): the rolling `last_7_days`, `last_30_days` and `last_90_days` presets close on today, at the spec's prescription table, the save door's refusal and the analytics preset arm + + Clause-②: no (no accept set widens: the three rolling windows gain the inclusive `{today}` end every calendar preset already has) + + The three rolling windows were the open arm of `DATE_RANGE_PRESET_MACRO_WINDOWS`: `['{30_days_ago}', null]`, prescribed as `{ $gte: '{30_days_ago}' }`. A `$gte`-only window has no upper bound, so on a column that carries future dates (a close date, a due date) "last 30 days" also counted every later day. Measured on a `date` field with one row dated today−5 and one dated today+5: that window counted 2, while the console's preset lowering and the analytics preset arm counted 1. + + - `DATE_RANGE_PRESET_MACRO_WINDOWS` now closes every rolling window on `{today}`: `last_7_days` is `['{7_days_ago}', '{today}']`, and `last_30_days` and `last_90_days` follow the same shape. That is the inclusive last-day end every calendar preset already uses, and the extent the console's lowering already selects. The table's declared type narrows to match, `readonly [start: string, end: string]` (it was `end: string | null`). A reader that compared `end` with `null` still compiles; that branch can no longer be taken. + - The save door's refusal of a bare preset comparand (`bareDateRangePresetComparandMessage`, and the `filter-preset-comparand` lint verdict that quotes it) now prescribes the closed window. For `last_30_days` it says: write `{ $between: ['{30_days_ago}', '{today}'] }`, where it used to say `{ $gte: '{30_days_ago}' }`. + - `@objectstack/core`'s `resolveAnalyticsDateRangePreset`, the preset arm behind `timeDimensions[].dateRange`, selects the same extent. A rolling window now stops before tomorrow begins in the reference timezone, exactly as `today` does, with `endExclusive: true`. It used to end at the reference instant, so it dropped rows stamped later today. Its ends keep this module's own "the day the window stops before" spelling (`tomorrow`). Every preset this resolver returns is now `endExclusive: true`, and a caller's explicit `[a, b]` window keeps its inclusive reading, as before. + - The semantic migration entry `filter-preset-ordering-comparand-refused`, which `spec-changes.json` and `os migrate meta` carry, now names `{ $between: ["{30_days_ago}", "{today}"] }` as the window for `last_30_days` in its replacement, where it named `{ $gte: "{30_days_ago}" }`. + + If you followed the old refusal and authored `{ $gte: '{N_days_ago}' }` for a rolling preset, add `$lte: '{today}'`, or write the `$between` pair above, to stop counting future-dated rows. +- 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. +- 4ffdebe: Clause-②: no + + A call with a revoked API key no longer hears "a revoked key never works again". The key row's own Restore API Key action clears the flag and the same key works again at once, so the `403 API_KEY_REVOKED` message now says the key was revoked, that its owner or a platform administrator can restore it (the Restore API Key action on the API Keys list), and otherwise to create a new key (`POST /api/v1/keys`, or the API Keys page) and replace this one wherever it is configured. The code, the status, the fields the refusal carries and the expired and inactive messages are unchanged. The error catalog's `API_KEY_REVOKED` Fix line says the same. +- b036ac5: feat(spec): `AuthSessionApi.getSession`'s result declares the fields its in-process readers read + + Clause-②: yes (widening) + + - **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains five optional members**, each declared for a named reader and typed as that reader takes it: `user.email` and `session.id` / `session.token` (read by `resolveAuthzContext`'s session arm in `@objectstack/core`), `user.authGate` as `unknown` (the ADR-0069 posture, which `normalizeAuthGate` validates against the closed posture vocabulary), and `session.impersonatedBy` as `string | null` (read by the print door's `impersonatorOf` in `@objectstack/service-print`). Before this, those reads went through `any` or a cast, so a field better-auth dropped or renamed compiled and read `undefined`. For `impersonatedBy`, that would have silently turned off the impersonation refusal. Every member is optional, so every implementer and caller typechecks unchanged. + - **`@objectstack/core`**: `resolveAuthzContext`'s session arm reads the session through the declared type instead of `any`. `ResolveAuthzInput.getSession`'s own type is unchanged. Runtime behaviour is unchanged. + - **`@objectstack/service-print`**: `impersonatorOf` reads `session.impersonatedBy` through the declared type instead of a cast. Runtime behaviour is unchanged. +- c7a3234: perf(core,plugin-auth): an authenticated request resolves its caller's grants once, not twice + + Clause-②: no + + `resolveAuthzContext` learns who the caller is from the transport's `getSession`. Against `@objectstack/plugin-auth` that is better-auth's `getSession`, whose `customSession` hook resolves the principal's grants for the session payload's `positions[]` and `isPlatformAdmin`; `resolveAuthzContext` then resolved the same grants again, with the same arguments, for the request's envelope. Every authenticated request on a door that resolves identity through `resolveAuthzContext` with a better-auth session read (the runtime dispatcher and the REST server among them) paid every grant read twice: eight reads per resolution for a caller in an organization. + + `resolveAuthzContext` now runs inside a request-scoped grants memo. A resolution that already completed inside the same call, with the same user, organization and seeds, is served to the next caller that asks for exactly that resolution, so the hook's resolution serves the resolver's own. The decision a request is authorised with is unchanged: + + - The memo lives for one `resolveAuthzContext` call (an `AsyncLocalStorage` scope, closed when the call settles). Nothing is cached across requests; the cross-request grants cache keeps its own default-off switch, `OS_AUTHZ_GRANTS_CACHE_TTL_MS`. + - An entry is served only when a fresh read would agree with it. No write may have started, been executed at the driver, or still be in flight on the engine since the first resolution opened: the engine's write epoch (bumped when a write starts) and a write observer the memo registers as an engine middleware (counting writes into the chain and, after the driver step, out of it) must both read what they read at the open, with nothing inside the chain. The observer sees statement execution, not commit visibility. A write inside an `engine.transaction()` becomes visible only at its COMMIT, outside every chain. On driver-sql (not on the Turso remote face, which opens no transaction), a request whose step 2 falls inside that one commit round trip is authorised as of its first resolution, and the next request reads fresh — the same answer as a write from another process. No grant validity boundary may lie between the two clocks. The organization must be the same (the session arm's dropped-claim re-resolution is its own entry). A `bypassGrantsCache` caller is never served, a failed resolution is never stored, and an engine without the write-epoch seam or `registerMiddleware` declines entirely. + - `plugin-auth`'s hook now passes the session's email as its seed email, exactly as `resolveAuthzContext` does for the same session, so the two calls ask for the same resolution. The seed reaches only the envelope's `email`, which the hook does not read: the payload's `positions[]` and `isPlatformAdmin` are unchanged, and platform-admin standing still compares the stored `sys_user.email`, never a seed. + + Where the saving does not apply, the request reads the grants twice, as before — always the safe direction: + + - a request that meets a concurrent write on its engine; + - a request whose session read itself writes: the first request on a fresh auth instance generates its signing key, and with an idle timeout configured `enforceSessionControls` stamps `sys_session.last_activity_at` about once a minute per session; + - the first `resolveAuthzContext` call on an engine, which registers the write observer and stores nothing for it. +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index c8feb555dff..60761ac3b38 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "18.0.0-next.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Microkernel Core for ObjectStack", "type": "module", diff --git a/packages/create-objectstack/CHANGELOG.md b/packages/create-objectstack/CHANGELOG.md index edb2fe2c032..d7590368bb8 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,88 @@ # create-objectstack +## 18.0.0 + +### Minor Changes + +- 19805d8: The blank starter ships a test story: `pnpm test` boots the scaffolded app's own stack with `bootStack` from `@objectstack/verify` and asserts one business fact through its in-process handle + + Clause-②: no + + A project scaffolded with `npx create-objectstack` used to start with no `test` script and no way to assert its own behaviour, so the first test an author wrote was a hand-assembled kernel or a faked `ctx`. The blank template now carries: + + - **`note.test.ts`**, one starter test. It boots the app the project declares (its objects, its plugins, the services it requires) on the real engine, in-process, with an in-memory database and no server, and asserts that a note without a title is refused with the engine's own `VALIDATION_FAILED` error naming `title`, beside the same note with a title being saved. It addresses the object as `Note.name`, so it follows the namespace scaffolding writes. + - **`vitest.config.ts`**: a 60 s hook timeout for the boot, vitest's console capture switched off, so a log line the stack writes while a worker shuts down cannot fail a green run, and `OS_REGISTRY_LOG: 'warn'`, which drops the engine's one-line-per-registered-item boot log. + - **`package.json`**: a `test` script (`vitest run`), and `@objectstack/verify` (at the project's `@objectstack/*` range) and `vitest` as devDependencies. The vitest range is `~4.0.0`, the one `objectstack init -t plugin` and `objectstack create plugin` declare, and it is generated from that shared policy rather than written by hand, so the three scaffolders cannot drift apart. `~4.0.0` is the range that installs on npm 10, the npm Node 22 bundles; `^4.0.0` does not. + - **`tsconfig.json`** includes every root `*.ts` (`"*.ts"`, as `objectstack init` writes it), so `pnpm typecheck` checks the starter test and the vitest config. + - **`README.md`** lists `pnpm test` beside `validate`, `lint` and `typecheck`. + + The project's CI workflow is unchanged: its steps are the script set every scaffolder's project must declare, and `objectstack init -t app` declares no `test` script. +- 5da2c0f: feat(create-objectstack): a scaffolded project installs its AI skills from the `@objectstack/skills` package at its own `@objectstack/spec` version, never from the repository's `main` branch + + Clause-②: no + + - **The project depends on the catalog.** The blank template's `devDependencies` gain `@objectstack/skills`, at the same `^` as every other `@objectstack/*` dependency, so a new project's dependency set changes: the skills are the ones written for the `@objectstack/spec` it installs. + - **The install reads `node_modules`.** After ` install`, the scaffolder runs `npx -y skills@1.7.2 experimental_sync --agent claude-code --copy -y`, which copies the installed package's catalog into `.claude/skills/` as real files, once, beside a `skills-lock.json` that names the package as every skill's source. The GitHub `npx skills add objectstack-ai/objectstack/skills …` command is gone from the run, its summary and the `AGENTS.md` and `README.md` it writes into the project. + - **The summary says what happened.** It names the package version installed and prints the same command for re-installing after an upgrade of the `@objectstack/*` packages, and for any other agent runtime. When the install leaves no catalog to read, or a skill did not come from the package, the run says so instead of reporting the skills installed. + - **The flags keep their meaning.** `--skip-skills` skips the skills step. `--skip-install` installs no skills, because nothing is in `node_modules` yet; the summary names ` install` and then the sync command. + - **To move a project created earlier onto the package:** add `@objectstack/skills` to `devDependencies` at your `@objectstack/spec` range and run your package manager's install. Remove the skills `skills add` installed — `npx skills remove objectstack-data -y` and so on for each `objectstack-*` skill, or `npx skills remove --all` if you installed no others — because the sync skips a skill it did not install and leaves the old copy in place. Then run `npx skills@1.7.2 experimental_sync --agent claude-code --copy -y` (name your own runtime after `--agent`). + +### Patch Changes + +- 5f98778: fix(create-objectstack): the scaffolded Dockerfile installs from the lockfile the project carries + + Clause-②: no + + Where pnpm is on PATH, `create-objectstack` installs with pnpm and the project gets `pnpm-lock.yaml` and no `package-lock.json`. The `Dockerfile` it wrote copied `package*.json` and ran `npm ci`, so `docker build` stopped with `EUSAGE` before `os build` ran. + + - The build stage now copies `package.json` and whichever of `pnpm-lock.yaml`, `pnpm-workspace.yaml` and `package-lock.json` exist, then installs from the lockfile: `corepack pnpm@10 install --frozen-lockfile` for `pnpm-lock.yaml`, `npm ci` for `package-lock.json`. With neither, the build stops and says to install once and commit the lockfile. + - pnpm is pinned to major 10, the major the template's `.github/workflows/ci.yml` installs with. An unpinned Corepack takes the registry's newest pnpm, which the Corepack bundled with Node 22 could not run when this was measured. + - Unchanged: `os build` in the image, the runtime stage and its pinned image tag. + - A project scaffolded before this release keeps its own `Dockerfile`. To fix one, replace its `COPY package*.json ./` and `RUN npm ci` lines with the build stage shown in the self-hosting guide. +- 31cd210: A new project's `pnpm-workspace.yaml` is now a short settings file: one comment line above each block instead of 57 to 66 lines of version history and measurements. `npm create objectstack`, `objectstack init` and a standalone `objectstack create` now write the same 21-line file. + + Clause-②: no + + - The settings are unchanged: `packages: []`, the same `onlyBuiltDependencies` and `allowBuilds` lists, and the same `peerDependencyRules.allowedVersions` entries, in the same order. A project installs exactly as before. + - Each block keeps one line that says why it is there: which pnpm versions read each build-approval key, and that the peer rules only silence a warning that was measured harmless. + - The measurements behind each setting now live in the `@objectstack/cli` source next to the values they explain, so they are kept up to date there instead of in every project. + - Already-scaffolded projects are not touched. Their longer comments can be deleted or kept; the settings are identical. +- e4ad677: fix(create-objectstack): a project name that starts with the template's namespace scaffolds instead of being refused + + Clause-②: no + + A project whose namespace begins with the template's own namespace plus `_` (for example `blank-probe`, namespace `blank_probe`, under the `blank` template) was refused with exit 1: "1 object name(s) still carry the template's 'blank_' prefix … This is a bug in the scaffolder". The rewrite was correct (`blank_note` became `blank_probe_note`), but the check that verifies it tested each name against the template prefix alone, and `blank_probe_note` starts with `blank_`. + + - The check now reports a name as stale only when it carries the template's prefix and not the new namespace's prefix, so `blank-probe` scaffolds with objects named `blank_probe_*`. + - A name the rewrite really missed (`blank_note` under namespace `blank_probe`) is still refused, as before. + - The same rule applies to every template namespace; no name is treated specially. + - No migration: a project that scaffolded before this release is unaffected, and a name that was refused before can be scaffolded again. +- d795719: fix(create-objectstack): a blank scaffold installs on npm 10 again — its `package.json` overrides `vitest` to the range it already declares + + Clause-②: no + + A project from `npm create objectstack` (or `npx create-objectstack`) could not be installed with npm 10, the npm that Node 22 bundles and the one the scaffolder falls back to when pnpm is absent. `npm install` exited 1 with `Cannot read properties of null (reading 'edgesOut')`. The template's own dependencies were not the cause. Optional `vitest` peers deeper in the dependency tree led npm 10 to vitest 4.1.12, whose peer declarations contradict each other, and npm 10's resolver crashes on that. npm 11 installs the same project. + + The blank template now ships: + + ```json + "overrides": { + "vitest": "~4.0.0" + } + ``` + + This routes every `vitest` in the npm dependency graph to the range the project declares in `devDependencies`. npm 11 already resolves the whole tree to that version, and with the override npm 10.9.x installs the identical tree. pnpm does not read npm's `overrides` field, so pnpm installs are unchanged. + + If you change `devDependencies.vitest` in a scaffolded project, change `overrides.vitest` to the same value. npm refuses an override that differs from the direct dependency (`EOVERRIDE: Override for vitest@… conflicts with direct dependency`). An existing project that hits the `edgesOut` crash on npm 10 can install with npm 11 or later. +- 0b69564: Clause-②: no + + When the dependency install fails (a network error, a registry outage, an engine mismatch, an npm crash), the + scaffolder's closing "Next steps" now lists `npm install` (or `pnpm install`, whichever package manager the run + detected) before `npm run dev` and `npm run validate`, the same as it does under `--skip-install`. Before, a + failed install printed the same list as a successful one, so the next command it named ran against an empty + `node_modules`. A successful install still prints no install step. No flag, exit code, prompt or scaffolded file + changes. + ## 18.0.0-next.5 No changes in this release. diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index c8f98ae48d4..33ee2bfe132 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.5", + "version": "18.0.0", "description": "Create a new ObjectStack project — npx create-objectstack", "bin": { "create-objectstack": "./bin/create-objectstack.js" diff --git a/packages/drivers/driver-memory/CHANGELOG.md b/packages/drivers/driver-memory/CHANGELOG.md index 83b4e9d1041..fa2a7ed8ae2 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,410 @@ # @objectstack/driver-memory +## 18.0.0 + +### 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 [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/drivers/driver-memory/package.json b/packages/drivers/driver-memory/package.json index ba5011ebc1e..de9d6c57db5 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "In-Memory Driver for ObjectStack (Reference Implementation)", "main": "dist/index.js", diff --git a/packages/drivers/driver-mongodb/CHANGELOG.md b/packages/drivers/driver-mongodb/CHANGELOG.md index 88d70cde516..791757ff76f 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,421 @@ # @objectstack/driver-mongodb +## 18.0.0 + +### 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 [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/drivers/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json index bffc9d60341..16d7cf505fe 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "MongoDB Driver for ObjectStack - Native document database driver via official mongodb client", "main": "dist/index.js", diff --git a/packages/drivers/driver-sql/CHANGELOG.md b/packages/drivers/driver-sql/CHANGELOG.md index b5d4efcbe93..4f5d9e86c60 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,542 @@ # @objectstack/driver-sql +## 18.0.0 + +### 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. +- c4111c6: fix(cli,driver-turso): `os migrate plan` and `os migrate apply` on a Turso REMOTE database no longer offer `--allow-destructive` for a change that database cannot apply + + Clause-②: yes (widening) + + On a primary reached over the Turso REMOTE transport (`libsql://…`), `os migrate plan` listed a NOT NULL tightening with the remedy `Run "os migrate apply --allow-destructive".` and closed with `(add --allow-destructive for drops / tightenings)`. That transport runs no SQLite table rebuild, so the apply then named the tightening as not applied and exited 1. The plan now says so before the apply: each change the database cannot apply (a tightened or relaxed NOT NULL, a dropped DEFAULT) is still listed, with its finding and `"os migrate apply" cannot apply it on this database: it needs the SQLite table rebuild, which the Turso REMOTE transport does not run.` in place of the remedy. The footer offers `--allow-destructive` only for the destructive changes the database can apply (a column drop, an index change) and says how many listed changes remain drift after the apply. The listing `os migrate apply` prints before its prompt reads the same way. Its counts of held-back destructive changes, before the prompt and after the run, offer a re-run with `--allow-destructive` only for the ones that re-run could apply, and name the rest as drift it cannot apply either. On every other database both commands read exactly as before, and their `--json` documents are unchanged on every database. + + `@objectstack/driver-turso`: `TursoDriver.driftApplyLimit(opType)` is new. It returns why the driver's `applyMigrationEntries` cannot perform a drift op of that type on this face, or `null` when it can. It answers from the same per-op declaration the REMOTE apply door dispatches by, and returns `null` for every op on the local and embedded-replica faces. A driver that does not implement it is read as applying every op the differ means it to. + + `@objectstack/driver-sql`: `driftFindingWithoutRemedy(entry)` is a new export. It returns a drift entry's finding without the `os migrate apply --allow-destructive` sentence that a destructive column entry's message ends with, and returns every other message unchanged; the messages themselves keep their exact text. + +### Patch Changes + +- 9e9e6f9: feat(cli)!: `os meta resync` is deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `os meta resync` reconciled the platform-default `sys_permission_set` rows (`admin_full_access`, `member_default`, …) to the shipped declaration. Since the authorization resolver reads permission sets from the security catalog (ADR-0131 D3), an edited default set is served from its declaration at the next boot, and the rows the command reconciled decide nothing. The command, its `--yes` / `--json` modes and its skip-count explanation are gone; a script that calls it gets oclif's unknown-command error. + + In `@objectstack/plugin-security`, `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments and one warning sentence (the permission-set backfill's re-provision list) no longer name the command. Their behaviour is unchanged. +- 3d886ee: A field can declare cell formatting rules: `conditionalFormatting: [{ condition, style }]`, the rule a list view already declares for its rows + + Clause-②: yes (widening) + + - **`FieldSchema.conditionalFormatting`** (optional) is an ordered list of `{ condition, style }` rules. The first rule whose CEL `condition` holds applies its CSS `style` map to that field's cell, wherever the field renders. A list view's row rules still style the row, and both may apply to one record. Example: `amount: Field.currency({ conditionalFormatting: [{ condition: P\`value < 0\`, style: { color: '#b91c1c' } }] })`. + - **One rule element, no second style vocabulary.** The `{ condition, style }` element moved out of `ListViewSchema` into its own spec module, and both `ListViewSchema.conditionalFormatting` and `FieldSchema.conditionalFormatting` mount it. The list view accepts exactly what it accepted before. Its refusal of an unknown rule key now reads the same on both members: for a `color` written beside `style`, it says to put the colour in `style` first, then points a list view at `rowColor`. + - **The scope is `value` and `record`.** `value` is this field's value on the record and `record` is the row. `@objectstack/lint` judges each condition at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no other root (`previous`, `parent` and the user roots are refused, naming the field and the rule). + - **For presentation only.** Use it for rules that carry no meaning of their own, such as an amount below zero in red or stock under its safety level in amber. A deadline's overdue state stays a declaration (`dueLike` with `settledWhen`). + - **Display only.** Nothing on the write path reads the key, and every metadata that parsed before parses unchanged. Field-level security drops a rule whose condition reads a field the caller cannot read, keeping the other rules in order (`@objectstack/metadata-core`). The SQL driver classifies the key as presentation, so it never reaches column DDL (`@objectstack/driver-sql`). + - **Who reads it.** objectui's cell renderers read the rules in a follow-up release, once this spec version is published. Until then a rule is validated when it is authored and styles no cell. +- 86f53a4: fix(cli): `os migrate plan` / `apply` / `unmapped-columns` examine what `os serve` mounts around the stack, so the retired `sys_account.issuer` is finally a drop (#22506) + + `os migrate plan` and `os migrate apply` only examine the objects their own boot registers, and that boot stopped at the stack: its config, its metadata and the platform floor. Nothing `os serve` mounts around a stack was there. On an app declaring `requires: ['auth']`, the plan examined 9 of the 68 tables the serving boot had created. `sys_account` read as an undeclared platform table, so its retired `issuer` column (and that column's unique index) was never a destructive drop. `os migrate apply --allow-destructive` dropped nothing, while the boot's drift line and `os migrate account-issuer` kept prescribing exactly that command. + + The two commands now also compose, each through the rule `os serve` itself reads, and so does `os migrate unmapped-columns`, which reads the plan's own `unmapped_column` findings and therefore needs the plan's object set: + + - the auth family behind `serve`'s auth gate: plugin-auth's identity objects, the security plugin and the audit plugin, when the stack mounts no `AuthPlugin` of its own, the `auth` tier is on, and an auth secret resolves; + - the provider of every capability `serve` mounts: the stack's `requires` and the always-on slate, as `os serve` composes it with no `--preset`; + - the REST API plugin; + - every `plugins` entry by `serve`'s rule for one: a package name is loaded from the app, and a plain bundle is wrapped as an app; + - the pinyin-search decision from the config's locales, so the `__search` companion columns a boot provisions are no longer listed as destructive drops on a config-only project. + + `os migrate unmapped-columns --object sys_account` (or any platform object the plan now declares) resolves the object and reads its unmapped columns, where it used to answer `OBJECT_NOT_FOUND`. + + Each piece is composed for its declarations only. Its `init()` runs, and its `start()` and lifecycle hooks do not, so no dispatcher, scheduler or seed runs inside a dry run. + + **What an operator sees.** On the shape that looped, `os migrate plan` lists `sys_account.issuer` and `uniq_sys_account_issuer_account_id` as destructive drops. `os migrate apply --allow-destructive` drops both, `os migrate account-issuer` reads zero, and the next boot prints no drift line for them. When no auth secret is set and the boot is not a development one, the plan says it did not compose the auth family, and how to compose it: run with the deployment's `OS_AUTH_SECRET` exported. `os migrate` reads no `.env` file. + + **driver-sql.** The deferred-DDL preview answers a rotation-declared object (`lifecycle.storage.strategy: 'rotation'`, such as `sys_activity`) from the rotator's facts: when the current shard and the read view exist, nothing is pending. It used to list `create_table` for such an object on every plan, and `apply` then created nothing. + + **Known limit.** A development boot, or one with `OS_TELEMETRY_DB` set, keeps lifecycle-classed objects in a sibling `telemetry` database. The one-shot migration boot does not provision that database, so it examines those objects against the primary one. +- e5687cb: An `email` field can declare that it identifies a correspondent for inbound mail: `mailAssociation` + + Clause-②: yes (widening) + + - **`mailAssociation: boolean`** (optional, no default) on a field definition. TRUE marks this email field as identifying a message correspondent: when a mailbox service ingests a message, it associates the message with every record of the object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Absent or FALSE: the field takes no part in matching. + - **The platform learns which fields carry a correspondent's address, never what a contact is.** Every object that flags a field is treated alike; no app code and no resolver service is involved. + - **The door that judges it.** `FieldSchema` refuses the key on any type other than `email`, naming the field's type and the fix (change the type to `email`, or drop the key). Every metadata that parsed before parses unchanged. + - **Who reads it.** Nothing yet: the mailbox service's ingest is the reader, and it ships separately. Until then `objectstack validate` and `objectstack lint` warn an author who sets it (`liveness-planned-property`) that no mailbox service reads it yet. + - **Where to author it.** The field designer offers it on email fields as "Match Inbound Mail". + - **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies `mailAssociation` as `presentation`: the mailbox service reads it, never `createColumn`, so a declaration on a builtin column name is not reported as discarded storage. No behaviour changes. + - **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` classifies `mailAssociation` as `keep`: the FLS scrub of a served object definition passes the flag through unchanged, because it names no field. + - **`@objectstack/platform-objects`.** The field designer's new row carries its label and help text in the metadata-form translation bundles for en, zh-CN, ja-JP and es-ES. +- 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. +- 8776119: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + feat(spec): `referenceDeletedWhen` beside `referenceVia` marks a polymorphic pointer's target as platform-deleted + + Clause-②: yes + + `FieldSchema` gains one optional key beside `referenceVia`: `referenceDeletedWhen: { field, value }`. On rows where the sibling `select` field `field` holds the option `value`, the record the pointer names was deleted by the platform's own assertion, and a renderer draws a tombstone from that option's own label instead of a link. Example: `record_id: Field.text({ referenceVia: 'object_name', referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' } })`. + + Any other shape is refused at parse, naming the field and the option: + + - the key on a field that declares no `referenceVia` (`FieldSchema`), or an unknown key inside it; + - a `field` that is the declaring field itself, is not declared on the object, is not a `select`, is a `multiple: true` select, or declares no inline `options` (a `picklist`-backed select); a `value` that is not one of the sibling's option values (`ObjectSchema`, at every door: `ObjectSchema.create()`, `defineStack`, raw `.parse()` and the metadata save door). + + Nothing that parsed before is refused: the key is new. Its liveness row is `planned` until the renderer that reads it ships. + + The other packages classify the new key where every field key is classified: + + - **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` deletes a served field's `referenceDeletedWhen` when its `field` names a field the caller cannot read (field-level security); `value` is an option value and never a field name. + - **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies it `presentation`: no DDL reads it. + - **`@objectstack/platform-objects`.** The field form's new `referenceDeletedWhen` row carries its labels and help text in zh-CN, ja-JP and es-ES. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/types@18.0.0 + - @objectstack/observability@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/drivers/driver-sql/package.json b/packages/drivers/driver-sql/package.json index dda9cb9edde..bb4d0645406 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "SQL Driver for ObjectStack - Supports PostgreSQL, MySQL, SQLite via Knex", "main": "dist/index.js", diff --git a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md index fe80e12dd18..c677ddb39e8 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,408 @@ # @objectstack/driver-sqlite-wasm +## 18.0.0 + +### 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 [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [9e9e6f9] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [86f53a4] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [53646fc] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [c4111c6] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/driver-sql@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/drivers/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json index 385aec698b9..0a4ed3d829c 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "WASM SQLite Driver for ObjectStack — runs in browser/WebContainer (StackBlitz) without native bindings", "keywords": [ diff --git a/packages/drivers/driver-turso/CHANGELOG.md b/packages/drivers/driver-turso/CHANGELOG.md index 669cb231301..0fd0b57fb48 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,510 @@ # @objectstack/driver-turso +## 18.0.0 + +### 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. +- ae4dfaf: feat(driver-turso,cli): the v18 organization-ownership ceremony runs on a Turso remote database + + Clause-②: yes (widening) + + `os migrate organization-ownership`, the plan and `--apply --backup-taken`, now completes against a Turso database reached over the remote transport (`libsql://…`), the transport hosted environments use. Before, the plan stopped at its boot with `Deferred schema DDL is not supported by the Turso REMOTE transport`, and the apply's column drops had no remote path either. + + - **`@objectstack/driver-turso`**, remote face: + - `detectManagedDrift()` answers instead of refusing with `NOT_IMPLEMENTED` / 501. The verdict is the shared differ's, the one the local face runs; the physical schema it diffs is read from the remote catalog (`pragma_table_info`, `sqlite_master`, `pragma_index_list` / `pragma_index_info`, and the ADR-0120 D4 duplicate probe). The declarations it diffs against are recorded by every remote schema door and by the DDL-free `registerObjectMetadata`, so a boot that skipped schema sync is covered too. + - `applyMigrationEntries()` applies `drop_index`, `create_index`, `recreate_index`, `replace_unique_index` and `drop_column` instead of refusing. Each entry runs as one libSQL `write` batch, so it lands whole or not at all: a `drop_column` drops the column's indexes with it in the same transaction, and a re-created index that fails to build leaves the old one in place. An entry is reported `applied` only once a read-back shows its target state. Every other column entry (nullability, width, a dropped DEFAULT) needs the SQLite table rebuild, which this transport does not run, so it is reported `skipped` and logged, as the in-place dialects report an op they cannot perform. + - The remote face declares `driftReportCaveat`: a sentence saying its drift report is for reading only and naming the reason. The OS_ARTIFACT_URL boot gate reads it (below). The local and embedded-replica faces declare `null`. + - The remote driver's `config` now carries its `url`, so the CLI labels a remote database by its URL (credential-free) instead of `better-sqlite3`. The ceremony writes that label into its plan and refuses an apply whose plan names another database; that refusal can now tell two remote databases apart. + - **`@objectstack/cli`**: the ceremony's plan and apply boots skip schema sync, instead of arming deferred DDL, when the primary datasource is a Turso remote one. No DDL is attempted, so the plan stays read-only, and every other datasource the boot connects is still deferred. The environment's own boot has already run the additive sync the ceremony reads. Every other `os migrate` command keeps refusing on that transport: their dry runs list held-back schema work, which a boot that holds nothing back cannot list. + - **`@objectstack/runtime`**: `createStandaloneStack` takes `skipSchemaSync`, passed to `ObjectQLPlugin` as the option `OS_SKIP_SCHEMA_SYNC=1` names. Unset, the plugin reads the variable as before. + + On a Turso remote primary, the `OS_ARTIFACT_URL` boot migration gate on `os serve` keeps its earlier answer: cannot judge, warn and boot. It applies no drift and refuses no boot. Only its warning's wording changes, from `Could not check the physical schema` to `Did not check the physical schema against the artifact: …`, followed by the driver's reason. The gate now decides this from a capability the driver declares (`driftReportCaveat`, a string on the remote face and `null` on the local and embedded-replica faces), not from a refusal it catches. Two follow-ups, filed separately and not closed by this change, have to land before that gate may act on a remote report: + - #23847: the remote transport's schema sync honours `storage.notNull` on create and add column, so the report stops naming a tightening its own sync produced; + - #23848: `os migrate apply` works on a Turso remote database, so the destructive drift the gate would refuse on has a remedy there. +- 61e5ee9: fix(cli,driver-turso): `os migrate plan` and `os migrate apply` run on a Turso REMOTE database, and the apply names every drift entry it could not apply + + Clause-②: yes + + On a primary reached over the Turso REMOTE transport (`libsql://…`, every hosted tenant), `os migrate plan` and `os migrate apply` used to stop at their boot with `Deferred schema DDL is not supported by the Turso REMOTE transport`, so the remedy the drift report and the `OS_ARTIFACT_URL` boot gate print for destructive drift, `os migrate apply --allow-destructive`, could not run there at all. Both commands now boot the way `os migrate organization-ownership` does on that transport: with schema sync skipped instead of deferred. What an operator on a remote primary now sees: + + - **The drift half runs.** `os migrate plan` lists the drift; `os migrate apply` applies what the transport can express, after the same confirmation: a column drop (with the indexes keyed on it, in one transaction) and every index entry. Nothing is written before the prompt, as before. + - **The additive half is reported as NOT examined, never as "nothing to create".** Both commands print a warning under the database line saying that tables and columns the metadata declares and the database lacks are neither listed nor created by this run, and that the serving boot (`os serve` / `os start`) creates them on this transport. The `--json` documents carry `schemaSync: "skipped"`, so their `pending: []` reads as not examined; an apply with no drift answers `message: "in_sync_partial"`, not `"in_sync"`. + - **Every entry the driver did not apply is named,** by table, column and kind, in both faces (`--json`: a new `notApplied` array on the post-apply document). The REMOTE transport runs no SQLite table rebuild, so a tightening, a relaxed NOT NULL, a width change or a dropped DEFAULT is not applied there. + - **A destructive entry confirmed with `--allow-destructive` that was not applied makes the run PARTIAL:** the human face ends `PARTIAL apply: …` and the command exits `1`; `--json` carries `message: "partial"` and exits `1`. A non-destructive entry that was not applied is named and the exit stays `0`. + + On every other database the apply behaves as before, with one difference in the same direction: a destructive change confirmed with `--allow-destructive` that the driver could not apply (its DDL failed, or the dialect cannot perform it) now exits `1` and is named, where the run used to exit `0` with a count. Its human line for the entries held back without `--allow-destructive` now reads `Skipped N destructive change(s) (re-run with --allow-destructive to include them)`. + + `@objectstack/driver-turso`: the REMOTE face's `driftReportCaveat` no longer says a destructive entry has no remedy on this transport; it says `os migrate apply --allow-destructive` applies a column drop or an index change there and names the rest. + + The other `os migrate` commands whose dry run arms deferred DDL (`duplicates`, `account-issuer`, `unmapped-columns`, `multi-value-columns`, and the dry runs of the data commands) still refuse on the REMOTE transport, unchanged. +- c4111c6: fix(cli,driver-turso): `os migrate plan` and `os migrate apply` on a Turso REMOTE database no longer offer `--allow-destructive` for a change that database cannot apply + + Clause-②: yes (widening) + + On a primary reached over the Turso REMOTE transport (`libsql://…`), `os migrate plan` listed a NOT NULL tightening with the remedy `Run "os migrate apply --allow-destructive".` and closed with `(add --allow-destructive for drops / tightenings)`. That transport runs no SQLite table rebuild, so the apply then named the tightening as not applied and exited 1. The plan now says so before the apply: each change the database cannot apply (a tightened or relaxed NOT NULL, a dropped DEFAULT) is still listed, with its finding and `"os migrate apply" cannot apply it on this database: it needs the SQLite table rebuild, which the Turso REMOTE transport does not run.` in place of the remedy. The footer offers `--allow-destructive` only for the destructive changes the database can apply (a column drop, an index change) and says how many listed changes remain drift after the apply. The listing `os migrate apply` prints before its prompt reads the same way. Its counts of held-back destructive changes, before the prompt and after the run, offer a re-run with `--allow-destructive` only for the ones that re-run could apply, and name the rest as drift it cannot apply either. On every other database both commands read exactly as before, and their `--json` documents are unchanged on every database. + + `@objectstack/driver-turso`: `TursoDriver.driftApplyLimit(opType)` is new. It returns why the driver's `applyMigrationEntries` cannot perform a drift op of that type on this face, or `null` when it can. It answers from the same per-op declaration the REMOTE apply door dispatches by, and returns `null` for every op on the local and embedded-replica faces. A driver that does not implement it is read as applying every op the differ means it to. + + `@objectstack/driver-sql`: `driftFindingWithoutRemedy(entry)` is a new export. It returns a drift entry's finding without the `os migrate apply --allow-destructive` sentence that a destructive column entry's message ends with, and returns every other message unchanged; the messages themselves keep their exact text. + +### Patch Changes + +- 2034975: fix(driver-turso): a Turso remote database enforces the `storage.notNull` a field declares, on the tables and columns its schema sync creates + + Clause-②: no + + **What was wrong.** On a Turso database reached over the remote transport (`libsql://…`, the transport hosted environments use), schema sync created every declared column nullable. A field declared `storage: { notNull: true }` was not enforced there: an insert that left it empty was stored. The same declaration on a local SQLite file (`file:`) has always produced a `NOT NULL` column. The drift report, which compares the database with the declarations through the shared differ, also read each such column as destructive `tighten_not_null` drift on a database the remote sync had just created. + + **What changes.** The remote face's `CREATE TABLE` and `ALTER TABLE … ADD COLUMN` add `NOT NULL` to a column whose field declares `storage.notNull`, wherever the local face adds it: on every field type, multi-value fields included, and never for `required` alone. On a remote database synced after this release, an insert that leaves such a field empty is refused by the database, and the drift report names no `tighten_not_null` for it. + + **What does not change.** + + - A column that already exists is not touched. A remote database synced by an earlier release keeps its nullable columns, and its drift report still names them `tighten_not_null`; no data at rest is migrated. So the remote face still declares its drift-report caveat (reworded to say this), and the `OS_ARTIFACT_URL` boot gate still does not act on a remote report. + - Adding a `storage.notNull` field to a remote table that already holds rows is refused by SQLite (`Cannot add a NOT NULL column with default value NULL`), as it is on a local SQLite file, and that object's schema sync fails loudly. The remote error now names the refused statement. One case still differs: when the field also declares a literal `defaultValue`, the local face creates the column with that DEFAULT and accepts it, while the remote face emits no declared column DEFAULT and refuses it. +- de70e97: Retirement prescriptions name `os migrate meta --write`: "… to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand." + + Clause-②: no + + Wording only: no schema, key, type, export or error code changes, and every input that parsed or was refused before gets the same verdict at the same path. Only the closing sentence of the message changes. + + - A prescription covered by an ADR-0087 conversion used to close with "Run `os migrate meta --from N` to list the mechanical edits for existing sources; apply them by hand." It now closes with "Run `os migrate meta --from N` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand." The default run still only lists. `os migrate meta --write` rewrites in place each edit it can trace to one literal in one project file and lists every other edit with the reason it was not written. The sentence never says the tool rewrites your sources automatically, because `--write` does not write every edit. Text up to and including "for existing sources;" is unchanged, so a caller matching that prefix still matches. + - The three prescriptions for conversions that cover only part of a value (dashboard `compareTo.offset`, the script flow node's `config.actionType` and the package manifest `permissions` case) carry the same `--write` clause before their second clause. + - The `record:chatter` / `record:discussion` `position` value prescriptions (`'sidebar'`, `'inline'`, `'drawer'`) used to tell the author to run a bare `os migrate meta`, which the command refuses because `--from` is missing. They now name `os migrate meta --from 17` and close with the same sentence. + - `@objectstack/lint`: the script node's retired dispatch-key diagnostic and the dataset widget's `chartConfig.xAxis.field` hint close with the new sentence. + - `@objectstack/driver-turso`: the `timeout`, `localPath` and `wasm` config tombstones close with the new sentence. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [9e9e6f9] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [86f53a4] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [53646fc] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [c4111c6] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/driver-sql@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index 0f41425482b..4b52b58fbdc 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Turso/libSQL Driver for ObjectStack — Edge-first SQLite with embedded replicas", "keywords": [ diff --git a/packages/formula/CHANGELOG.md b/packages/formula/CHANGELOG.md index de73838b96b..8e978e093bb 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,466 @@ # @objectstack/formula +## 18.0.0 + +### Minor Changes + +- ca426cb: **Shipped in 18.0.0-next.5**, whose CHANGELOG missed this entry; listed here one prerelease late. + + feat(formula): `current_user.hasCapability(name)` — a page predicate can ask whether the viewer holds a system capability, from the same list the action gate reads + + Clause-②: yes (widening) + + - **The predicate.** The CEL standard library registers `hasCapability` beside `can`, receiver-only, so the one spelling is `current_user.hasCapability('')` (the `user` / `ctx.user` / `os.user` aliases work too). It answers `true` when the name is in the acting subject's held system capabilities and `false` when it is not. A name no action declares is just a string: there is no registry lookup. + - **The data.** `EvalContext` gains `systemPermissions` (type `EvalSystemPermissions`, a list of capability names): the `systemPermissions` array of the published `/auth/me/permissions` response, unchanged — the list an action's `requiredPermissions` is checked against (ADR-0066 D4). `PermissionBinding` gains the same field, optional. Pass it beside `permissions`: + + ```ts + celEngine.evaluate(expr, { user, permissions: toEvalPermissions(me.objects), systemPermissions: me.systemPermissions }); + ``` + + - **Loud, like `can`.** With no `systemPermissions` on the context the call fails (`ok: false`, `kind: 'runtime'`) rather than answering; an empty list is a real answer and evaluates to `false`. A non-list, a non-string or empty name, or a receiver that is not the acting subject fails the same way. A bare `hasCapability(name)` does not resolve. + - **The authoring gate.** The visibility-predicate rule's function-existence check reads the evaluation environment, so a page `visibleWhen` calling `current_user.hasCapability(…)` now passes `os validate`, `os lint` and `os build`, where before it was refused as an unknown function (`visibility-predicate-unknown-function`). An invented sibling (`hasCapabilities`) is still refused. + - **`can` points at it.** `current_user.can(object, verb)` with a verb outside the object-permission vocabulary still fails, and its message now names `current_user.hasCapability('')` for a capability question. + - **Docs.** The page component `visibleWhen` describe names `current_user.hasCapability('')` as the way to gate a component on a capability: `visibleWhen` hides, `requiredPermissions` refuses. + - **Hosts.** A host that evaluates page predicates supplies the list for the predicate to answer; one that does not makes it fail, and a failing page `visibleWhen` renders the component. +- 2b61f2d: A flow TEXT slot — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message` — reads ADR-0032 §3's `{{ }}` template holes, rendered by the formula template engine over the flow's variables. A single-brace `{…}` token in one is refused at `objectstack validate`, at `registerFlow` and by the node's contract, with the `{{ }}` spelling of each token. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 3 fixes one template delimiter: "One delimiter, `{{ }}`; single `{ }` deleted." The notify pair was already typed as `template` slots, while the executor read the single brace, so a flow carried two template dialects and an author who met both mixed them. + + **What changes.** + + - **The renderer.** The text slots render through one renderer inside `@objectstack/service-automation` (package-internal; the package's public exports do not change): `{{ path }}` and `{{ path | formatter[:arg] }}` holes, every other character literal. A hole reads a flow variable by name (`{{ record.name }}`, a node output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or `{{ rows[0].subject }}`, and a `$`-named one: `{{ $error.message }}`). `null` and an absent path render nothing, an object or array renders as JSON, a `Date` as its ISO text; the ADR-0032 formatters make the rest explicit (`{{ amount | currency }}`, `{{ due | date:long }}`). A hole holds no logic. + - **The refusal.** One judge in `@objectstack/spec/automation` (`textSlotTemplateRefusal`, `flowNodeTextSlotSources`, `FLOW_NODE_TEXT_SLOTS`, `TEXT_SLOT_TEMPLATE_REFUSAL`): `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` refuse a single-brace token at the slot's key; `registerFlow` and `objectstack validate` (`expression-invalid`, `error`) refuse it with the same words and also compile every slot's holes, so `{{ a + b }}` or an unknown formatter is refused before a run. A stored flow carrying one is skipped at boot with a warn naming it. Past the doors, a hole that does not compile fails the node with a guard refusal, which a `fault` edge does not route. + - **`@objectstack/formula`.** A template hole's path may contain `$` (`{{ $error.message }}`): a widening of the hole grammar, so a host scope's `$`-named variable has a template spelling. An unbound one renders nothing, like any unknown path. + - **`flow-double-brace-interpolation`** no longer flags `{{ }}` on the text slots, and its hint names them as the only `{{ }}` positions; a bare `$ref.x` outside the holes of a text slot is flagged with the hole spelling. + - **Unchanged:** the value slots are CEL's. + + **Not converted.** No ADR-0087 D2 conversion rewrites `{x}` to `{{ x }}`. The two renderers were run over the same variables: a path renders the same text for a string, a number, a boolean, `null`, an absent key or variable, an ISO date string, an object or an array, but a `Date` value rendered JSON-quoted under the 17.x interpolator (`"2026-10-08T09:30:00.000Z"`, quotes included) and as its ISO text now, and a screen or `end` text that was one token holding an object, an array or a `Date` rendered `String(value)` (`[object Object]`). So the rewrite is the author's to check; the D3 record is the semantic entry `flow-text-slot-single-brace-refused`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `title: 'Deal won: {record.name}'` | `title: 'Deal won: {{ record.name }}'` | + | `message: 'Failed: {$error.message}'` | `message: 'Failed: {{ $error.message }}'` | + | `message: 'Total {amount * 2}'`, `'{round(x)}'` | compute it first — `assignments: { v: { dialect: 'cel', source: 'amount * 2' } }` — then `'Total {{ v }}'` (a number format is a formatter: `{{ v \| number:2 }}`) | + | `message: 'Due {TODAY() + 7}'`, `'By {$User.Id}'` | compute it first with an `assignment` node's CEL value envelope — `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' }, by: { dialect: 'cel', source: 'current_user.id' } }` — then `'Due {{ due }}'`, `'By {{ by }}'` | + + **The one-line fix: double the braces of every path token in a text slot (`{x}` → `{{ x }}`), and compute anything else into a variable first.** + + **Who is affected, measured.** This repository's in-tree text-slot sites — `examples/app-showcase` (24 strings, 30 tokens) and `examples/app-todo` (5 strings, 7 tokens), all of them paths — are rewritten in this change, with the docs pages that taught the single brace (`content/docs/automation/flows.mdx`, `content/docs/getting-started/common-patterns.mdx`). Other repositories and deployed metadata were not measured here. +- 345d3f3: feat(formula): `isoDate(t)` and `isoDatetime(t)`, the string form of a CEL timestamp + + Clause-②: yes (widening) + + - **What is new.** Two CEL stdlib functions that turn a timestamp into ISO text on the UTC calendar. `isoDate(t)` returns `YYYY-MM-DD` and `isoDatetime(t)` returns `YYYY-MM-DDTHH:mm:ss.sssZ`, always with three-digit milliseconds and a `Z`. Both are in `CEL_STDLIB_FUNCTIONS`, so `introspectScope` advertises them and the build check accepts them. + - **The bytes the flow template dialect wrote.** `isoDate(today())` is the text `{TODAY()}` wrote, `isoDatetime(now())` is the text `{NOW()}` wrote, and `isoDate(daysFromNow(n))` / `isoDate(daysAgo(n))` are the text `{TODAY() + n}` / `{TODAY() - n}` wrote, in a flow value envelope. This holds at month, year, leap-day and DST-transition instants, whatever the host's zone. + - **`isoDate(today())` is the reference-timezone day.** `today()` is that day at UTC midnight, and `isoDate` reads the UTC calendar. Under a non-UTC reference zone, `isoDate(now())` can be a different day: it is the UTC day of the instant. + - **Only a timestamp is accepted.** Text, a number or `null` is refused, at build when the argument's type is known and at run otherwise. It is never coerced or rendered. For ISO text, parse it first: `isoDate(date(s))`. An invalid timestamp, or one outside 0001-01-01 to 9999-12-31, is refused at run. + - **`string(timestamp)` is still refused.** CEL defines that conversion as RFC 3339 text that drops a zero fraction, which is not the template's `.000Z`, so each shape has one spelling. + - **No write path changes.** An envelope that returns a timestamp (`today()`, `now()`, `daysFromNow(n)`, `addDays(…)`) still evaluates to a `Date` and writes what it wrote before. +- 9af0005: The shared build validator (`@objectstack/lint`'s expression rule over `@objectstack/formula`) judges `record..` against an object's declared read attachments (`ObjectSchema.attachedOnRead`) + + - **`@objectstack/lint`.** The field index the expression rule builds now adds each block an object declares under `attachedOnRead` to the names `record.` resolves to, and hands the shared validator the block's declared leaves. A predicate such as `record.viewer.can_act` on an object that declares the `viewer` block is accepted where it was refused as an unknown field. + - **`@objectstack/formula`.** `ExprSchemaHint` gains an optional `attachedOnRead` (block name → declared leaf keys). When a `record.` or `previous.` reference names a declared block, its next segment must be a leaf the block declares. Anything else is refused with the existing `unknown-field` code: `field` is the dotted path as written (`viewer.can_actt`), `suggestion` the nearest declared leaf (`viewer.can_act`), and the new optional `block` and `leaves` params carry the leaves the block declares, which the message names. No new refusal code. + - **`@objectstack/metadata-core`.** The ADR-0106 field-level-security masker classifies the new top-level key as passed through unchanged (`OBJECT_REFERENCE_POSITIONS.attachedOnRead`): a block name and its leaf keys never name a field, so no caller's served object definition loses or gains anything. + - **Unchanged.** An object that declares no block keeps every verdict it had, and a second segment off any other field is not judged. Index access, a method call on a block and a third segment stay unjudged. +- 94379f4: fix(formula)!: the unknown-field check judges every spelling of a `record` / `previous` member, not only the dot, so `os build` and the object save door refuse `record['typo']`, `previous['typo']`, `record.?typo` and `record[?'typo']` (#22428) + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set change, in both directions, of the `os build`, `os validate` and `os lint` verdicts and of the object save door, shipped as `minor` under the launch-window convention for accept-set narrowings. It narrows: an expression that names an undeclared field of its object in a non-dot member spelling (`record['FIELD']`, `record["FIELD"]`, `record.?FIELD`, `record[?'FIELD']`, or any of those on `previous`), or an undeclared leaf of a declared read attachment in such a spelling, is now refused where it was accepted. It widens: three shapes the dot-only regex misread as a member of the root, and refused as unknown fields, are now accepted, because none of them names a member. They are text inside a string literal (`record.name == 'record.typo'`), a root name after another root (`vars.record.x`), and a method call on the root itself (`record.size()`). No refusal code is added. + + `validateExpression`'s field-existence pass judges each member an expression reads on `record` or `previous` against the object's fields, and refuses an undeclared one with the `unknown-field` code. It found those members with a regex over the dot spelling, so it judged `record.typo` and `has(record.typo)` and nothing else. `record['typo']`, `record["typo"]`, `previous['typo']`, `record.?typo` and `record[?'typo']` name the same column, and they reached the evaluator with no verdict at every slot the pass judges, among them a select option's `visibleWhen`, a field's `requiredWhen` and a validation rule's condition. There the expression faults on a key the record never carries, or reads a value that never exists. + + The pass now reads members through the same AST member reader the relationship-traversal analysis is built on, so every spelling gets the dot spelling's verdict, did-you-mean included: + + - `os build`, `os validate` and `os lint` refuse an expression that names an undeclared field of its object as `record['FIELD']`, `record["FIELD"]`, `record.?FIELD`, `record[?'FIELD']` or any of those on `previous`, at `error`, with the message they already give for `record.FIELD`. + - The object save door runs the same pass, so an object write in publish mode that carries such an expression is now refused with an `expression-invalid` issue located at the slot, the build's own finding. + - A declared read attachment's leaf (`ObjectSchema.attachedOnRead`) is judged in the same spellings: `record.viewer['can_actt']` and `record.viewer.?can_actt` are refused like `record.viewer.can_actt`. An earlier entry in this release says index access on a read attachment stays unjudged; that describes the check before this change. + + **Remedy.** Declare the field on the object, or fix the typo to the field the message suggests. The spelling itself is not the defect: `record['status']`, `record.?status` and `record[?'status']` on a declared `status` are accepted, as before. + + **Unchanged.** + + - A computed key (`record[record.kind]`, `record[someVar]`) names no member before evaluation, so it is not judged. + - A method call on a member (`record.name.startsWith('A')`) still judges the member (`name`). + - Stored rows are not migrated or refused on read; an object stored before this change keeps loading until it is next saved, and that save is judged. Drafts are not gated. + - Measured before crossing: the stacks this repository ships give the same expression findings before and after the change. That covers `examples/app-todo`, `examples/app-crm`, both `examples/app-multi-package` sub-stacks, the objects, actions, flows, views and pages of `examples/app-showcase`, the 51 objects `@objectstack/platform-objects` exports, and `plugin-approvals`' `sys_approval_request`, whose eight action predicates read the leaves of its declared `viewer` read attachment and pass. No in-repo producer writes a non-dot spelling of an undeclared field. + - No public export or signature moves; only the doc comments of `ExprSchemaHint.fields` and `ExprSchemaHint.attachedOnRead` change. `analyzeRelationshipTraversals` answers exactly what it answered before: measured identical, set order included, on 4,528 analyses of the 2,264 `record` / `previous` expressions in this repository's TypeScript sources. +- e865300: A formula field can declare a currency result: `returnType: 'currency'`, with its currency in its own `currencyConfig` + + Clause-②: yes (widening) + + - **What is new in `@objectstack/spec`.** `FieldSchema.returnType` accepts `'currency'` beside `'number'`, `'text'`, `'boolean'` and `'date'`. A `currency` result is an amount of money. Its value is a bare number, as a currency field's is. Its currency is the formula field's own `currencyConfig`, the same `CurrencyConfigSchema` a currency field carries, with the same defaults and the same refusals: no `currencyConfig` means dynamic mode (the tenant default currency), and `{ currencyMode: 'fixed', defaultCurrency: 'USD' }` fixes it. Nothing is read at display time from the fields the expression references: the currency is a declaration on the formula. + - **Readers.** The filter doors judge a formula returning `currency` as the currency field it reads like: a text operator on it is refused (`INVALID_FILTER`), and the number door judges its comparands. A formula is still not title-eligible unless it returns `text`, and is still refused for `sum` / `avg` / `min` / `max` (it has no stored column). + - **Designer forms.** The field designer and the object designer's field grid both offer Currency as a return type. The field designer shows its Currency Config row for a formula whose return type is Currency, as it does for a currency field. + - **What is new in `@objectstack/formula`.** `inferFormulaReturn(expression, fields)` returns the declaration authoring stamps onto a formula field, judged over the host object's declared field map: `{ returnType: 'currency' }` when the expression is provably an amount of money, plus `currencyConfig: { currencyMode: 'fixed', defaultCurrency }` when its source amounts are in one fixed currency. "Provably money" is unit checking over the expression: amounts in one currency added, subtracted, scaled by a number or a percent, divided by a number, rounded, or picked by `min` / `max` / `coalesce` / a ternary. Two different currencies, an amount multiplied by an amount, or an amount plus a plain quantity are not money. For everything it cannot prove to be money it answers exactly what `inferExpressionType` answers, so a numeric formula stays `number`. Its types `FormulaReturnDeclaration` and `FormulaSourceField` are exported with it. `inferExpressionType` is unchanged. + - **`@objectstack/platform-objects`.** The designer help text for the return type and the currency config rows names the currency result, in all four locales. + - **Nothing to migrate.** Every value accepted before is accepted now. To declare a formula over money as money, declare `returnType: 'currency'` and copy the source currency field's `currencyConfig` onto the formula, or run `inferFormulaReturn` and write what it returns. +- d537a31: feat(formula)!: `validateExpression` judges relationship traversal in the `value` role when the caller opts in, with formula wording + + Clause-②: yes (narrowing) + + + + - `validateExpression('value', source, { fieldTypes, traversalHydration: true, … })` now refuses a second hop through a reference (`traversal-multi-hop`) and a reference read both through and as a plain value (`traversal-bare-and-traversed`). The messages say the formula reads null, and the repair for an empty reference is the optional read `record.LOOKUP.?FIELD.orValue('…')`. Before, the `value` role ignored the flag. + - `findTraversalConflicts` takes an optional fourth argument, `subject` (`'predicate'`, the default, or `'formula'`), which selects that wording. The `TraversalSubject` type is exported. + + Without the flag, every role behaves as before. + +### Patch Changes + +- 4840720: perf(formula): a CEL evaluation reuses one environment instead of building the stdlib on every call, and compiles reuse the canonical parse environment + + Clause-②: no (an internal cache: no accept set moves, no evaluation result moves, no exported signature changes; owes the seat's review of record before the queue) + + - **`celEngine.evaluate` reuses one environment.** It used to build a new cel-js `Environment` and register the whole stdlib into it on every call, which objectql's formula fields reach once per row per field. The stdlib now reads `now()`, the timezone and the permission binding of `current_user.can` / `current_user.hasCapability` from a per-evaluation slot. `evaluate` saves the slot, sets it and restores it in a `finally`, so a nested or a thrown evaluation leaves the outer binding as it was. Evaluations also run a bounded per-source cache of parsed programs, keyed on the source after its row-dependent rewrites. + - **`celEngine.compile` reuses the canonical parse environment** that `parseCelToAst` already shares. + - Results, error kinds and error messages are unchanged. `registerStdLib`, `registerPermissionPredicate` and `registerCapabilityPredicate` keep their signatures and their behaviour. + - Measured on one source evaluated 20,000 times: about 142 µs per evaluation before, about 3 µs after. A 25-row read of an object with three formula fields built 78 environments before and builds none after. +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index 10c1ab60ef9..bc05f5a50ef 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "18.0.0-next.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "ObjectStack canonical expression engine — CEL (cel-js) + ObjectStack stdlib + dialect registry", "main": "dist/index.js", diff --git a/packages/lint/CHANGELOG.md b/packages/lint/CHANGELOG.md index 8091980090c..3a859cdfd2a 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,2457 @@ # @objectstack/lint +## 18.0.0 + +### Major Changes + +- 317cddd: feat(spec)!: an element binds data through the node-level `dataSource` only — the flat `object` / `filter` / `sort` / `limit` of `element:record_picker`, `element:number` and `element:repeater`, and `object-grid.defaultFilters`, are retired (#11509) + + Clause-②: no (narrowing: the ten element-layer flat data-binding keys and `object-grid.defaultFilters` leave the accept set, and the component-props gate's `dataSource.object` waiver becomes a refusal) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). `@objectstack/lint` is `major` with it: its component-props rule refuses what it used to waive. + + **Why.** One page element carried two doors onto one query. Each of these flat keys was the same query as a key of the node-level `dataSource` binding (`ElementDataSourceSchema`), and the three renderers resolved them by three contradictory rules: the record picker let the binding win, `element:number` AND-combined the two filters, and the repeater read the flat keys alone and ignored the binding — while the component-props rule waived a missing flat `object` whenever `dataSource.object` was present, so a repeater bound only through `dataSource` passed `os validate` and drew "No records". The console moved all three elements onto the binding first (objectui#11880); from this release the binding is the one door. `object-grid.defaultFilters` was the legacy second spelling of `filter`, read only when `filter` lowered to nothing. + + **What changes.** + + - **`@objectstack/spec`.** `ElementRecordPickerPropsSchema` `object` / `filter` / `sort` / `limit`, `ElementNumberPropsSchema` `object` / `filter`, `ElementRepeaterPropsSchema` `object` / `filter` / `sort` / `limit` and `ObjectGridPropsSchema` `defaultFilters` are `retiredKey()` tombstones: each is refused at its key with the prescription, and its input type is `never`. The repeater's other spellings of its query keys (`objectName`, `where`, `orderBy`, `top`, …) now point at the binding. The `object-*` blocks keep their own keys, and the relationship-scoped blocks are unchanged. + - **`@objectstack/lint`.** `validate-component-props` (`component-props-invalid`, warning) reports an `element:record_picker`, `element:number` or `element:repeater` node with no `dataSource.object` at that path, instead of waiving the flat `object` for any type whose binding names one. A flat key is reported by its tombstone. + - **Conversions (stored rows, artifacts, `os migrate meta --from 17`).** `element-flat-data-binding-to-data-source` moves a flat key the binding lacks onto it, deletes one the binding already set where the binding won, and appends `element:number`'s flat filter to the binding's (they always AND-combined). It leaves for the author, as a TODO: a record-picker key beside a `dataSource.view` that sets no such key of its own, a repeater key the binding sets to a different value or beside a `view`, and an `element:number` filter pair that is not two rule arrays. `object-grid-default-filters-removed` moves `defaultFilters` onto an empty `filter` (absent, `null`, `[]` or `{}`) and deletes it beside a `filter` that has rules. Both run before `page-component-filter-record-to-rule-array`, which then converts a moved record-form filter at its new door. Both are retired from the load path: an author is refused at the parse. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ type: 'element:record_picker', properties: { object: 'deal', limit: 20, labelField: 'name' } }` | `{ type: 'element:record_picker', dataSource: { object: 'deal', limit: 20 }, properties: { labelField: 'name' } }` | + | `{ type: 'element:number', properties: { object: 'deal', aggregate: 'count', filter: [...] } }` | `{ type: 'element:number', dataSource: { object: 'deal', filter: [...] }, properties: { aggregate: 'count' } }` | + | `{ type: 'element:repeater', properties: { object: 'deal_note', sort: [...], titleField: 'subject' } }` | `{ type: 'element:repeater', dataSource: { object: 'deal_note', sort: [...] }, properties: { titleField: 'subject' } }` | + | `object-grid` `properties: { defaultFilters: [...] }` (no `filter`) | `properties: { filter: [...] }` | + | `object-grid` `properties: { filter: [...], defaultFilters: [...] }` | `properties: { filter: [...] }` — the fallback was never read beside a `filter` with rules | + + **The one-line fix: move each key from `properties` to the node's `dataSource` (a sibling of `type`), unchanged; rename `defaultFilters` to `filter` where `filter` is empty, and delete it where it is not.** + + **For a consumer that pins both repositories:** its objectui pin moves past objectui#11880 no later than its objectstack pin moves past this release — the converted shape is one only that objectui reads. + + **Who is affected, measured.** This repository authors none of the eleven keys: its one element-layer author (the showcase record picker) already binds through `dataSource`, and a tree-scoped absence pin (`packages/spec/src/ui/element-flat-binding-retirement.test.ts`) keeps it that way. Other repositories and deployed metadata were not measured here. + + ### The retirement kit + + - `RETIRED_KEYS_BY_MAJOR[18]`: `ui/ElementRecordPickerProps:object|filter|sort|limit`, `ui/ElementNumberProps:object|filter`, `ui/ElementRepeaterProps:object|filter|sort|limit`, `ui/ObjectGridProps:defaultFilters`. D3 entries `element-flat-data-binding-retired` and `object-grid-default-filters-retired`, each with a step-18 rationale fragment. They absorb the three protocol-18 narrowings of the same keys to the rule array (`element-number-filter-rule-array`, `element-record-picker-filter-rule-array`, `object-grid-default-filters-rule-array`), whose keys are gone in the same major; `page-component-filter-record-to-rule-array` drops the retired doors from its reach. + - Generated: `authorable-surface/ui.json` (11 rows `[RETIRED]`) and the `ui/component` reference page. Hand-edited ledger: `dropped-refinements.baseline.json` loses the three rows whose only dropped refinement was a retired `filter`. + - Pins: the tombstones, the binding, both conversions and the tree-scoped absence walk in `element-flat-binding-retirement.test.ts`; the missing-binding refusal and the repeater trap in `validate-component-props.test.ts`; the docs gate's twin in `check-yaml-examples.ts --self-test`. +- 615c696: Each operand of a `get_record`, `update_record` or `delete_record` node's `filter` is a value slot now. A field's value, an operator's comparand and each element of a list comparand take a CEL value envelope, `{ id: { dialect: 'cel', source: 'record.id' } }`, which the executor evaluates in the run's scope before the query, or a literal handed to the query as written. A query placeholder — `{current_user_id}`, `{current_org_id}`, `{today}`, `{30_days_ago}` and the rest of the date macros — is such a literal: the query engine resolves it, and it passes untouched, as before. Every other single-brace `{…}` token at an operand is refused with its envelope: at `objectstack validate`, at `registerFlow`, at the metadata save door and by the executor's parse. An operand envelope that evaluates to `null` refuses the node, as a token that resolved to nothing did. + + Clause-②: yes (narrowing: a flow {…} token at a get_record, update_record or delete_record filter operand is refused, a whole-string query placeholder excepted, an object naming a string dialect at an operand must be a valid CEL value envelope, an operand envelope that evaluates to null refuses the node, and @objectstack/lint no longer exports validateFlowFilterTokens, FLOW_FILTER_TOKEN_UNKNOWN or FlowFilterTokenFinding and retires the flow-filter-token-unknown rule id; widening: a filter operand gains the CEL value envelope arm, the exported FlowNodeExpressionPath type gains the optional filterOperands field, ValueSlotTemplateOptions gains filterOperand, FLOW_NODE_EXPRESSION_PATHS gains the three filter rows, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains get_record and delete_record, the published GetRecordConfig, UpdateRecordConfig and DeleteRecordConfig JSON Schemas mark filter as a value slot, and flowFilterOperands, FlowFilterOperand, classifyFlowFilterOperandString and FlowFilterOperandStringKind are new exports) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL — its contract table names "filter values" — and Decision 3 deletes the single brace. A CRUD filter was the last value position that read the single-brace dialect: the executor interpolated the whole filter, resolving a flow token against the run's variables and handing a token no variable resolved on to the query engine when it was a query placeholder. Two `{…}` vocabularies met in one string. The query placeholders are the query-filter surface, which ADR-0032 puts out of its scope, so they stay; the flow token goes. + + **One classifier.** `classifyFlowFilterOperandString` (`@objectstack/spec/automation`) draws the line: a string that is one whole wrapped token the query engine resolves on the server (`isKnownFilterToken`) is a placeholder and passes; any other string carrying a `{…}` token is refused; the rest is a literal. The node contracts, `registerFlow`, `objectstack validate` and the executor's parse all read it, through the value-slot judge. + + **What an operand is.** The operands are the positions a value sits at, read the way the filter grammar reads a `FilterCondition` (`flowFilterOperands`): a field's value, each entry of an operator map (`$gt`, `$in`, `$between`, …), each element of a list comparand, inside `$and`, `$or` and `$not` and inside a nested-relation condition. An envelope there is one operand, and may evaluate to a list (`{ id: { $in: { dialect: 'cel', source: 'record.ids' } } }`). The filter's keys, operators and combinators are its structure, never evaluated. The top-level `filter` is never an operand. + + **What changes where a value may be absent.** The template erased a condition whose token resolved to nothing, and the node was refused. Under CEL an absent variable or key fails the run before any query, naming it, and an envelope that evaluates to `null` — a guarded form, a field that holds no value — refuses the node with the guard's message: a condition whose comparand is `null` or nothing does not narrow a query, it widens it. So route around the node with a `decision` where the value may be absent, rather than guarding it to `null`. To match an empty field, write the literal `null` (or `{ $null: true }`), never a computed one. + + **A flow variable named like a placeholder no longer shadows it.** Under the template a flow variable named `today` or `current_user_id` won over the placeholder of the same name. No flow token is read at an operand now, so the placeholder is always the query engine's; read the variable with an envelope, `{ dialect: 'cel', source: 'today' }`. No flow, variable or output variable in this repository is named like a placeholder. + + **A related field named `dialect`.** An object at a field's value that names a string `dialect` is read as an envelope. A nested-relation condition on a related object's field named `dialect` is written through an operator map, `{ rel: { dialect: { $eq: 'x' } } }`. No object in this repository declares such a field. + + **The lint rule `flow-filter-token-unknown` retires.** It reported a `{FUNC()}` token neither the flow template dialect nor the query placeholders define at a flow filter. Every token it reported is refused now by the value-slot judge at the same position and severity, with the envelope that replaces it, so the rule, its `os explain` entry and its exports (`validateFlowFilterTokens`, `FLOW_FILTER_TOKEN_UNKNOWN`, `FlowFilterTokenFinding`) are removed rather than left reporting the same defect a second time with the retired dialect's remedy. A consumer moves to the value-slot judge's findings: rule id `flow-filter-token-unknown` → `expression-invalid` (`EXPRESSION_INVALID`); `validateFlowFilterTokens(stack)` → `validateStackExpressions(stack)`, which reports each refused operand located at its node and `config.filter.`; `FlowFilterTokenFinding` → `ExprIssue`; `FLOW_FILTER_TOKEN_UNKNOWN` → `EXPRESSION_INVALID`. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `filter: { id: '{record.id}' }` | `filter: { id: { dialect: 'cel', source: 'record.id' } }` | an absent `record.id` fails the run before the query instead of refusing the node | + | `filter: { amount: { $gt: '{record.cap}' } }` | `filter: { amount: { $gt: { dialect: 'cel', source: 'record.cap' } } }` | a `null` cap refuses the node, as before | + | `filter: { id: { $in: ['{a}', '{b}'] } }` | `filter: { id: { $in: [{ dialect: 'cel', source: 'a' }, { dialect: 'cel', source: 'b' }] } }`, or one envelope evaluating to the list | — | + | `filter: { owner: '{$User.Id}' }` | `filter: { owner: { dialect: 'cel', source: 'current_user.id' } }` | in a run with no user `current_user` is `null` and the read fails: skip the node with a `decision` on `current_user != null` | + | `filter: { due: { $lt: '{TODAY() - 3}' } }` | `filter: { due: { $lt: { dialect: 'cel', source: 'isoDate(daysAgo(3))' } } }` | none for a whole number of days | + | `filter: { owner: '{current_user_id}' }`, `{ due: { $lt: '{today}' } }` | unchanged | a query placeholder stays the query engine's | + | `filter: { owner: '{today}' }` meaning a flow variable named `today` | `filter: { owner: { dialect: 'cel', source: 'today' } }` | the placeholder no longer shadows the variable | + + **The one-line fix: write each filter operand the flow computes as a CEL value envelope, and keep the query placeholders as they are.** + + **Who is affected, measured.** In this repository, at the base: seven flow tokens at a filter operand in the shipped examples (`app-crm` 2, `app-showcase` 3, `app-todo` 2 — `app-todo`'s two query placeholders stay), five in the docs (`getting-started/common-patterns`, `kernel/runtime-services/examples` 2, the `service-automation` README 2), the flows guide's and the spec's own remedy texts (a `$User.Email` read named `filter: { id: '{uid}' }`; a `recordId` refusal named `filter: { id: '{record.id}' }`) and the `FlowSchema` example; all are migrated in this change. hotcrm was not measured. + + **Still accepted, unchanged.** Every literal operand, every query placeholder, a filter's keys, operators and combinators, an empty filter, and a filter on `create_record`, which has none. The `filters` alias still converts to `filter` at load. + + ### The kit + + - **The contract.** `GetRecordConfigSchema`, `UpdateRecordConfigSchema` and `DeleteRecordConfigSchema` judge each filter operand: an envelope must be a valid CEL value envelope, and a `{…}` token in a literal is refused by the value-slot judge in its filter mode, naming the operand's envelope and what `null` does there. Each `filter`'s `describe()` and TSDoc is restated, and so are the `recordId` guidance and the run-user remedy, which named a single-brace filter value. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains a `filter` row per node, with the new `filterOperands` flag: both walks emit each operand at its own path (`filter.amount.$gt`, `filter.id.$in[0]`), never the filter itself. `GetRecordConfigSchema` and `DeleteRecordConfigSchema` join `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`. + - **The executor.** The CRUD executors parse their config as authored, evaluate each operand envelope through `AutomationEngine.evaluateValueEnvelope` before the query, hand every other operand on as written, and refuse the node — as a guard a `fault` edge cannot route — when an operand evaluates to `null`. The single-brace `interpolateFilter` is deleted. + - **The lint.** `objectstack validate` refuses a token at each operand position and judges an operand envelope as CEL through the shared judges; the wrong-syntax hints at a filter operand name its envelope; `flow-filter-token-unknown` retires. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 7958bdf: No flow position reads the single-brace `{…}` template dialect any more, and its resolver is deleted (ADR-0032 Sequencing 6: "delete the single-brace resolver"). `AutomationEngine.evaluateCondition` evaluates every condition as CEL: a condition carrying a `{var}` hole is refused as the CEL brace trap, naming its source, and a `template` or `flow` envelope is refused before evaluation, where the deleted path substituted the variables and compared the text. A literal in a value slot is handed on as written: a `Date` in a code-defined flow reaches the write as the `Date`, where the deleted interpolator wrote `{}`. + + Clause-②: no (narrowing) + + + + **BREAKING**: a narrowing of a published runtime method and of published lint exports, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 3 keeps one template delimiter, `{{ }}`, and its Sequencing 6 deletes the single-brace resolver. The earlier changes on this line retired the dialect position by position — every value slot, the text slots, each CRUD filter operand — so the resolver's last readers were two calls that saw literals only and a condition path that only a direct call reached. + + **What changes.** + + - **Conditions.** `AutomationEngine.evaluateCondition(condition, variables, context?)` reads every condition as CEL — bare text, an envelope with `dialect: 'cel'`, or one naming no dialect. The template path it kept for a string carrying a `{var}` hole is deleted: such a string is refused as a CEL fault that names its source and says not to wrap field references in `{…}`, and an envelope with `dialect: 'template'` or `dialect: 'flow'` is refused before evaluation. `registerFlow` already refused both at every condition slot (a start node's `condition`, an edge `condition`, a `decision` branch's `expression`, a screen field's `visibleWhen`), so no registered flow changes. What changes is a direct caller: a plugin calling the method, or `@objectstack/verify`'s `stack.automation.evaluateCondition`, which hands the condition over as given. + - **Value-slot literals.** A literal in a value slot — a `fields`, `input` or `templateData` value, an `assignment` value in any of its three shapes, a `notify` recipient, an `http` `body`, a screen's `defaults`, `defaultValue` or `recordId` — is handed on as a copy of what was written (arrays and plain objects copied, every other value as it is). The deleted interpolator walked every object's own entries, so a `Date` written in a code-defined flow reached the write as `{}`; it reaches it as the `Date`. + - **`@objectstack/lint`.** `validateFlowTemplatePaths` reads the `{{ }}` holes of a node's strings and no single-brace token; a CRUD node's `filter` is not scanned, since each operand is a value slot that renders no template. So `flow-template-unknown-field` and `flow-template-lookup-traversal` report only `warning` now: their `error` at a filter operand, and every finding on a single-brace token, are gone, and the value-slot and text-slot judges refuse those tokens at the same doors as `expression-invalid`. Messages and hints spell a hole `{{ record.x }}`. `flow-double-brace-interpolation` and `flow-bare-dollar-reference`, at a string outside every value slot, text slot and declared literal (a plugin node's own config, a `connector_action` input), say the value is read as written, where they prescribed a `{var}` token nothing reads. The lint's mirror of the retired grammar is removed with its exports. + - **`@objectstack/spec`.** The `FlowSchema` example's `connector_action` input holds a literal, since nothing reads a token there. The upgrade guide's step-18 text-slot entry says no other flow string reads the single-brace dialect, where it said every other one kept it, and its value-slot entry names every value slot the retirement reached on this line, where it named only the `assignment` values and the CRUD `fields` map. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `engine.evaluateCondition('{amount} > 100', vars)` | `engine.evaluateCondition('amount > 100', vars)` | the template substituted text and compared it; CEL compares the values and fails the run on an absent variable | + | `"{status} == 'open'"`, `'{status} == open'` | `"status == 'open'"` | a string literal is quoted | + | `{ dialect: 'template', source: "{flag} == true" }`, `dialect: 'flow'` | `{ dialect: 'cel', source: 'flag == true' }` | — | + | `classifyFlowTemplateToken(inner)` (`@objectstack/lint`) | `valueSlotTemplateRefusals(value)` / `textSlotTemplateRefusal(text)` (`@objectstack/spec/automation`), which refuse a token with the spelling that replaces it | the grammar it classified is read by nothing | + | `FLOW_TEMPLATE_DATE_FUNCTIONS`, `FLOW_TEMPLATE_VALUE_FUNCTIONS` | the CEL stdlib: `isoDate(today())`, `isoDatetime(now())`, `round`, `floor`, `ceil`, `abs`, `min`, `max` | — | + | `FlowTemplateTokenVerdict` | nothing: the judges' reading of the retired grammar is internal to `@objectstack/spec` | — | + + **The one-line fix: write a condition as CEL, without `{…}` around a reference.** + + **Who is affected, measured.** In this repository at the base: no braced condition and no `template` or `flow` condition envelope in `examples/`, the docs or any package source; the deleted path's own pins in `@objectstack/service-automation` (`engine.test.ts`, `nested-region-parity.test.ts`) and `@objectstack/verify`'s door test are re-pinned to the refusal here. No authored `Date` literal in a flow was found; a compiled artifact serialises one to text, so only a flow handed to `registerFlow` in code carried one. Neither repository imports the removed lint exports. hotcrm at `0f84c9c` carries no braced condition and no `connector_action` input; its flows still author the single-brace dialect at value and text positions earlier changes on this line refuse, which its own upgrade migrates. + + ### The kit + + - **The engine.** `AutomationEngine.evaluateCondition` (`@objectstack/service-automation`): the template path, its hole discriminator and its text comparison are deleted. The value-slot resolvers' literal arm and the `assignment` executor copy a literal (`copyLiteral`, `builtin/value-slot-map.ts`). The single-brace interpolator (`interpolate`, `interpolateString`, `resolveToken`, its function table and `FlowExpressionFunctionError`), which the package did not export, is deleted with its tests. + - **The lint.** `validateFlowTemplatePaths` and the two wrong-syntax hints (`lint-flow-patterns.ts`); `classifyFlowTemplateToken`, `FLOW_TEMPLATE_DATE_FUNCTIONS`, `FLOW_TEMPLATE_VALUE_FUNCTIONS` and `FlowTemplateTokenVerdict` are removed. + - **The verify handle.** `stack.automation.evaluateCondition`'s documentation states the engine's reading: CEL, a `{var}` hole refused. + - **ADR-0087.** No new entry: the step-18 D3 entries `flow-value-slot-template-dialect-refused` and `flow-text-slot-single-brace-refused` record the retirement. +- 0e9e7b7: A flow VALUE slot no longer reads the single-brace `{…}` template dialect: a `create_record` / `update_record` `fields` value or an `assignment` value that carries a `{…}` token is refused at `objectstack validate`, at `registerFlow` and by the executor, with the CEL spelling of each token. A computed value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`; a string is the literal text it spells. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** Since #19938 the value slots also evaluate a CEL value envelope, so a flow had two dialects for one job — two function sets, two meanings of `/`, and a template that wrote nothing where CEL refuses. The maintainer's ruling D on the flow expression dialects put the template's retirement from these slots on the v18 train, with a remedy for every spelling and an automatic conversion only where one is lossless (ADR-0087 D2). + + **What is refused.** In every value slot — `create_record.fields.*`, `update_record.fields.*`, the `assignment` node's `assignments` map, and the two legacy `assignment` shapes the executor still reads (the `assignments: [{ variable, value }]` array and the bare `{ : }` config) — a string, or a string at any depth of an array or object value, that carries a `{…}` token the interpolator would resolve. One judge says it everywhere (`flowNodeValueTemplateRefusals` / `valueSlotTemplateRefusals` in `@objectstack/spec/automation`): `FlowValueSlotSchema`, `AssignmentValueSchema`, `CreateRecordConfigSchema`, `UpdateRecordConfigSchema` and `AssignmentConfigSchema` refuse it at the value's path; `objectstack validate` reports it as `expression-invalid` at `error`, located at the node and `config.`, which also refuses it at the metadata save door; `registerFlow` refuses the flow (a stored flow carrying one is skipped at boot with a warn naming it); and the `create_record` / `update_record` / `assignment` executors refuse the node before writing anything. Each refusal leads with `VALUE_SLOT_TEMPLATE_REFUSAL` and then names the CEL spelling of the string's tokens. + + **Not converted.** No D2 conversion rewrites any spelling: every token spelling authored in flows answers differently under CEL for some input (measured through both engines over the same variables, 13 of 25 probes the same, 12 different), so a rewrite would change what some flow writes. Where a value may be absent, whether the field should take nothing, `null` or a default was decided silently by the template; it is now the author's decision. + + **What is still accepted, unchanged.** A CEL value envelope and every literal (a token-free string, numbers, booleans, `null`, arrays, objects). A text slot (`notify` `title` / `message`, a screen `description`, …) reads `{{ }}` holes and refuses a single-brace token. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — guard one that may be absent a step at a time from `vars`: `has(vars.record) && has(vars.record.owner) ? vars.record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) | + | `'{items.0}'` | `{ dialect: 'cel', source: 'items[0]' }` | an empty list fails the run | + | `'{$error.message}'` | `{ dialect: 'cel', source: 'vars["$error"].message' }` | a `$`-named variable is read through `vars` | + | `'{round(x * 100) / 100}'` | `{ dialect: 'cel', source: 'round(x * 100) / 100.0' }` | CEL divides two integers as integers: keep a decimal operand on every division, or `123.46` becomes `123` | + | `'Renewal — {contract.number}'` | `{ dialect: 'cel', source: "'Renewal — ' + contract.number" }` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` | + | braces meant literally, `'{"a": 1}'` | `{ dialect: 'cel', source: "'{\"a\": 1}'" }` | a CEL string literal | + + **The one-line fix: write the CEL spelling the refusal names, and guard a value that may be absent.** + + **Who is affected, measured.** A TypeScript-AST census of every `create_record` / `update_record` `fields` value and `assignment` value (all three shapes, same-file spreads included): this repository at `959c209d5` carried 21 authored sites (`examples/**` and `packages/verify/src`) — 20 refused by this change (9 bare references, 10 dotted paths, 1 `$error` path, all migrated in this change) and 1 `{$User.Id}` (`examples/app-todo`), which the run-user refusal on this line refuses and migrates; hotcrm at `c529de2` carries 91 (2 of them through a same-file spread) — 71 refused by this change (31 bare references, 36 dotted paths, 4 text with holes) and 20 that the date-macro and run-user refusals on this line refuse (15 date macros, 5 `{$User.Id}`). Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `automation/flow-value-slot-template.ts` (`VALUE_SLOT_TEMPLATE_REFUSAL`, `valueSlotTemplateRefusals`, `flowNodeValueTemplateRefusals`), composed into the value-slot contracts in `automation/builtin-node-config.zod.ts`; one new dropped-refinement site (`automation/AssignmentConfig` `out.catchall`, the bare legacy shape's values). + - **The doors.** `AutomationEngine.registerFlow` and the `create_record` / `update_record` / `assignment` executors (`@objectstack/service-automation`); `validateStackExpressions` (`@objectstack/lint`), whose `warning` hint pointing a template expression at the envelope this refusal replaces. + - **The ledger.** The D3 semantic entry `flow-value-slot-template-dialect-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: no authored spelling maps losslessly. +- 2b61f2d: A flow TEXT slot — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message` — reads ADR-0032 §3's `{{ }}` template holes, rendered by the formula template engine over the flow's variables. A single-brace `{…}` token in one is refused at `objectstack validate`, at `registerFlow` and by the node's contract, with the `{{ }}` spelling of each token. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 3 fixes one template delimiter: "One delimiter, `{{ }}`; single `{ }` deleted." The notify pair was already typed as `template` slots, while the executor read the single brace, so a flow carried two template dialects and an author who met both mixed them. + + **What changes.** + + - **The renderer.** The text slots render through one renderer inside `@objectstack/service-automation` (package-internal; the package's public exports do not change): `{{ path }}` and `{{ path | formatter[:arg] }}` holes, every other character literal. A hole reads a flow variable by name (`{{ record.name }}`, a node output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or `{{ rows[0].subject }}`, and a `$`-named one: `{{ $error.message }}`). `null` and an absent path render nothing, an object or array renders as JSON, a `Date` as its ISO text; the ADR-0032 formatters make the rest explicit (`{{ amount | currency }}`, `{{ due | date:long }}`). A hole holds no logic. + - **The refusal.** One judge in `@objectstack/spec/automation` (`textSlotTemplateRefusal`, `flowNodeTextSlotSources`, `FLOW_NODE_TEXT_SLOTS`, `TEXT_SLOT_TEMPLATE_REFUSAL`): `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` refuse a single-brace token at the slot's key; `registerFlow` and `objectstack validate` (`expression-invalid`, `error`) refuse it with the same words and also compile every slot's holes, so `{{ a + b }}` or an unknown formatter is refused before a run. A stored flow carrying one is skipped at boot with a warn naming it. Past the doors, a hole that does not compile fails the node with a guard refusal, which a `fault` edge does not route. + - **`@objectstack/formula`.** A template hole's path may contain `$` (`{{ $error.message }}`): a widening of the hole grammar, so a host scope's `$`-named variable has a template spelling. An unbound one renders nothing, like any unknown path. + - **`flow-double-brace-interpolation`** no longer flags `{{ }}` on the text slots, and its hint names them as the only `{{ }}` positions; a bare `$ref.x` outside the holes of a text slot is flagged with the hole spelling. + - **Unchanged:** the value slots are CEL's. + + **Not converted.** No ADR-0087 D2 conversion rewrites `{x}` to `{{ x }}`. The two renderers were run over the same variables: a path renders the same text for a string, a number, a boolean, `null`, an absent key or variable, an ISO date string, an object or an array, but a `Date` value rendered JSON-quoted under the 17.x interpolator (`"2026-10-08T09:30:00.000Z"`, quotes included) and as its ISO text now, and a screen or `end` text that was one token holding an object, an array or a `Date` rendered `String(value)` (`[object Object]`). So the rewrite is the author's to check; the D3 record is the semantic entry `flow-text-slot-single-brace-refused`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `title: 'Deal won: {record.name}'` | `title: 'Deal won: {{ record.name }}'` | + | `message: 'Failed: {$error.message}'` | `message: 'Failed: {{ $error.message }}'` | + | `message: 'Total {amount * 2}'`, `'{round(x)}'` | compute it first — `assignments: { v: { dialect: 'cel', source: 'amount * 2' } }` — then `'Total {{ v }}'` (a number format is a formatter: `{{ v \| number:2 }}`) | + | `message: 'Due {TODAY() + 7}'`, `'By {$User.Id}'` | compute it first with an `assignment` node's CEL value envelope — `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' }, by: { dialect: 'cel', source: 'current_user.id' } }` — then `'Due {{ due }}'`, `'By {{ by }}'` | + + **The one-line fix: double the braces of every path token in a text slot (`{x}` → `{{ x }}`), and compute anything else into a variable first.** + + **Who is affected, measured.** This repository's in-tree text-slot sites — `examples/app-showcase` (24 strings, 30 tokens) and `examples/app-todo` (5 strings, 7 tokens), all of them paths — are rewritten in this change, with the docs pages that taught the single brace (`content/docs/automation/flows.mdx`, `content/docs/getting-started/common-patterns.mdx`). Other repositories and deployed metadata were not measured here. +- 5fb1746: A flow CEL expression that reads a root the flow does not bind is refused at `objectstack validate`. The run fails such an expression with `Unknown variable: X` (X being the root) on every evaluation, and the build door used to pass it. The reachable case is the run user: `user.id == "u1"`, `ctx.user.id == "u1"` and `os.user.id == "u1"` — the spellings formulas, row-level security and the client accept — are refused, naming `current_user`, the one spelling flow CEL binds. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface (`objectstack validate`'s `expression-invalid` rule). + + **Why.** Flow CEL evaluates in one scope per run: the flow's variables spread to top level, the trigger record's fields flattened beside them, `record` bound to the record the run was handed (where an entrance hands it one), and `previous`, `vars` and `current_user` bound by the engine. Formulas, row-level security and the client also bind the run's user as `user`, `ctx.user` and `os.user`; flow CEL does not, because a top-level `user` would collide with a variable or a lookup field of that name. So an author who wrote the alias by habit shipped a flow that faulted at its first evaluation, and nothing warned. + + **What is refused.** At every flow CEL site the build door reads — a node's `config.condition` (the start node's trigger gate included), an edge `condition`, a `decision` branch `expression`, a screen field `visibleWhen`, and the CEL value envelopes of an `assignment` and of a `create_record` / `update_record` `fields` map — a root that is none of: + + - the engine's roots: `previous`, `vars`, `current_user`; + - `record`, where an entrance the stack declares hands the flow a record — a record trigger (a `record-*` `triggerType`), a time-relative sweep, the inbound hook, a `type: 'flow'` action, a `map` node with an `itemObject`, or a `subflow` / `map` parent handed one — or where the flow binds a variable named `record`; + - a name the flow binds: a declared variable, an `outputVariable`, an `iteratorVariable` (a `loop`'s default `item` included), an `indexVariable`, an `errorVariable`, an `assignment` target, a node id, a screen field's `name`; + - a field (or a registry-injected column) of any object the stack declares — the run flattens whichever record its entrance hands it (an object-less flow action hands only the row `id`). + + The user spellings are told to write `current_user`, with the guard for a flow that can run without a user; any other root is named with what could have bound it, and the nearest in-scope name. The error is `expression-invalid`, `error`. Where the flattened-scope near-miss advisory ("`X` is not a field of … If `X` is a flow variable this is safe to ignore") named the same root, the refusal replaces it. + + **Not judged — the door stands down where the run can bind a name it cannot see.** + + - **A node:** a `script` or `connector_action` node (each hands its code the live variable map), a `wait`, `subflow` or `map` node (a resume folds the caller's `variables` bag in under their plain names), a screen with no field list, or a node type outside the builtin set. + - **An entrance the stack declares that hands the flow a record whose keys are not in hand:** + - a record trigger (`config.objectName`) or a time-relative sweep (`config.timeRelative.object`) on an object the stack does not declare; + - an `api` trigger, whose inbound hook hands the request body in as the record; + - a `type: 'flow'` action launching the flow on an undeclared object; + - a `map` node feeding the flow whose `itemObject` is undeclared or absent; + - a `subflow` or `map` parent that is itself such a flow. + - **The runtime publish gate:** a flow write there is not judged for unbound roots, by design (rulings `5791822697` item 3 and `6104584601`). Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged. + + A `$` name has no CEL spelling (`$runId == "x"` does not parse), so the `$` roots stay the text-slot rule's. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `condition: 'user.id == "u1"'` | `condition: 'current_user.id == "u1"'` (`current_user != null && current_user.id == "u1"` where the flow can run without a user) | + | `condition: 'ctx.user.id == record.owner'` | `condition: 'current_user.id == record.owner'` | + | `assignments: { who: { dialect: 'cel', source: 'os.user.id' } }` | `assignments: { who: { dialect: 'cel', source: 'current_user.id' } }` | + | `condition: 'deals.size() > 0'` with nothing binding `deals` | declare it (`variables: [{ name: 'deals', type: 'list' }]`) or bind it with a node (`outputVariable: 'deals'`) | + + **The one-line fix: write `current_user` for the run user, and bind or correct every other root the refusal names.** + + **Who is affected, measured.** The last published spec line, 17.x, judged a flow expression's syntax only, so it accepts every refused source. Measured with the judge over every flow CEL site: the example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`: 35 flows, 45 sites), `packages/platform-objects` (no flows) and `objectstack-ai/hotcrm` at `f0afcbda07` (32 flows, 65 sites) — 0 refusals; every root those flows read is an engine root, `record` on a flow an entrance hands a record, a binding of the flow's own or a field of its trigger object. One flow of the 67 is left unjudged by an entrance (`showcase_inbound_task_webhook`, an `api` trigger). Deployed metadata and other repositories were not measured. + + ### The kit + + - **The judge.** `flow-cel-root-scope.ts` in `@objectstack/lint`, called by `validateStackExpressions` at each flow CEL site. One bound set per flow, read once like the shadowing pass's variable set, and one entrance index per stack (`flowCelEntrances`). + - **The ledger.** The D3 semantic entry `flow-cel-unbound-root-refused` (protocol 18). There is no D2 conversion: none of these roots ever evaluated in a flow, so there is no behaviour to carry over. +- 0f77ff5: A flow CEL expression that reads `record` on a flow no entrance hands a record is refused at `objectstack validate`. Since #22642 the run binds `record` only to the record it was handed, so such a read fails the run with `Unknown variable: record`, and the build door still counted `record` as bound on every run and passed it. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface (`objectstack validate`'s `expression-invalid` rule). + + **What is refused.** `record` is no longer one of the roots the build door binds on every run (`previous`, `vars` and `current_user` still are). It is bound where: + + - an entrance the stack declares hands the flow a record: a record trigger (start `config.triggerType` is a `record-*` token), a time-relative sweep (start `config.timeRelative`), the inbound hook (trigger kind `api`), a `type: 'flow'` action that targets the flow (on any object, or on none), a `map` node with a `config.itemObject` that names the flow, or a `subflow` / `map` parent that is itself handed a record; + - or the flow binds a variable named `record` itself (a declared variable or an `assignment` target). + + A start `config.objectName` alone is no entrance: with no `record-*` trigger nothing hands the run a row. A parent's own `record` variable is not handed on either: a child gets its parent's context, not its variables. Everywhere else `record.X` is refused as `expression-invalid`, `error`, with the remedy below. + + **Not judged.** The stand-downs of the unbound-root rule are unchanged. At the runtime publish gate this judgment still stands down: its per-write snapshot carries no actions and no other flows, so it cannot see an entrance, and a flow write there is not refused for `record`, by design (rulings `5791822697` item 3 and `6104584601`). + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `record.assignee` on a flow no entrance hands a record, meaning a flow variable `assignee` | `assignee`, or `vars.assignee` | + | `record.status == "open"` on a flow meant to run when a row is written | the trigger that hands it the row: start `config: { objectName: 'acct', triggerType: 'record-after-update' }` | + + **The one-line fix: read a flow variable by its name or through `vars`, and read `record` only on a flow an entrance hands one.** + + **Who is affected, measured.** With this judge over every flow CEL site: the example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`: 35 flows, 14 that read `record`), `packages/platform-objects` (no flows) and `objectstack-ai/hotcrm` at `1d7148b` (32 flows, 14 that read `record`): 0 newly refused. Every flow that reads `record` there is a record-change, time-relative or inbound-hook flow. Deployed metadata and other repositories were not measured. +- 32fa194: A retired page-component prop is refused at `objectstack validate`, `objectstack build` and `objectstack lint`. Before this change, a key that `ComponentPropsMap` tombstones with `retiredKey()` (`object-grid` `defaultFilters`, `element:record_picker` `displayField`, `page:header` `breadcrumb` and the rest) drew only a `component-props-invalid` warning. The command printed "✓ Validation passed" and built the artifact, while the conversion registry told authors the key is refused. It is now a `component-props-invalid` error carrying the tombstone's prescription, and the stack does not pass until the key is gone. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface (the `component-props-invalid` rule of `objectstack validate`, `objectstack build` and `objectstack lint`). + + **What is refused.** A value verdict a tombstone raises: the tombstone's own issue, wherever the key sits in the props bag, an array entry's key included. A tombstoned key inside a union member is refused when that member is the only one judging keys (`object-form` `sections[].fields[]` `publicPicker`). A retired element's props row (`user:profile`, `ai:chat_window`) raises the same issue and is refused the same way. + + **What stays a warning.** Every other `component-props-invalid` finding: a wrong type, a missing required prop, a closed-set refusal (a url action's `target` scheme, a `record:details` section's `count`), a retired enum value and a missing `dataSource.object` binding. Every `component-props-unknown-key` finding also stays a warning. + + **Not judged here.** The runtime publish door (the Studio, REST `/meta`, MCP) does not run this rule, so a page saved there is not refused for a retired prop. `defineStack` and the stack schema keep the open `properties` bag as written. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `object-grid` `properties: { defaultFilters: [...] }` with an empty `filter` | `properties: { filter: [...] }` | + | `element:record_picker` `properties: { displayField: 'title' }` | `properties: { labelField: 'title' }` | + | a retired prop whose prescription says to delete it, e.g. `page:header` `breadcrumb` | nothing: delete the key | + + **The one-line fix: apply the prescription the error prints.** Where an ADR-0087 conversion covers the key, `objectstack migrate meta --from ` lists the mechanical edits for existing sources. + + **Who is affected, measured.** At objectstack `21c86444e`, this rule ran over `examples/app-crm`, `app-todo`, `app-multi-package` and `app-showcase` (through `objectstack validate`), over the five shipped pages outside them (`@objectstack/platform-objects` ×3, `@objectstack/plugin-approvals`, `packages/qa/downstream-contract`) and over the `create-objectstack` template, which authors no page. Result: 0 newly refused, and 0 findings of this rule of any kind. Deployed metadata and other repositories were not measured. +- 4f68ba1: Every finding of the component-props gate is now an error at `objectstack validate`, `objectstack build` and `objectstack lint`. A page component whose `properties` carry a key its `ComponentPropsMap` row does not declare, a value that row rejects, or no `dataSource.object` binding where the element reads only that binding drew a warning: the command printed "✓ Validation passed" and built the artifact, and the renderer ignored the key or drew nothing. Each is now a `component-props-unknown-key` or `component-props-invalid` error, and the stack does not pass until it is fixed. A retired prop was already refused, and still is. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface (the `component-props-invalid` and `component-props-unknown-key` rules of `objectstack validate`, `objectstack build` and `objectstack lint`). + + **What is refused now.** Every verdict of the gate: + + - `component-props-unknown-key`: a key the component's props row does not declare, at the bag's own level, on a strict object or union arm below it, or on a strip-mode object below it. + - `component-props-invalid`: a value the props row rejects (a wrong type, a missing required prop, a closed-set refusal such as a url action's `target` scheme or a `record:details` section's `count`, a retired enum value), and a missing `dataSource.object` on `element:record_picker`, `element:number` and `element:repeater`. + + **Not judged here.** `defineStack` and the stack schema keep the open `properties` bag as written. A component `type` that `ComponentPropsMap` carries no row for (a `custom.*` or plugin type) is not judged. + + `ComponentPropsSeverity`, the type `@objectstack/lint` exports for these findings, narrows from `'error' | 'warning'` to `'error'`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `page:header` `properties: { titel: 'Deals' }` | `properties: { title: 'Deals' }`, the declared spelling the error names | + | `record:related_list` `properties: { limit: 'five' }` | `properties: { limit: 5 }`, the type the error names | + | `element:number` with no `dataSource` | `dataSource: { object: 'deal' }` on the component node, beside `type` | + | a key the component does not read | nothing: delete the key | + + **The one-line fix: apply what the error names.** An unknown-key error names the declared spelling when one is close, a value error names what the row expects, and the binding error names where the object goes. If the component really does read the key or value, the props row is what is wrong: correct the declaration in `@objectstack/spec`, not the page. + + The `@objectstack/spec` change is text only: the reason of each D3 migration entry named above said the gate reported its refusal as an advisory finding, and now says it is an error. + + **Who is affected, measured.** At objectstack `8323cad97`, this rule ran at its own input tier over `examples/app-crm`, `app-todo`, `app-multi-package` and `app-showcase`, and over the eight shipped pages outside them (`@objectstack/platform-objects` ×3, `@objectstack/plugin-approvals`, `packages/qa/downstream-contract`, `@objectstack/cloud-connection` ×2, `@objectstack/mcp`): 37 pages, 177 components judged. It also ran over `objectstack-ai/hotcrm` at `0f84c9c72`: 8 pages, 50 components judged. Result: 0 findings of every class in both. A planted undeclared key and a planted wrong-typed prop each drew one finding on every population. The `create-objectstack` template authors no page. Deployed metadata and other repositories were not measured. + +### Minor Changes + +- 913fd6d: fix(lint)!: a validation rule that reads, through a reference, a field the reference's target does not declare is refused where it is written + + Clause-②: no (narrowing) + + + + **BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The validation-rule pass judges the field a rule's `condition` reads one hop through a reference against the target object's fields: the fields it declares, the system columns the platform adds to it, and `id`. It is the same check the field-formula pass makes for a formula. `os validate`, `os build` and the object save door (`PUT /api/v1/meta/object/:name`) run the same pass, under the existing `EXPRESSION_INVALID` rule. It covers a `script` or `cross_field` rule's `condition`, at the top level and inside a `conditional` rule's `then` / `otherwise`. + + - FROM: `record.account.region == 'north'`, where `account` is a lookup to an object that declares no `region`, saved with a 200, and every write the rule judged was refused. TO: refused (`422 INVALID_METADATA` at the save door). The message names the field and the target: "unknown field `region` on ``". + - `record.account.status == 'closed'`, where the target declares `status`, passes as before. + - A target the stack does not carry is not judged. At the save door the stack carries the live registry's objects in the written package's closure, so a target from a package it depends on is judged. + - A reference read more than one hop deep (`record.account.owner.email`) keeps its own refusal and is not judged a second time. + + What to do. Read a field the target declares, or declare the field on the target object. Do not declare it on the object that carries the rule. At `os build` and `os validate` this pass does not see a target field that an `objectExtensions` entry contributes, while the save door judges the merged object. +- cbe6d87: fix(lint)!: the page save door refuses a page component `visibleWhen` that `os build` refuses (#24491) + + Clause-②: no (narrowing) + + A page component's `visibleWhen` was judged by `os validate`, `os build` and `os lint`, and not by the metadata save door. A page saved through Studio, REST `/meta` or an MCP/AI author answered `200` and was stored when a component predicate called a function nothing registers (such as `totallyBogusFn(1)`), read a bare field (`status == 'open'`), did not parse, or overran the CEL parse budget. At render such a predicate faults, the visibility fallback is `true`, and the component renders for everyone. + + The visibility-predicate family (`validateVisibilityPredicates`, `validatePredicatePathRefs`) now runs on a `page` write as well as on a `view` write, on the principle of ruling B on #23388: a page with a finding is fixed, then saved. The door's verdict is the build's finding: the same rule id (`visibility-predicate-unknown-function`, `visibility-bare-identifier`, `visibility-predicate-syntax`, `visibility-predicate-over-budget`), location (`pages[N].regions[N].components[N]`, and the slot or nested path the build names), message and hint. `visibility-root-mislayered` (a `data.`-rooted predicate on a page) rides the save response as an advisory, as it is a warning at the build. `validatePredicatePathRefs` judges schema-bound form views only, so it adds no finding on a page. + + **BREAKING — what moves for consumers.** This is an accept-set narrowing on the metadata save door, shipped as `minor` under the launch-window convention for accept-set narrowings (`.changeset/pre.json` is open in `next` pre mode). + + - FROM: a page write in publish mode answered `200` for a component `visibleWhen` the build refuses. This covers `PUT /api/v1/meta/page/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/page/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - TO: it answers `422 INVALID_METADATA`, with the build's issue located at that component. + - Fix: correct the predicate the issue names, as `os build` already requires, and save again. Call a function `introspectScope` lists, read through a bound root (`record.FIELD`, `current_user.FIELD`, `page.VAR`), and fix the syntax the verdict quotes. Saving the page as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **The next save of a stored page whose component `visibleWhen` the build refuses 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. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. + - A `view` write is judged as before. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: the 36 pages this repository ships (the examples, `platform-objects`, `plugin-approvals`, `cloud-connection`, `mcp` and `qa/downstream-contract`) carry 8 component `visibleWhen` predicates. Neither rule finds anything in them, at the build or at the door, before this change or after it; deployed rows were not measured. + - No public export or signature moves. The two registry entries gain `page` in `runtimeTypes`, and the gate itself is unchanged: `page` already mapped to `pages`. + + +- 9f12d06: fix(lint)!: the save door refuses a list view written into a package and bound outside that package's closure (#24563) + + Clause-②: yes (narrowing) + + **Why.** The runtime save door judges a write into a package against that package's closure (ruling #9612). The closure is the package's own objects, the objects of the packages its manifest declares in `dependencies`, platform/system objects, and provenance-less overlay rows; every other object is dropped from the snapshot before any rule runs. The list-view field rule read a binding missing from that snapshot as "an object this stack does not define" and skipped the whole list view. So a list view written into a package and bound to an object of a package it never declared, or to an object that exists nowhere, was stored with every field reference unjudged: `200`, no advisory. The same body as an overlay write is refused `422 list-view-field-unknown` for an unknown `columns` field. No other rule judged the binding on a `view` write, and such a view then fails at render ("This view's query was rejected"). + + **What is refused.** A list view written into a package whose bound object is outside the package's closure is refused with one `list-view-object-outside-closure` issue. The issue is located at the key the binding is read from: `views[0].data.object` for a standalone list view, otherwise the container's `objectName` / `object`. The hint names the remedy. An object that exists nowhere is refused the same way. This is the verdict the dataset door already gives an out-of-closure base object under the same closure (`object-reference-unknown`). An object written into a package whose built-in `listViews` entry retargets `data.object` outside the closure is refused the same way. The list view's field references stay unjudged until the binding is inside the closure. + + **BREAKING — what moves for consumers.** This is an accept-set narrowing on the metadata save door, shipped as `minor` under the launch-window convention for accept-set narrowings (`.changeset/pre.json` is in `exit` mode, so the next version pass cuts 18.0.0 and this change ships in it). + + - FROM: a publish-mode write of a list view into a package, bound outside the package's closure, answered `200` and was stored, with no issue. This covers `PUT /api/v1/meta/view/:name?package=…` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/view/:name/publish`, `publishMetaItem`), a package draft publish (`publishPackageDrafts`), and a package copy (`duplicatePackage`), which writes its copies through the same door. + - TO: `saveMetaItem` in publish mode, the draft promotion and `publishPackageDrafts` answer `422 INVALID_METADATA`, with the `list-view-object-outside-closure` issue at the binding, and nothing is stored. A package draft publish fails the item, and the batch with it. In `duplicatePackage`, a copied list view whose bound object stays outside the copy package's closure is refused with the same rule and listed in its `failed[]`. One way that happens is when that object's own copy was refused. + - Fix: declare a dependency on the package that defines the bound object in the `dependencies` of the writing package's manifest, or bind the view to an object the package may reach (its own, a declared dependency's, or a platform object). Then save again. Once the object is inside the closure its fields are judged: an unknown `columns` field is refused `422 list-view-field-unknown`, as at the overlay door. Saving the view as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **The next save of a stored list view bound outside its package's closure is refused with the fix; stored views keep serving until then.** The door judges writes only. A stored row is not re-judged at boot, and it loads and is served as before. + + **Unchanged.** + + - A list view bound to an object inside the closure (the package's own, a declared dependency's, a platform/system object, an overlay row): same field verdicts, same refusals. + - A write that names no package (an overlay write) is judged against the whole collection, exactly as before. An object that exists nowhere stays unjudged there. + - The flow door: `flow-trigger-unknown-object` stays an advisory. + - `os validate`, `os build` and `os lint`: no closure is stated, so the rule skips an unresolvable binding as before. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: the 11 views shipped in this repository carry 39 list-view bindings, in `examples/app-crm`, `app-todo`, `app-showcase` and `app-multi-package`, the `create-objectstack` blank template, and `qa/downstream-contract`. None is outside its package's closure, and the door's `view` rule set refuses none of them, with the package's closure or without it. None of the 97 platform built-in list views retargets `data.object`. Deployed rows were not measured. + + **API.** + + - `AuthoringRuleContext` gains an optional `packageScope` (`{ packageId, dependencies }`). Only the runtime publish gate sets it, and only when it narrowed the snapshot's objects to a package closure. `runAuthoringRules` never sets it. + - `validateListViewFieldRefs(stack, options?)` accepts an optional `{ packageScope }` (`ListViewFieldRefOptions`, exported). + - `LIST_VIEW_OBJECT_OUTSIDE_CLOSURE` (`'list-view-object-outside-closure'`) is exported. + + +- f85a83b: fix(lint)!: the object save door refuses a formula field whose expression `os build` refuses (#22019) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. At the object save door it did not. A formula field calling an unregistered function, such as `sqrt(record.amount)`, was refused by `os build` as an unknown function, but `PUT /api/v1/meta/object/:name` answered 200, stored it, and the field read `null` on every row. + + The runtime publish gate now runs the build's own formula check on an object write. The registry entry for the build's expression rule (`validateStackExpressions`) declared the flow, action and hook writes and never the object write, so the gate never dispatched it there. It now declares `object` as well, for one of its passes: a formula field's `expression`. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' expression`), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for a formula field whose expression the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that field's `expression`. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), and the other errors in the build's formula check. Its warnings now ride the save response as advisories, as they already did for a flow write. + + **Remedy.** Fix the expression: the message names the unknown function or field and the position, as `os build` already requires. Use one of the functions `introspectScope` lists, qualify field reads as `record.FIELD`, or compute the value in a stored field and reference it. Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps reading, with the formula still `null`, until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. + - This entry's crossing covers formula fields alone. The other expressions an object carries (validation-rule predicates, the field-rule slots `requiredWhen`, `readonlyWhen` and `visibleWhen`, option `visibleWhen`, and the object's own action predicates) are judged at this door through their own #22032 entries, each its own crossing, measured over the stored corpus first. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every formula field this repository ships has 0 refusals and 0 advisories at the door. That is 29 fields on 28 objects: examples 7 on 6, and the platform `display_title` formulas 22 on 22. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature. The registry entry reaches the passes through an internal function that is not on the package's entry. The built entry declarations differ only in one doc comment, on `AuthoringRuleContext.runtimeWriteType`. + + +- 73a0a6b: fix(lint)!: the object save door refuses an object action's `visible` or `disabled` predicate that `os build` refuses (#22032) + + Clause-②: no (narrowing: the object save door refuses an action visible or disabled predicate os build already refuses) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For an object's own actions it did not, at the object save door. An action whose `visible` read a bare field, such as `amount > 1`, or called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The action runtime evaluates such a predicate fail-closed, so the action was hidden on every record. + + The runtime publish gate now runs the build's action check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields, validation-rule predicates, the field-rule slots and option `visibleWhen`. On an object write it now also judges each `actions[].visible`, and each `actions[].disabled` that is not a boolean literal, as a predicate over `record`, the way the build does. Every expression an object carries is now judged at this door. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · action 'ACTION' visible`, or `disabled`), message and hint. One location differs, never the verdict: an action that is also declared in the stack's top-level `actions` is located by `os build` at `stack · action 'ACTION' visible`, and by the door, which judges the object alone, at the object. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for an action whose `visible` or `disabled` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that action. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), and a syntax error. Its warnings now ride the save response as advisories. + - The platform's own `sys_approval_request` (shipped by `@objectstack/plugin-approvals`) carries 8 action `visible` predicates that read `record.viewer`, a block the approvals service attaches on read and the object does not declare. The shared validator refuses all 8 (``unknown field `viewer` on `sys_approval_request` ``). Until #22211 lands, a save of that packaged object answers as follows. The first two rows were measured on the showcase's `objectstack dev` boot; the third is a source reading. + + | kernel and setting | publish save, before → after | draft save | + |:--|:--|:--| + | host-config kernel, no `OS_METADATA_WRITABLE` | 403 `NOT_OVERRIDABLE` → 422 `INVALID_METADATA` (8 issues) | 403 `NOT_OVERRIDABLE` | + | `OS_METADATA_WRITABLE=object` | 200 → 422 `INVALID_METADATA` (8 issues) | 200 | + | environment-scoped kernel | 403 `NOT_OVERRIDABLE`, unchanged | 403 `NOT_OVERRIDABLE` | + + On a host-config kernel the authoring gate answers before the package door, so there the 422 is returned where the more basic 403 belongs; #22220 carries that order. The object itself is unchanged: it registers, is served, and its actions evaluate as before. + + **Remedy.** Fix the predicate: the message names the unknown function or field, the bare reference, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and write a boolean literal (`true` / `false`) for a constant. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. For `sys_approval_request` there is nothing to repair on the consumer side: its predicates are the platform's, and #22211 carries the fix. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - The stack's top-level `actions`, its flows, its sharing rules and its hooks are not judged on an object write: each is judged at its own type's door, as before. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every object this repository ships (118 objects from its `*.object.ts` files) carries 58 action predicates on 16 objects, and the example stacks as `defineStack` composes them (33 objects, standalone actions merged in) carry 59 on 5. The shared validator refuses the 8 on `sys_approval_request` above and nothing else, and the door refuses the same 8 after this change and none before it. There are 0 other refusals and 0 advisories, at both doors. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. The built entry declarations differ only in one doc comment, on `AuthoringRuleContext.runtimeWriteType`. + + +- f2a45db: fix(lint)!: the object save door refuses a field-rule slot whose predicate `os build` refuses (#22032) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field's rule slots it did not, at the object save door. A field whose `requiredWhen` read a bare field, such as `amount > 1`, or whose `visibleWhen` called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. + + The runtime publish gate now runs the build's field-rule-slot check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields and validation-rule predicates. On an object write it now also judges each field's `requiredWhen`, `readonlyWhen` and `visibleWhen` the way the build does, with the build's three gates on them: the `parent` gate, the null-guard check over `requiredWhen`, and the refusal of a `requiredWhen` or `readonlyWhen` that reads through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' SLOT`), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for a field whose `requiredWhen`, `readonlyWhen` or `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that slot. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, a root a field-level rule never binds (such as `current_user`), a `parent` read on an object that does not declare exactly one `master_detail` relationship, an ordering or arithmetic operator in `requiredWhen` applied to a nullable field with no `!= null` guard, and a `requiredWhen` or `readonlyWhen` that reads through a reference field (`record.account.tier`, or `parent.REF.FIELD`). Its warnings now ride the save response as advisories. + - A detail object's `requiredWhen` or `readonlyWhen` that reads through one of its master's reference fields (`parent.REF.FIELD`) is judged whenever the master is in the write's context, and that includes a save of the master itself. So a master save can answer 422 with an issue located at a stored detail's field. Fix the detail's predicate, then save the master again. + + **Remedy.** Fix the predicate: the message names the unknown function or field, the unbound root, the unguarded operand or the reference read, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, guard a nullable operand in `requiredWhen` with `record.FIELD != null && …`, and move a check that must read through `record.REF` into a `validations[]` `script` rule, whose `condition` is read one hop through a reference; a read through `parent.REF` has no such surface, so read a column the master declares instead (denormalise the value onto it). Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. + - `conditionalRequired` is still refused at the save door's schema step, before this gate, as a key retired in protocol 17; `os build` judges it as a field-rule slot as before. + - Option `visibleWhen` and the object's own action predicates are judged at this door through their own #22032 entries, each its own crossing, not through this one. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every field-rule slot this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 9 slots on 8 fields of 3 objects (examples: 8 on `showcase_invoice` and `showcase_invoice_line`, three of them `parent`-scoped; the platform: 1 on `sys_permission_set`), over the 118 objects this repository ships. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- aa9447c: fix(lint)!: the object save door refuses a field option's `visibleWhen` that `os build` refuses (#22032) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field option's `visibleWhen` it did not, at the object save door. An option whose `visibleWhen` read a bare field, such as `amount > 1`, or called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The server's option check cannot evaluate such a predicate and lets the value through, so the gate it declares is never enforced. + + The runtime publish gate now runs the build's option check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields, validation-rule predicates and the field-rule slots. On an object write it now also judges each `fields[].options[].visibleWhen` the way the build does: as a predicate over `record` and `previous`, and with the build's refusal of a read through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for an option whose `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, and a read through a reference field (`record.account.tier`, `previous.account.tier`). Its warnings now ride the save response as advisories. + + **Remedy.** Fix the predicate: the message names the unknown function or field, the bare reference or the reference read, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and compare a reference field as a value (`record.account != null`) rather than read through it. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - `current_user` is still accepted in an option's `visibleWhen`, as the build accepts it: the option evaluator binds the acting user (ADR-0068 D1). A role gate such as `'org_admin' in current_user.positions`, or a grant check such as `current_user.can('OBJECT', 'edit')`, still saves. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen` it is still refused, as before. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - The object's own action predicates (`actions[].visible`, `actions[].disabled`) are judged at this door through their own #22032 entry, its own crossing, not through this one. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: this repository ships 5 option predicates, all on `showcase_cascade` (four `record.country` cascades and one `current_user.positions` role gate), among the 118 objects it ships. They have 0 refusals and 0 advisories, at the build and at the door. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- 3d91885: fix(lint)!: the object save door refuses a validation rule whose predicate `os build` refuses (#22032) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a validation rule's predicates it did not, at the object save door. A rule whose `condition` called an unregistered function, such as `sqrt(record.amount) > 1`, or read a bare field, such as `amount > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The rule then faulted on every write it judged. + + The runtime publish gate now runs the build's validation-rule check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields alone. On an object write it now also runs its validation-rule pass: each `validations[]` rule's `condition` and a `conditional` rule's `when`, plus the null-guard check over every predicate the rule carries, its nested `then` and `otherwise` rules included. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · validation 'RULE'`, or `… validation rule 'RULE' then → 'CHILD'` for a nested predicate), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for a validation rule whose predicate the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, an ordering or arithmetic operator applied to a nullable field with no `!= null` guard (`has()` is no guard here), and the other errors in the build's validation-rule check. Its warnings now ride the save response as advisories. + + **Remedy.** Fix the predicate: the message names the unknown function or field, or the unguarded operand, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and guard a nullable operand with `record.FIELD != null && …`. Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. + - This entry's crossing covers validation-rule predicates. The field-rule slots (`requiredWhen`, `readonlyWhen`, `visibleWhen`), option `visibleWhen` and the object's own action predicates are judged at this door through their own #22032 entries, each its own crossing. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every validation rule this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 21 rules carrying 13 predicates on 10 objects: examples 11 predicates on 7 objects, and the platform objects 2 on 3 (one rule on `sys_user` carries no predicate). + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- 8fc50b7: fix(lint)!: a `conditional` validation rule's nested `then` / `otherwise` predicate meets the same expression verdict as the rule's own (#22042) + + Clause-②: no (narrowing) + + A `conditional` validation rule applies its `then` rule when its `when` holds and its `otherwise` rule when it does not, and the rule validator evaluates either branch as a rule of its own. `os build` judged a rule's own `condition` and `when` with the shared `validateExpression` validator, but reached the predicates inside `then` / `otherwise` with the null-guard check alone. So a nested `condition` that called an unregistered function, such as `sqrt(record.amount) > 1`, or read a bare field, such as `amont > 1`, passed `os build`. The object save door gives the build's verdict, so `PUT /api/v1/meta/object/:name` stored it, and the rule then refused every write it judged, because a validation rule that cannot be evaluated fails closed. The same predicate one level up was refused at both doors. + + The build's expression rule (`validateStackExpressions`) now runs the same check on every predicate nested in a `conditional` rule, at every depth: each nested `condition`, and the `when` of a `conditional` nested inside a branch. A nested `condition` also gets the relationship-traversal checks, because ObjectQL hydrates a one-hop read there, as it does at the top level. A nested `when` does not, because the evaluator never hydrates a `when`, as at the top level. A nested finding is located at the nested rule, the location the null-guard check already gave it: `object 'OBJECT' · validation rule 'OUTER' then → 'INNER'`, with `when-predicate` appended for a nested `when`. A rule's own `condition` and `when` keep their findings and their location (`object 'OBJECT' · validation 'NAME'`) and are judged once. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` now refuse, at `error`, a stack whose `conditional` validation rule carries a nested predicate the shared validator refuses. The validator's warnings on a nested predicate are now reported too, and at the save door they ride the response as advisories. + - An object write in publish mode that carries such a rule answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at the nested rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode) and the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`). + - The verdict is the one a rule's own `condition` already got: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, and, for a nested `condition`, a reference field read both through the relationship and as a value, or a read deeper than one hop. + + **Remedy.** Fix the nested predicate the way the same predicate is fixed at the top level: the message names the unknown function or field and the position. Qualify field reads as `record.FIELD`, and use one of the functions `introspectScope` lists. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - A rule's own `condition` and `when` are judged exactly as before, at the same location; the null-guard check over every predicate is unchanged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: this repository ships one `conditional` validation rule with nested predicates (`showcase_account.churn_reason_consistency`, two nested `condition`s), among 21 validation rules on the 118 objects it ships. Both have 0 refusals and 0 advisories, at the build and at the door. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. + + +- bb4f5cc: fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, or any other root the server's option check does not bind (#22157) + + Clause-②: no (narrowing: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, which the runtime's option check cannot bind) + + A select option's `visibleWhen` is a gate the server enforces on write: the rule validator evaluates the predicate for the value a caller picks, and refuses the value when the predicate is false. That option check binds `record`, `previous` and the acting user (`current_user`, and its ADR-0068 aliases `user`, `ctx` and `os`), and nothing else. An option predicate that read `parent`, such as `parent.status == 'closed'`, still passed `os build` and the object save door. `parent` is a root the platform declares, and a field's own `readonlyWhen` and `requiredWhen` bind it on an object with exactly one `master_detail`. The option check does not bind it. So the predicate faulted on every write that picked the option, the server logged "the option's gate was NOT enforced on this write", and the value was admitted. + + The build's expression rule (`validateStackExpressions`) now gives a select option's `visibleWhen` a root verdict over the roots the option check binds. A predicate that reads any other root the platform declares, `parent` above all, is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the option, the field and the root. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a root other than `record`, `previous`, `current_user`, `user`, `ctx` or `os`. Besides `parent`, that covers the roots the platform declares for other evaluation sites, such as `input`, `vars`, `trigger`, `data` and `features`. Each of them faulted at the option check in the same way. + - An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + + **Remedy.** Rewrite the predicate against what the option check binds: `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`. To gate a choice on a master-detail header's state, read a column the detail object declares, and denormalise the header value onto the detail; `parent` is bound only for a field's own `readonlyWhen` and `requiredWhen`. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind `parent` for an option, this refusal is lifted in that same change. + - The acting user is still accepted in an option's `visibleWhen` under all four ADR-0068 spellings, and so is a grant check such as `current_user.can('OBJECT', 'edit')`. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen`, the acting user is still refused and `parent` is still accepted, as before. + - A root the platform does not declare at all, such as `app`, was already refused in an option's `visibleWhen` by the bare-reference check, and it still is, with the same message. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every object this repository ships carries 5 option predicates, all on `showcase_cascade`: four `record.country` cascades and one `current_user.positions` role gate. That is over the 119 objects from its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them (33 objects). They have 0 refusals and 0 advisories, at the build and at the door, before this change and after it. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- 326a90a: feat(spec)!: a `page` gains an optional `print` declaration and a linted printable block subset; the list-export `'pdf'` prescription points at the print page; the zero-reader document schemas retire whole (#22158) + + Clause-②: yes (narrowing) + + + + **BREAKING** — the document family's exports leave `@objectstack/spec/data` (an export removal on a published entry), graded `major` on `@objectstack/spec`: Changesets is in pre mode on `main` (tag `next`), where the launch-window `major` guard stands aside for the line's breaking changes, so the level says what the change is. The v18 opening marker already takes the fixed group to `18.0.0-next.N`, so this grade moves no version on its own. `@objectstack/lint` is `minor`: its new rule refuses blocks only inside a page that declares `print`, which no page could carry before this release, and it adds exports. The `print` declaration itself is additive. + + Card ① of the ruling on PDF and print documents (letter B′): **a document is a page with a print declaration; there is no separate template type.** + + ### The print page (additive) + + `PageSchema` gains an optional, closed `print` block. Its presence makes the page a print page — a document (an invoice, a delivery order, a letter, a report) authored in the page's own blocks: + + ```ts + definePage({ + name: 'invoice_print', + label: 'Invoice', + type: 'record', + object: 'invoice', + regions: [ + { name: 'header', components: [{ type: 'element:image', properties: { src: '/logo.png', alt: 'ACME' } }] }, + { name: 'main', components: [ + { type: 'record:details', properties: { fields: ['customer', 'invoice_date'] } }, + { type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'description' }, { name: 'amount' }] } }, + ] }, + { name: 'footer', components: [{ type: 'element:text', properties: { content: 'Payment due within 30 days.' } }] }, + ], + print: { paperSize: 'A4', orientation: 'portrait', margins: { top: 15, bottom: 15 }, repeatHeader: true, repeatFooter: true, pageNumbers: true }, + }); + ``` + + - **Keys** (`PagePrintSchema`): `paperSize` (`A4` | `A5` | `Letter` | `Legal`), `orientation` (`portrait` | `landscape`), `margins` (`{ top, right, bottom, left }` in millimetres), `repeatHeader` / `repeatFooter` (repeat the page's own `header` / `footer` region on every sheet), `pageNumbers`, and the page-break hints `repeatTableHeaders` and `avoidBreakInside`. Each maps to print CSS (`@page`, `thead { display: table-header-group }`, `break-inside`). + - **Not yet rendered.** Nothing applies these keys until the console's print rendering lands; the liveness ledger carries `print` as `planned` with an author warning, so `os validate` tells an author who writes it that the layout is validated but not yet applied. + - **Refused at the parse** (`checkPagePrintComposition`, exported for `.shape` mirrors): `print` on a `slotted` page, on an `html` / `jsx` / `react` page, on a `list` or `utility` page (a print page is a `record`, `home` or `app` page), on a `full` page with no `regions`, and `repeatHeader` / `repeatFooter` on a page with no region of that name. + - **The printable block subset** (`PRINTABLE_PAGE_COMPONENT_TYPES`, with the reason for every other vocabulary type in `PRINT_REFUSED_PAGE_COMPONENT_TYPES`): `page:section`, `page:card`, `page:footer`, `record:details`, `record:highlights`, `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`. Inside a print page any other block — one that pages or windows its rows (`object-grid`, `record:related_list`), one that lays itself out to the screen (`page:sidebar`, `object-kanban`, `object-calendar`), or one with nothing printable (controls, inputs, shell chrome, `page:tabs`) — is refused by `@objectstack/lint`'s new gating rule `print-page-block-unprintable` (`validatePrintPageBlocks`), on `os validate`, `os build`, `os lint` and the page save door. + + ### The list-export prescription + + `'pdf'` stays refused in `view.exportOptions` formats. The refusal no longer says "PDF export itself was declined as NOT PLANNED" — no longer true — and instead names the view's `allowPrinting` for printing a list and a page that declares `print` for a document. + + ### FROM → TO (the retirement) + + `DocumentTemplateSchema`, `DocumentSchema`, `ESignatureConfigSchema` and the orphaned `DocumentVersionSchema` (`data/document.zod.ts`) are removed from `@objectstack/spec/data`, with their type aliases (`DocumentTemplate`, `DocumentTemplateParsed`, `Document`, `DocumentParsed`, `ESignatureConfig`, `ESignatureConfigParsed`, `DocumentVersion`, `DocumentVersionParsed`). No code in this repository, objectui or hotcrm read them. + + | before | what to write instead | + | --- | --- | + | `DocumentTemplateSchema` (a docx template with placeholders) | a `page` that declares `print`, its body drawn from the printable block subset | + | `DocumentSchema` / `DocumentVersionSchema` | nothing — no document store ever kept them; a document record is ordinary object data, its files `sys_file` attachments | + | `ESignatureConfigSchema` | nothing — no e-signature integration exists | + + **The one-line fix: delete the import; author a printable document as a page with a `print` block.** Every such import is TS2305 after upgrade. `data/DocumentSchemaValidation` (the NoSQL driver's block) is unaffected. `os migrate meta --from 17` lists the delegated step (D3 `document-schemas-retired`); there is no mechanical edit, since no stack collection ever carried these shapes. + + ### The retirement kit + + - `RETIRED_DEFS_BY_MAJOR[18]`: `data/DocumentTemplate`, `data/Document`, `data/ESignatureConfig`, `data/DocumentVersion`; D3 semantic entry `document-schemas-retired`, with a step-18 rationale fragment. The `ESignatureConfig` deadline-key entries in `RETIRED_KEYS_BY_MAJOR[18]` stay as history. + - No tombstone and no D2 conversion: none of the schemas is a stack collection member or a metadata type, so a conversion would have no seam that runs. + - Generated: `json-schema.manifest/data.json` −4 defs, `authorable-surface/data.json` −30 rows, `authorable-defaults/data.json` −2, the api-surface / declaration-map / export-origins shards, the `data/document` reference page removed. + - Pin: `src/data/document-schemas-retirement.test.ts`, a tree-scoped absence walk over the declared radius. +- d1c226d: fix(lint): the approval-approver, data-model, AI agent-authoring, view-reference and chart-binding findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: yes (widening: three rule id constants exported from the barrel) + + - **Shorter verdicts.** Each finding of these 15 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 197 characters, the `packages/cli` unit suite's at most 188, and `os validate` on the example apps' at most 191; before, the longest of each ran from 201 to 480 characters. The ids: + - approval nodes (`flows[]`): `approval-approvers-may-resolve-empty` (both the group arm and the `manager` arm), `approval-approver-not-membership-tier`; + - the ADR-0120 uniqueness rules (`objects[].indexes` and field-level `unique`): `unique/unscoped-declared-index`, `unique/double-declaration`, `unique/legacy-organization-composite`; + - `os lint`'s data-model sweep: `relationship/master-detail-required`, `relationship/delete-behavior`, `rollup/non-numeric-aggregand`; + - AI agents (`agents[]`, `apps[].defaultAgent`): `agent-authoring-withdrawn`, `default-agent-outside-roster`, `default-agent-legacy-alias`; + - view references: `view-ref-nav-view-missing` (app navigation), `view-key-collision` (view containers); + - chart bindings (reports, list-view charts, dataset-bound page charts): `chart-measure-unknown`, `chart-axis-not-selected`. + + The `manager` arm of `approval-approvers-may-resolve-empty` stays conditional — it says what happens where `sys_user.manager_id` is unset and asserts nothing about the column, which the rule cannot read. `view-ref-nav-view-missing` still lists the object's list views, at most three and then `(and N more)`, and `chart-axis-not-selected` quotes at most three names of the chart's selection. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **Three rule id constants are published** for ids `os lint`'s data-model sweep already emitted without one: `RELATIONSHIP_MASTER_DETAIL_REQUIRED` (`relationship/master-detail-required`), `RELATIONSHIP_DELETE_BEHAVIOR` (`relationship/delete-behavior`) and `ROLLUP_NON_NUMERIC_AGGREGAND` (`rollup/non-numeric-aggregand`), exported from `@objectstack/lint` beside the `UNIQUE_*` constants. The id strings themselves are unchanged. + - **`os explain ` takes these 15 ids**, for example `os explain unique/double-declaration`. It prints the reasoning the verdicts no longer carry: why an all-group or all-`manager` approval slate can stall, what silences the `manager` arm and what does not, and what an `org_membership_level` approver resolves against; the ADR-0120 scope words, the two channels that refuse a bare `unique: true` on a declared index, how a field `unique` and an index `unique` contradict or repeat, and why a hand-written organization composite enforces nothing on NULL rows; why a `master_detail` reference must be required under `controlled_by_parent`, what deleting a master does to its details, and why a `min` / `max` roll-up needs a numeric child; the platform agent roster and its retired aliases, how the runtime enforces the agent withdrawal, and what `defaultAgent` resolves against; how a navigation `viewName` is matched and what a renamed view key does to its references; and which chart positions bind a measure on each surface and which selection each is measured against. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 15 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 15 entries. + - **Where the new text prints.** On the CLI: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify`, and the scaffold check `os init` runs print the new `message` on the text face for the approval, agent, view-reference and chart ids and for the three `unique/*` ids, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. `relationship/master-detail-required`, `relationship/delete-behavior` and `rollup/non-numeric-aggregand` print on `os lint` only (and in the metadata-generation rubric's lint half), as before. At the runtime publish gate (Studio, REST `/meta`, MCP): on a `flow` write, the two approval ids ride the 2xx `advisories` and the `[Protocol] authoring advisory` log line; on a `report` write, `chart-measure-unknown` at a report's `values` or chart `yAxis` changes the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`, and `chart-measure-unknown` at a report chart's `series[]` and `chart-axis-not-selected` ride the 2xx `advisories` and the advisory log line. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** the three agent ids (the rule runs there only on a `flow` write, whose snapshot carries no agents or apps), the two view-reference ids and the three `unique/*` ids (CLI-only rules), the three data-model sweep ids (`os lint` only), and the chart ids at a list-view or page chart (a report write carries neither surface). +- 54d32e5: feat(lint, cli): one-line author-time rule verdicts, and `os explain ` for the reasoning + + Clause-②: yes (widening) + + - **Shorter warnings.** `field-no-consumers` and `security-owd-unset` now print one verdict sentence and one fix. `os validate`, `os build` and `os dev` used to print `field-no-consumers` as a single line of about 860 characters, and `os build` added a second paragraph of about 700; the warning now reads: + + ```text + ⚠ object "my_app_ticket" · field "description": declared, but nothing in this stack displays or reads it (inert) + fix: add it to a view column or a form section, or remove the declaration + rule: field-no-consumers at objects[1].fields.description — `os explain field-no-consumers` for what counts as a consumer + ``` + + The finding's `message` no longer restates its `where`, and no longer carries the list of consumer and carrier kinds, the exemptions or the scanned roots; `hint` is the fix alone (for a `carrier-only` verdict it still names each carrier site a removal must clean). The `verdict`, `carriers` and `rootsScanned` fields of a `field-no-consumers` finding are unchanged. A tool that matched the old message text should match on `rule`, `where` and `path` instead. + + `security-owd-unset` also changes on the runtime wire. It runs at the metadata write door's publish gate for `object` writes (Studio, REST `/meta`, MCP), and an active write of a custom object (neither `isSystem: true` nor `sys_`-named) with no `sharingModel` is refused with a 422 whose `security-owd-unset` issue now carries the message `custom object declares no sharingModel (OWD); the runtime falls back to 'private', but the baseline must be an authored decision` and the hint `declare sharingModel: 'private' (owner + shares; recommended), 'public_read', 'public_read_write', or 'controlled_by_parent' (master-detail children)`. The old message named the object, which the issue's `where` and `path` still carry, and told the leave_request incident, which `os explain security-owd-unset` now prints. + - **`os explain `.** `os explain` takes an author-time rule id as well as a schema name — `os explain field-no-consumers`, `os explain security-owd-unset` — and prints the reasoning the warning no longer carries; `--json` prints `{ rule, covers, paragraphs }`. A schema name resolves exactly as before. With no argument it also lists the rule ids that have an explanation (`--json` adds `rules: [{ id, covers }]`). The `rule:` line names the command only for those rule ids. An argument that is neither still exits 1, and its error string changes from `Unknown schema: "X"` to `Unknown schema or rule id: "X"`, followed by a second list, `Rules with an explanation: …`; the `--json` `error` field changes the same way, from `Unknown schema: X` to `Unknown schema or rule id: X`. + - **`os explain constructor` (or `__proto__`) is refused as an unknown id** instead of printing `Schema: Object … undefined` and crashing with `schema.required is not iterable`: both lookups read own keys only. + - **`os validate` prints the `fix:` and `rule:` lines** under each author-time warning, the way `os build` does, and the hint line under every author-time finding (`os build`, `os validate`, `os verify`, `os init`) is now labelled `fix:`. `os lint` adds the same `os explain` pointer to its rule line. + - **The `fix:` line is always a fix.** `expression-invalid` no longer puts the authored source in `hint`, where it printed as `fix: source: …`: the source now ends the finding's `message` as `` — source: `…` `` (the spelling the flow engine's runtime refusals use), so the CLI verdict line still carries it, and so does the issue `message` at the runtime publish gate for `flow`, `action`, `hook` and `object` writes (Studio, REST `/meta`, MCP): in the 422 for an `error`, in the 2xx `advisories` for a `warning`. Its `hint` is empty, so no `fix:` line prints and the runtime issue's `hint` is `''`. `component-props-invalid`, `flow-time-relative-descriptor-invalid`, `react-prop-missing-required` (where the component contract describes the binding) and `liveness-experimental-property` carried context in `hint`; each now leads with the instruction, and `component-props-invalid`'s message states its consequence (nothing refuses it today, so the renderer receives the props as written). Two of the four run at the runtime publish gate. `flow-time-relative-descriptor-invalid` is an `error` on `flow` writes, so its new hint reaches the 422 issue `hint`. `liveness-experimental-property` runs on `email_template`, `mapping` and `datasource` writes as a `warning`, so its hint would ride the 2xx `advisories`, but none of those three ledgers has an `experimental` row today, so it reaches no runtime response yet. `component-props-invalid` is CLI-only, and `react-prop-missing-required` judges no `page` write at the gate, so their new text reaches the CLI only. + - **New exports in `@objectstack/lint`:** `RULE_EXPLANATIONS`, `explainRule(ruleId)` and the type `RuleExplanation`, from the root entry and from the import-free `@objectstack/lint/rule-explanations` entry. +- d1dbe70: fix(lint)!: four authoring rules now fire on the sibling spelling of a defect they already caught + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing of the `os lint`, `os validate` and `os build` verdicts, shipped as `minor` under the launch-window convention for accept-set narrowings. No rule id is added. Each rule below now reports a spelling of a defect it already reported, at the severity it already had. + + - **`hook-api-update-readonly-field`** (error) and **`hook-body-write-unknown-field`** (warning) read a hook body's writes from one extractor. That extractor accepted only the literal `ctx.api.object(...)` receiver. A body that binds the handle first, such as `const api = ctx.api as HookApi | undefined; await api.object('crm_case').update({...})`, was invisible to both rules. The byte-identical `ctx.api.object(...)` statement fired both. The receiver can now also be a local bound to `ctx.api`, with or without type-only and non-null wrappers. That covers `const api = ctx.api`, `const { api } = ctx`, `const { api: db } = ctx` and an optional call `api?.object(...)`. The local must be declared once and never reassigned, and the write must sit in the block that declares it. A reassigned, shadowed, defaulted or out-of-scope local is still not judged, and neither is a local bound to `ctx.api.sudo()`. The action-body write rules share the extractor, so they read the same receivers. + - **`visibility-bare-identifier`** (error) used to accept any name written as a namespace. `has(record.x) && foo.x == 'crm_lead'` published clean, and the console's engine then answered `Unknown variable: foo` and rendered the element unconditionally. A namespace root that no layer binds is now reported, with its own message and fix. Every root a renderer binds still passes: `record`, `previous`, `parent`, `current_user`, `user`, `ctx`, `os`, `features`, `page` and `data`, plus everything in `@objectstack/formula`'s `SCOPE_ROOTS`. On a metadata-editing form, a dotted chain on the right of `==` / `!=` is still left to `predicate-rhs-path-shaped`. + - **`security-master-detail-ungranted`** (warning) used to treat an object as a detail only when it had a `master_detail` field. The runtime also resolves a `controlled_by_parent` object's master through a required lookup. That child, granted in no permission set, gets the same never-derived 403 on object-level CRUD. It is now reported too. A child with two `master_detail` fields is reported against the master the runtime picks. + - **`component-props-invalid`** / **`component-props-unknown-key`** (warning) skipped a component whose `properties` bag was absent. A top-level component never arrives without one, because `PageComponentSchema` defaults it to `{}`. A nested component (in `children`, `items[].children` or `footer`) is never parsed by that schema, so every required prop it omitted went unreported. An absent bag is now judged as `{}`. + + **What an author sees.** A stack that passed before can now draw one of these findings. Each one names a real defect: a write the runtime drops or refuses, a predicate that can never evaluate, a role that is denied the object, or a component missing a prop its contract requires. The fix is in the finding's hint. The readonly-write and visibility findings are errors, so they fail `os build`. The others are warnings, which fail only `os lint --strict`. +- 215e662: A `date` or `datetime` field can declare that it is a deadline, and when that deadline is settled: `dueLike` and `settledWhen` + + Clause-②: yes + + - **`dueLike: boolean`** (optional, no default) declares the date a deadline. A renderer may then show relative overdue wording and an overdue colour once the date has passed. Absent means not a deadline: nothing is inferred from the field's name. + - **`settledWhen`** (optional) is a per-record CEL predicate in the `visibleWhen` / `readonlyWhen` / `requiredWhen` family. While it holds, the deadline is settled and no overdue wording or colour applies to that record, for example `record.status == 'done'`. + - **The doors that judge them.** `FieldSchema` refuses either key on any type other than `date` / `datetime`, and refuses `settledWhen` unless the field also declares `dueLike: true`. The CEL is judged by `@objectstack/lint`'s field-rule pass, the one `visibleWhen` goes through, at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no root but `record` (plus `previous`, and `parent` on a master-detail line item). + - **Display only.** Nothing on the write path reads either key. Every metadata that parsed before parses unchanged; a field without the keys behaves exactly as before. + - **Who reads them.** objectui's date and datetime cells read `dueLike` on the record detail surfaces today. Reading `settledWhen`, and retiring the field-name guess, follows in objectui once this spec version is published. + - **Where to author them.** The field designer offers both on date and datetime fields, `settledWhen` only once the field is a deadline. The example apps declare them on the showcase and todo `task` and the CRM `activity`. +- 3d886ee: A field can declare cell formatting rules: `conditionalFormatting: [{ condition, style }]`, the rule a list view already declares for its rows + + Clause-②: yes (widening) + + - **`FieldSchema.conditionalFormatting`** (optional) is an ordered list of `{ condition, style }` rules. The first rule whose CEL `condition` holds applies its CSS `style` map to that field's cell, wherever the field renders. A list view's row rules still style the row, and both may apply to one record. Example: `amount: Field.currency({ conditionalFormatting: [{ condition: P\`value < 0\`, style: { color: '#b91c1c' } }] })`. + - **One rule element, no second style vocabulary.** The `{ condition, style }` element moved out of `ListViewSchema` into its own spec module, and both `ListViewSchema.conditionalFormatting` and `FieldSchema.conditionalFormatting` mount it. The list view accepts exactly what it accepted before. Its refusal of an unknown rule key now reads the same on both members: for a `color` written beside `style`, it says to put the colour in `style` first, then points a list view at `rowColor`. + - **The scope is `value` and `record`.** `value` is this field's value on the record and `record` is the row. `@objectstack/lint` judges each condition at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no other root (`previous`, `parent` and the user roots are refused, naming the field and the rule). + - **For presentation only.** Use it for rules that carry no meaning of their own, such as an amount below zero in red or stock under its safety level in amber. A deadline's overdue state stays a declaration (`dueLike` with `settledWhen`). + - **Display only.** Nothing on the write path reads the key, and every metadata that parsed before parses unchanged. Field-level security drops a rule whose condition reads a field the caller cannot read, keeping the other rules in order (`@objectstack/metadata-core`). The SQL driver classifies the key as presentation, so it never reaches column DDL (`@objectstack/driver-sql`). + - **Who reads it.** objectui's cell renderers read the rules in a follow-up release, once this spec version is published. Until then a rule is validated when it is authored and styles no cell. +- b1f7a7a: fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads a member of `ctx` or `os` the server's option check never binds, such as `os.org.id`, `os.env` or `ctx.locale` (#22274) + + Clause-②: no (narrowing: a select option's `visibleWhen` that reads an unbound member of a bound root is refused at build and at the object save door) + + A select option's `visibleWhen` is a gate the server enforces on write. The option check binds `record`, `previous` and the acting user, as `current_user` and its ADR-0068 aliases `user`, `ctx.user` and `os.user`. Under `ctx` and `os` it binds the `user` member and nothing else: it passes no organization and no environment. The build already refused a root the option check does not bind, such as `parent`, but it judged `ctx` and `os` as whole roots. So an option predicate that read `os.org.id != ''`, `os.env == 'prod'` or `ctx.locale == 'en'` passed `os build` and the object save door with no finding. On every write that picked the option the predicate then faulted (`No such key: org`, `env` or `locale`), the server logged "the option's gate was NOT enforced on this write", and the value was admitted. + + The build's expression rule (`validateStackExpressions`) now judges the members of `ctx` and `os` in an option's `visibleWhen`, in the same verdict that judges its roots. A member other than `user` is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the member, says that the option check binds only the `user` member under that root, and gives the remedy. Every spelling of the read is judged the same: `os.org`, `os.?org`, `os['org']` and `has(os.org)`. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a member of `ctx` or `os` other than `user`, such as `os.org.id`, `os.org.tier`, `os.env` or `ctx.locale`. + - An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + + **Remedy.** + + - For the caller's organization, compare `current_user.organizationId`, which the option check does bind: it holds the acting user's organization id, or `null` when the caller acts outside an organization. `os.org.id != ''` becomes `current_user.organizationId != null`. No other organization fact, such as its tier, is available to an option predicate; read a column the object declares instead. + - `os.env`, `ctx.locale` and any other member: rewrite the predicate against `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`. + - Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind a member such as `os.org` for an option, this refusal is lifted for that member in the same change. + - The acting user is still accepted under all four ADR-0068 spellings (`current_user`, `user`, `ctx.user`, `os.user`), and so are its fields (`current_user.positions`, `ctx.user.id`) and a grant check such as `current_user.can('OBJECT', 'edit')`. + - The same members are still accepted where they are bound, such as `os.org.id` in a `formula` field's `expression`. The refusal is the option slot's alone. + - A computed key such as `os[name]` names no member, so it is not judged. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: the objects this repository ships carry 5 option predicates, all on `showcase_cascade`, which read `record` (four) and `current_user` (one). None reads `ctx` or `os`. That holds over every object in its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them. They have 0 refusals at the build and at the door, before this change and after it. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. + + +- 46692c1: fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads a member of the acting user the server's option check never binds, such as `current_user.roles` (renamed `positions`), `ctx.user.roles` or `current_user.email` (#22394) + + Clause-②: no (narrowing: a select option's `visibleWhen` that reads an `EvalUser` member the schema does not declare is refused at build and at the object save door) + + A select option's `visibleWhen` is a gate the server enforces on write. The option check binds the acting user under four spellings, `current_user` and its ADR-0068 aliases `user`, `ctx.user` and `os.user`, all one `EvalUser` object. That object carries the caller's `id`, `positions`, `isPlatformAdmin` and `organizationId`, and nothing else. The build already refused a root the option check does not bind (such as `parent`) and a member of `ctx` or `os` other than `user`, but it stopped there. So an option predicate that read `'admin' in current_user.roles`, `ctx.user.roles == ['a']` or `current_user.email == 'a@b.c'` passed `os build` and the object save door with no finding. `roles` has not been a member of the acting user since ADR-0090 D3 renamed it `positions`; `name` and `email` are declared by `EvalUserSchema` but the server never sets them for this check. On every write that picked the option the predicate then faulted (`No such key: roles`, `email`), the server logged "the option's gate was NOT enforced on this write", and the value was admitted. + + The build's expression rule (`validateStackExpressions`) now judges the members of the acting user in an option's `visibleWhen`, under all four spellings, in the same verdict that judges its roots and the members of `ctx` and `os`. A member is accepted only when `EvalUserSchema` (`@objectstack/spec`) declares it AND the option check binds it; the allowlist is read off the schema and off `@objectstack/formula`'s `buildScope`, never written out. Any other member is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the member, names the members the acting user does carry there, and gives the remedy. Every spelling of the read is judged the same: `current_user.roles`, `current_user.?roles`, `current_user['roles']`, `has(current_user.roles)`, and the same below `ctx.user` and `os.user`. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a member of `current_user`, `user`, `ctx.user` or `os.user` other than `id`, `positions`, `isPlatformAdmin` and `organizationId`, such as `current_user.roles`, `current_user.role`, `ctx.user.roles`, `current_user.email` or `user.name`. + - An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + + **Remedy.** + + - `roles` / `role` → `positions`: `'admin' in current_user.roles` becomes `'admin' in current_user.positions`, in whichever spelling the predicate used (`ctx.user.roles` becomes `ctx.user.positions`). + - `name`, `email` and any other member: rewrite the predicate against a member the option check binds (`current_user.id`, `current_user.positions`, `current_user.isPlatformAdmin`, `current_user.organizationId`), or against a column the object declares (`record.FIELD`, `previous.FIELD`). + - Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind a member such as `email` for an option, this refusal lifts for that member in the same change, because the allowlist is derived from what the option check binds. + - The same members are still accepted where they are bound, such as `current_user.email` in a row-level security policy or an object action's `visible` predicate. The refusal is the option slot's alone. + - A grant check such as `current_user.can('OBJECT', 'edit')` is a call, not a member read, and is still accepted. + - A computed key such as `current_user[name]` names no member, so it is not judged. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: the objects this repository ships carry 5 option predicates, all on `showcase_cascade`, which read `record` (four) and `current_user` (one, `current_user.positions`). None reads a member the option check does not bind. That holds over every object in its `*.object.ts` files and the two `app-multi-package` sub-stacks. They have 0 refusals at the build, before this change and after it. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. + + +- faf6348: feat(i18n): a refused `end` node's message translates in the run's locale (`flows..refusals..message`) + + Clause-②: yes (widening) + + - **What is new.** An `end` node declaring `outcome: 'refused'` shows its `message` to the person who started the run. Translate it under `flows..refusals..message`, keyed by the flow's `name` and the `end` node's `id`, the same addressing the `screens` keys use. Before this key, a refusal rendered in the source language on every console. + - **Keep the holes.** The translation is a `{{ }}` template like the message it translates (`'已拒绝:{{ record.name }} 是重复记录'`). A single-brace `{token}` in a translation is refused when the bundle is parsed, with the same text-slot refusal the source message gets. + - **Where it is read.** The engine picks the translated template before it renders the holes, through the `i18n` service, in the run's locale, and the run stores and returns that text (`refusalMessage`). With no translation for that locale, the authored message renders. `flowRefusalMessageKey(flowName, nodeId)` (`@objectstack/spec/system`) spells the key. + - **The run's locale.** `AutomationContext.locale` (`@objectstack/spec/contracts`) is new and optional. The trigger door (`POST /api/v1/automation/:name/trigger`, the legacy trigger route, a `type: 'flow'` endpoint) and the action door (`POST /api/v1/actions/...`, the MCP `run_action` bridge) set it from the request's resolved locale: `Accept-Language` first, then the `localization` settings. It is persisted with a paused run, so a resumed leg renders in the locale the run was started in. A run no person started (a record-change or schedule trigger) has no locale and stores the authored message. + - **`objectstack validate`** refuses a refusal key over an unknown flow, an unknown node, a completed `end` node, or a node of another type (`translation-target-unknown`, error). + - **`os i18n extract` and the coverage gate** now scaffold and demand the refusal message of every refusing `end` node in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` and has a refusing `end` with no translation now gets one missing-key issue per locale for it. + - **`AutomationEngine.setI18nServiceSource()`** (`@objectstack/service-automation`) attaches the `i18n` reader. The automation plugin wires it, so a host using `AutomationServicePlugin` needs no change. A host constructing the engine directly gets the authored message until it attaches one. +- f89b5f5: feat(i18n): a screen field's option labels and a flow's terminal toasts have translation keys (`flows..screens..fields..options.`, `flows..successMessage` / `.errorMessage`) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. Two options of one screen field whose values read the same as text — `1` and `"1"`, `true` and `"true"`, or a repeated value — are now refused when the flow is parsed (`FlowSchema`, `objectstack validate`, the metadata save door, `registerFlow`), at the second option's `value`. Neither the option's label translation (keyed by the value as text) nor the console's select can tell such a pair apart. To fix a refused flow, give each option of the field a value that stays distinct as text, or drop the duplicate. Measured reach in this repository: 0 — no flow in its examples carries such a pair. + + - **What is new.** A fully translated screen flow no longer has to keep its select options and its completion or failure toast in the source language. Translate an option's label under `flows..screens..fields..options.`, and the flow's own toasts under `flows..successMessage` and `flows..errorMessage`. + - **How an option is addressed.** By its value read as text: `String(value)`, so the option `{ value: 1, label: 'Tier 1' }` is translated under `options.1` and `{ value: true, … }` under `options.true`. `flowScreenFieldOptionKey(value)` (`@objectstack/spec/automation`) spells the key. Not by position (reordering options would re-point every translation), and not by an object field's options (a screen field declares no such binding). + - **One new refusal**, the narrowing the BREAKING note above describes: option values of one screen field must stay distinct as text, because the label translation is keyed by them. + - **The toasts translate only where the flow authors one.** A bundle cannot add a toast to a flow that declares none; `objectstack validate` refuses such a key (`translation-target-unknown`, error). + - **Where it is read.** `translateFlow` (`@objectstack/spec/system`) overlays both, and `resolveFlowScreenFieldOptions(bundle, flowName, nodeId, field)` resolves a served screen field's options. The console's flow runner reads them in a following release; until then a translation is stored and the authored text is drawn. + - **`objectstack validate`** warns on an option key that names no declared option of the field, or names it by its label (`translation-option-key-unknown`). + - **`os i18n extract` and the coverage gate** now scaffold and demand each screen field's option labels, the failure toast of every flow that declares one, and the completion toast of a flow with a screen, in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` gets one missing-key issue per locale for each until it translates them. + - **A screen's `description`** is keyed too, in the same release, and translated by the engine rather than the console: see the entry for `flows..screens..description`. +- d25b187: feat(lint): `objectstack validate` judges a translated screen `description` + + Clause-②: yes (widening) + + - A translation of `flows..screens..description` over a screen that declares no `config.description` is reported as `translation-target-unknown` (error). The engine translates body text only where the screen authors one, so nothing would read the key. Declare the screen's `description`, or drop the key. +- 6981246: A flow whose `record-*` trigger names no start `config.objectName` is no longer judged for unbound flow CEL roots by `objectstack validate`. With no object named, the record-change trigger registers its hook with no object filter, so it fires on a write to any object, one the stack does not declare included, and hands that row to the run as `record`, its fields flattened beside it. The build door cannot know that record's keys, so it leaves the flow unjudged, as it already does for a record trigger on an object the stack does not declare. The trigger shape itself is not refused. + + Clause-②: yes (widening: a flow whose `record-*` trigger names no start `config.objectName` opens, so `validate` no longer reports an unbound flow CEL root there) + + **What changes.** On such a flow, `expression-invalid` no longer reports a root the flow does not bind (`user.id`, `foo.bar`, a field name no declared object has). The same flow with a declared `config.objectName` is judged exactly as before. + + **The runtime publish gate, by design.** That gate does not judge unbound flow CEL roots on a flow write, and is not going to: who launches a flow and what record it is handed are read from other items of the stack, which makes this a whole-stack judgment that belongs to `objectstack validate`, and the gate's per-write snapshot is not widened to carry flows and actions. The stand-down there is the intended boundary, not a gap waiting on a wider snapshot. + + **Who is affected.** Every shipped `record-*` trigger names its object, so no example app changes: `objectstack validate` over `app-crm`, `app-todo`, `app-multi-package` and `app-showcase` reports the same findings before and after. +- 5081090: feat(lint): `validateCapabilityReferences` resolves the capabilities a list view or a dashboard requires (#22639) + + Clause-②: no + + - The `/meta` read gate now serves a list view or a dashboard only to a user who holds every capability its `requiredPermissions` names, so a misspelled name hides the item from everyone. `validateCapabilityReferences` resolves those names like every other carrier's: a dashboard's, a view container's `list` and each `listViews` entry, a view item's `config`, a flattened list view's, and an object's own `listViews` entries. An unresolved name warns `capability-reference-unknown` at its own path (for example `views[0].listViews.legal_review.requiredPermissions`). + - It is a warning, as for objects, fields, apps and actions: a single package's lint cannot see a capability another installed package declares. +- a22e67b: A `record:details` section heading can show how many values a multi-value field holds on the current record: `sections[].count: { field }`, with the section's `label` writing the `{{ count }}` hole where the number goes. + + Clause-②: yes (widening) + + ```ts + { + name: 'plan_gap', + label: '{{ count }} features need a higher plan', + fields: ['capabilities_not_on_plan'], + count: { field: 'capabilities_not_on_plan' }, + } + ``` + + - **`@objectstack/spec`.** `RecordDetailsProps.sections[]` accepts `count`, a closed object whose one key, `field`, is a bare machine name (snake_case, no dotted path). A section that declares `count` must write the `{{ count }}` hole in its `label`, in every locale of an inline label map. It is refused when it has no `label`, when the label has no hole, when the label writes any other `{{ … }}` hole (another variable, or the count with a formatter), when it writes the single-brace `{count}` / `${count}`, and beside `group` (a group-referencing section's heading is the group's own label). A translated `objects.._sections..label` writes the same `{{ count }}` hole, and `os i18n extract` seeds it from the authored label. `SECTION_HEADING_COUNT_HOLE` exports the hole's grammar (inner whitespace is free: `{{count}}` is the same hole). + - **`@objectstack/lint`.** The page field-binding pass resolves `count.field` against the bound object, at error severity, on `os validate`, `os lint` and `os build`: a field the object does not declare is `page-field-unknown`, and a declared field that holds one value is the new `page-section-count-not-multi-value`. A multi-value field is `multiselect`, `checkboxes` or `tags`, or a `select`, `lookup`, `user`, `file` or `image` field with `multiple: true`. A section's `fields[]` miss stays a warning. + - **Not rendered yet.** The key is declared ahead of its renderer and its description carries the `[EXPERIMENTAL — not enforced]` marker: until the record detail renderer reads it, the hole renders as literal text. + - **Nothing to migrate.** No stack that parsed before is refused: every new refusal needs the new key. A `{{ count }}` hole in a section that declares no `count` is still accepted. +- 76815b9: feat(spec)!: a `controlled_by_parent` object derives its master from exactly one required `master_detail` — the parse refuses anything else, and the runtime's two fallback tiers retire (#23100) + + Clause-②: no (narrowing) + + ADR-0055 §1 says a `controlled_by_parent` object "must declare exactly one required `master_detail` field … Validation error otherwise (fail closed)". Until now neither the parse nor the runtime kept it. `ObjectSchema` accepted such an object with no master at all. The security runtime derived a master through a three-tier fallback the record never stated: a required `master_detail`, then any `master_detail`, then a required `lookup`, taking the first match by field order within a tier. The maintainer ruling (letter B) enforces the record, under ADR-0049 (enforce-or-remove). + + **BREAKING — what moves for consumers.** + + - **`@objectstack/spec`.** `ObjectSchema` (raw `.parse()` / `.safeParse()`, `ObjectSchema.create()`, `defineStack`, `os validate` and the metadata save door) refuses an object declaring `sharingModel: 'controlled_by_parent'` unless exactly one of its fields is a `master_detail` declared `required: true`. With none, the issue sits at `sharingModel` and names each field that relied on a retired tier. With two or more, it sits at `fields` and names them. A plain object literal is judged like a raw parse: an omitted `required` on its `master_detail` is refused, while `ObjectSchema.create()` keeps forcing an omitted `required` to `true`. The step-18 semantic migration entry `cbp-master-relation-tiers-retired` carries the prescription for `os migrate meta`. The step-18 conversion `cbp-master-detail-required-defaulted` covers the one lossless shape: a `controlled_by_parent` object whose one `master_detail` omits `required`, which Studio's field designer writes. It gets `required: true`, exactly what `ObjectSchema.create()` does. It runs on stored rows, built artifacts and `os migrate meta --from 17`. A live authoring parse still refuses the shape and names the fix. + - **`@objectstack/plugin-security`.** The relation resolver derives a master from that one field and nothing else. An object that declares a non-required `master_detail` only, a required `lookup` only, or two required `master_detail` fields resolves no master. Every read of it is denied, and every write and insert is refused with `422 INVALID_METADATA`. The plugin reports such an object once per object (`[security/controlled-by-parent] … resolves no master relation`), naming the shape it relied on. The `422` message now says "not exactly one required master_detail field". + - **`@objectstack/lint`.** `security-controlled-by-parent-no-relation` now refuses the non-required `master_detail` and required `lookup` shapes as well. `security-controlled-by-parent-ambiguous-relation` reports two or more required `master_detail` fields as "no master is derived", since the runtime no longer picks one by field order. Two required `lookup` fields are now a no-relation object, not a tie. `security-master-detail-ungranted` no longer treats a lookup-bound `controlled_by_parent` object as a detail. + + **FROM → TO — HotCRM's three objects.** These are the only producers measured on the retired third tier: the read-only HotCRM clone at `0f84c9c`, 8 `controlled_by_parent` objects, 5 of them already on one required `master_detail`. HotCRM's pinned engine is unaffected until it bumps past the release that carries this change. + + | object | FROM | TO | + |---|---|---| + | `crm_campaign_member` | `crm_campaign: Field.lookup('crm_campaign', { required: true })` | `crm_campaign: Field.masterDetail('crm_campaign', { required: true, deleteBehavior: 'restrict' })` | + | `crm_event_attendee` | `crm_event: Field.lookup('crm_event', { required: true })` | `crm_event: Field.masterDetail('crm_event', { required: true, deleteBehavior: 'restrict' })` | + | `crm_article_feedback` | `crm_knowledge_article: Field.lookup('crm_knowledge_article', { required: true, deleteBehavior: 'cascade' })` | `crm_knowledge_article: Field.masterDetail('crm_knowledge_article', { required: true, deleteBehavior: 'cascade' })` | + + `deleteBehavior: 'restrict'` keeps what the two lookups do today: a campaign or an event with members or attendees cannot be deleted. Without it a `master_detail` cascades, and a detail's rows are deleted with their master. An object whose rows must outlive their parent is not a detail. It leaves `controlled_by_parent` for `private`, `public_read` or `public_read_write`. + + **Remedy, in one line.** On every object with `sharingModel: 'controlled_by_parent'`, declare exactly one `master_detail` field with `required: true`, pointing at the master. Every other parent is a `lookup`. + + **Unchanged.** + + - An object with one required `master_detail` resolves and derives exactly as before, chain walk included. So does a required `master_detail` beside other `lookup` fields, or beside a `master_detail` that is not required. Every in-tree `controlled_by_parent` object is in that set: `examples/app-crm` (1), `examples/app-showcase` (2), the CLI's eval corpus (4) and the dogfood fixtures. + - Stored metadata whose one `master_detail` merely omits `required` keeps serving, converted as above. A stored row in any other retired shape is not rewritten: it still registers, under its `[metadata_spec_invalid]` diagnostic, and is denied until it is corrected and saved. Those shapes are an explicit `required: false`, two masters, or a required `lookup`. None of them is converted: turning a `lookup` into a `master_detail` changes what deleting the master does to every row, choosing between two masters is the pick ADR-0055 §1 refuses, and leaving `controlled_by_parent` is the author's decision. + - No export, signature or error code is added or removed. + + +- 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 `.`. +- d537a31: feat(lint)!: a formula field's `expression` is judged on the one-hop line — one hop through a reference passes, a second hop is refused + + Clause-②: no (narrowing) + + + + The field-formula pass now passes `traversalHydration` for a formula `expression`, because the engine's formula read path loads one hop through a reference field. + + - `record.book.title + ' · ' + record.reader.name` passes, as before. It now also computes at run time. + - `record.book.shelf.code` (two hops) is refused. The refusal names the field and says a formula resolves one hop. + - `record.reader == null ? '—' : record.reader.name` (the reference read both through and as a plain value) is refused. The refusal names `record.reader.?name.orValue('—')`, the read that gives an empty reference a value. + + What to do. Read a field stored on the related object instead of a second hop, and replace a null test on a reference with the optional read. +- 69147ab: fix(objectql)!: the one-hop read through a reference never reads a stored-metadata-body table, and the lint refuses that read where it is written + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The tables of the stored-metadata-body family (`STORED_METADATA_BODY_OBJECTS`) are projected or refused at every door that serves them. The one-hop read a formula field and a validation rule share (`resolveOneHopRelated`) was an ordinary engine read, which the family's projection does not reach. + + - **`@objectstack/objectql`.** A hop whose target is a family table is not read, whichever authority the hop is read under. A formula field reading through it is `null` with its reason, on every read, write response and `serveFormulaField` call (the seam a hook body's `ctx.title()` reads through). A validation rule reading through it has no verdict, so the write it judges is refused. The answer is the one an unreadable related record gets, so it says nothing about the row. A hop into any other object is unchanged. + - **`@objectstack/lint`.** A formula field's `expression`, or a validation rule's hydrated `condition`, that reads through a reference into a family table is an error, with the prescription. `os validate`, `os build` and the object save door run the same pass, so the author learns it at save rather than from a `null`. + + What to do. Remove the read through the reference. Stored metadata is read through the metadata API (`GET /api/v1/meta/:type/:name`), where its body is projected. Reading the reference field itself (the stored id) is unchanged. +- 45bec43: fix(lint)!: a formula that reads, through a reference, a field the reference's target does not declare is refused where it is written + + Clause-②: no (narrowing) + + + + **BREAKING** (an authoring narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The field-formula pass judges the field a formula reads one hop through a reference against the target object's fields: the fields it declares, the system columns the platform adds to it, and `id`. `os validate`, `os build` and the object save door (`PUT /api/v1/meta/object/:name`) run the same pass, under the existing `EXPRESSION_INVALID` rule. + + - FROM: `record.book.name`, where `book` is a lookup to an object that declares no `name`, saved with a 200 and read `null` on every row. TO: refused (`422 INVALID_METADATA` at the save door). The message names the field and the target: "unknown field `name` on ``". + - `record.book.title`, where the target declares `title`, passes as before. + - A target the stack does not carry is not judged. At the save door the stack carries the live registry's objects in the written package's closure, so a target from a package it depends on is judged. + + What to do. Read a field the target declares, or declare the field on the target object. Do not declare it on the object that carries the formula. At `os build` and `os validate` this pass does not see a target field that an `objectExtensions` entry contributes, while the save door judges the merged object. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- ace0a53: feat(lint)!: `relationship/master-detail-required` refuses the three unsafe master-reference shapes at `error` on a `controlled_by_parent` object (#9139) + + Clause-②: no (narrowing) + + A `controlled_by_parent` detail derives all of its record access from the master its `master_detail` reference names (ADR-0055). Three declarable shapes of that reference leave the security gate as the only thing refusing a detail record saved without its master, because record validation never checks a field that is not `required` and skips `readonly` and `system` fields before its required check: + + 1. `required` absent, or `required: false`; + 2. `required: true` with `readonly: true`; + 3. `required: true` with `system: true`. + + A record that lands without its master anyway is readable by nobody, and every later write to it by id is refused. Until now `relationship/master-detail-required` was a `warning` with the predicate "`required` is not `true`", on every object, so shapes 2 and 3 drew no finding at any severity. The maintainer ruling of 2026-08-16 (Direction 1) scheduled the promotion for the v18 boundary, scoped to `controlled_by_parent`. + + **BREAKING — what moves for consumers.** + + - `os lint` reports each of the three shapes at `error` when the object declares `sharingModel: 'controlled_by_parent'`, located at the defect (`…fields.FIELD.required`, `.readonly` or `.system`). It covers every `master_detail` field of such an object, the same scope the builder's `required: true` force already applies. `os lint` therefore exits non-zero on such a stack, and the metadata-generation rubric (`scoreMetadata`) weighs the finding as an error and marks the stack `valid: false`. + - `@objectstack/spec` gains the step-18 semantic migration entry `cbp-master-detail-required-lint-error`, so `os migrate meta` across protocol 18 prints the prescription below. + + **Remedy — the v18 upgrade-checklist line.** On every object with `sharingModel: 'controlled_by_parent'`, give each `master_detail` reference `required: true` and remove any `readonly: true` or `system: true` from it. `os lint` now refuses the missing-`required`, `required` + `readonly` and `required` + `system` shapes there at `error` (`relationship/master-detail-required`). An object authored through `ObjectSchema.create` already gets `required: true` when the key is omitted, so the edit there is dropping the flag. + + **Unchanged.** + + - On every object that is not `controlled_by_parent` the rule is exactly as before: a `warning` for a `master_detail` without `required: true`, the same message and fix, and no finding for the two flagged shapes. + - The rule is not in the authoring-rule registry. `os build`, `os validate` and the metadata save door do not run it, so a stack carrying one of the shapes still builds and publishes. Only `os lint`'s exit code and the generation rubric move. + - Runtime is untouched. The security gate keeps refusing an insert that omits the master FK on these shapes and keeps resolving the master for metadata already at rest, and stored metadata is neither rewritten nor refused on load. + - No export or signature moves in either package. + - Measured before crossing, at `b04a5295f`: 129 authored objects across the example apps, the platform, plugin and service objects and the CLI's golden eval corpus. 7 of them are `controlled_by_parent`, and 0 draw the new `error`. + + + +### Patch Changes + +- 4ad3ce5: fix(lint, service-analytics): a total over a formula field names the stored-number remedy, not a count + + Clause-②: no + + - **What changed.** A dataset or cube measure taking `sum`, `avg`, `min` or `max` over a `formula` field is still refused (`measure-aggregate-field-type-refused` in `os validate`, `400 DATASET_INVALID` at the dataset compile). The prescription changed. It used to name `count` / `count_distinct` as the way out, and a count read as a total is a plausible wrong number (3 records shown as "total leave days" where the true total is 10). It now reads: "The field is computed on read and stores no value: store the value as a number field (kept by a hook) and aggregate that. `count` / `count_distinct` answer a different question: how many records." + - **One source.** The sentence lives in `@objectstack/spec/data` as `VIRTUAL_FIELD_AGGREGATE_REMEDY`, returned by `virtualFieldAggregateRemedy(aggregate, fieldType)` for a refused value-reading aggregate over a virtual type (`SEARCH_VIRTUAL_TYPES`, which is `formula`). The function returns `undefined` for every other pair. The lint hint, its `os explain measure-aggregate-field-type-refused` entry and the dataset compile leg all print this one sentence. + - **What stays the same.** Nothing is newly accepted or refused. Every other refused pair keeps its own hint, including `count_distinct` over a JSON-stored or `multiple: true` field. The analytics door's cube-query refusal (`400 INVALID_FIELD`) and the engine's `aggregate` door keep their own wording. +- df9d07e: fix(lint): the RLS predicate and org-axis rules judge an object's `defaultRowScope` + + Clause-②: no + + - **What changed.** `validateRlsPredicateEnforceability` now judges `objects[].defaultRowScope.using` and `.check` with the same passes it runs on a permission set's `rowLevelSecurity[]` policy, with the declaring object as the target. A default whose predicate does not lower, does not parse, overruns a parse bound, names a column the object does not declare, reads a `current_user` key nothing resolves, or (for a `select` / `all` read scope) is refused by the engine's filter admission is reported under the same id, at the same `error` severity, at `objects[N].defaultRowScope.`. `validateOrgAxisRedLines` rule ① (`org-axis-permission-inheritance`) also judges a default that reads `parent_organization_id`. + - **A new authoring-time finding on a shape that was silent.** Until now these rules walked `permissions[].rowLevelSecurity[]` only, so `os validate`, `os build` and `os lint` passed such a default. The security plugin's row-policy collection hands an object's default to the same compiler an authored policy reaches, so a predicate that does not lower is dropped at request time and the object reads as zero rows for every caller the default applies to. A stack that declares such a default now fails these commands; the fix is the same rewrite the finding prescribes for a permission-set policy. A default that lowers, and an object with no default, report nothing new. + - **How the finding reads.** `where` names the default and its object (`default row scope "NAME" of object "OBJECT"`), never a permission set. `os explain` for the RLS predicate ids now says what a dropped `using` does for both sources: a permission set's policy denies the holders of that set, and a default, applied only when the caller's sets carry no row policy for the object and operation, denies every caller it applies to. + - **The key's own text.** `ObjectSchema.defaultRowScope`'s description said authoring checks do not judge its predicate; it now says `os validate`, `os build` and `os lint` judge it as they judge a permission set's policy, and that saving the object through the server does not run that check. +- 7aa8d51: feat(plugin-security): the boot no longer writes `sys_capability` rows for the platform's curated capabilities + + Clause-②: no + + The curated and derived-default capability seeder (`bootstrapSystemCapabilities`) is deleted. On a fresh database the boot writes no `sys_capability` row for the nine curated platform capabilities (`manage_users`, `manage_org_users`, `manage_metadata`, `manage_platform_settings`, `setup.access`, `setup.write`, `studio.access`, `manage_sharing`, `view_all_audit_log`), and none for a name a permission set grants in `systemPermissions` without declaring it. The curated capabilities are served by the registry: `GET /api/v1/meta/capability` lists them and `GET /api/v1/meta/capability/:name` answers each one's definition, as before. Declare a capability your package grants with `defineCapability` (`stack.capabilities`); a declared capability is still seeded as a package row. + + No authorization decision changes: a grant is still resolved by the capability name in `systemPermissions`, and no runtime check reads a `sys_capability` row. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. + + In `@objectstack/spec` (`PLATFORM_CAPABILITIES`) and `@objectstack/lint` (`validateCapabilityReferences`), the doc comments now name the registry instead of the retired seeder; no behaviour changes there. +- 95a8435: feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted + + Clause-②: no + + `SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served. + + A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run. + + In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `CAPABILITY_NAME_COLLISION` + - `capabilityNameCollisionDiagnostic` + - `formatCapabilityNameCollisionDiagnostic` + - `reportCapabilityNameCollisions` + - `CapabilityNameCollisionDiagnostic` (type) + + FROM → TO: + + - FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`. + - FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place. +- 66949b7: feat(plugin-security)!: the four security-catalog row objects are retired — `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` (ADR-0131 D3) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Positions, permission sets and capabilities live in the security catalog — the environment registry, fed by package declarations and by environment metadata saved through `/api/v1/meta` (ADR-0131 D3). The four row objects that mirrored them are no longer registered, so their data and REST routes and their Setup object pages are gone. No table is dropped and no row is deleted. + + FROM → TO: + + - `GET|POST|PATCH|DELETE /api/v1/data/sys_permission_set` → `GET|PUT|DELETE /api/v1/meta/permission/:name`; to copy a set, `POST /api/v1/security/permission-sets/:name/clone` with `{ name, label }`. + - `… /api/v1/data/sys_position` → `/api/v1/meta/position/:name`. A position's permission sets are its definition's `permissionSets`. + - `… /api/v1/data/sys_position_permission_set` → the position definition's `permissionSets` (`PUT /api/v1/meta/position/:name`). + - `… /api/v1/data/sys_capability` → read-only `GET /api/v1/meta/capability`; a package declares a capability with `defineCapability` (`stack.capabilities`). + - Setup → Access Control → Positions / Capabilities / Permission Sets now open Setup's catalog lists (`/apps/setup/metadata/?scope=environment`) instead of the object pages; the `sys_position` record page (`SysPositionDetailPage`) is removed. + - An object permission on one of the four names in a permission set grants nothing and can be deleted. + + What else changes: + + - `sys_user_permission_set.permission_set_id` is a plain text column now, not a lookup: same column, same stored ids, same indexes. It is not written and grants nothing; a grant names its set by `permission_set`. + - A permission set saved through the metadata door is written to no row: the `permission` mutation projector, the `sys_permission_set` and `sys_position` data-door write-throughs and the row-only position backfill are no longer registered. + - On a fresh `single`-posture database the first human is promoted to platform administrator without reading any catalog row. An unscoped grant written before `sys_user_permission_set.permission_set` existed (it names no set) still withholds the promotion until the organization-ownership migration names it. + - Uninstalling a package still revokes every grant naming a permission set the package declared, by the names the uninstalling door captures before the withdrawal (`declared`, `captureUninstallDeclared`), and now by those names alone: the cleanup reads and removes no `sys_permission_set` or `sys_position_permission_set` row, so the `sets` and `positionBindings` counts on its outcome are always 0. A grant written before `sys_user_permission_set.permission_set` existed names no set and is not reached; the organization-ownership migration names it. + - On a `group` or `isolated` deployment the data-door half of the walled catalog refusal goes with the objects: a data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability` answers `404 OBJECT_NOT_FOUND` for every caller instead of `403 PERMISSION_DENIED` below the platform operator. The refusal at the `/meta` write doors, the permission-set clone door and the `/packages` write doors is unchanged. + - `capability-reference-unknown` no longer counts a `sys_capability` seed row as a declared capability — no runtime reader consults one. Declare the capability with `defineCapability`. + - On an existing database `os migrate plan`'s unmanaged-table sweep now lists the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` tables: they keep their rows and no object declares them any more. The sweep only reports; nothing is dropped, and the tables stay for the organization-ownership migration. + + Removed exports, no replacement: + + - `@objectstack/plugin-security`: the object definitions `SysPosition`, `SysPermissionSet`, `SysPositionPermissionSet`, `SysCapability` (from `./objects`); `securityObjects` lists the remaining three. + - `@objectstack/platform-objects/pages`: `SysPositionDetailPage`. + - `@objectstack/spec`: `SystemObjectName.POSITION`, `SystemObjectName.PERMISSION_SET`; the four names leave `PLATFORM_OBJECTS_BY_PACKAGE['plugin-security']` and `PLATFORM_PROVIDED_OBJECT_NAMES`. + - `@objectstack/core`: `isRowActive` and `ActivatableRow` — a catalog row's `active` column decides nothing; deactivation is the activation ledger's (`sys_metadata_activation`). + - `@objectstack/objectql`: the published `PLATFORM_OBJECT_TENANCY` record loses its `sys_permission_set` key (the other three names were never in it). A removed key of an exported constant is a narrowing, so `@objectstack/objectql` takes `minor` under the launch-window convention for breaking changes. +- 9fdb127: A `loop`'s (with a `body`) and a `map`'s `collection` is a value slot now: the list the node iterates is a CEL value envelope, `{ dialect: 'cel', source: 'tasks' }`, that the executor evaluates in the run's scope, or the items written as an inline array, iterated as written. A string there is refused at `objectstack validate`, at `registerFlow` and by the executor: a `{…}` template token, naming its CEL spelling, and a variable's bare name, naming that variable in an envelope. The envelope is newly accepted there; the contract refused it before this change. + + Clause-②: yes (narrowing: a string is refused at loop.collection and map.collection, a {…} token and a variable's bare name alike; widening: both accept a CEL value envelope they refused, their published JSON Schema and inferred types gain the envelope arm, and FlowCollectionSlotSchema is a new export) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (both slots accept a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. The list a `loop` or `map` iterates is a computed value. Until now the executors ran a string `collection` through the single-brace interpolator. The contract also declared a variable's bare name there (`collection: 'tasks'`), but the structured `loop` and `map` never read one: the interpolator handed the name back unchanged, the value was no list, and every run failed at the node ("collection 'tasks' did not resolve to an array"), although `registerFlow` and `objectstack validate` accepted the flow. That arm retires with the token, and the refusal names the envelope that iterates. + + **What changes where the list may be absent.** The envelope must evaluate to a list, and anything else fails the node, `null` included, as a token that resolved to nothing did. One difference remains on an absent variable: the template failed the node with a returned failure (the step's `NODE_FAILURE`), and CEL fails it with an evaluation fault naming the variable (`EXECUTION_ERROR`). A `fault` edge routes both. To iterate nothing when the list may be absent, guard it to answer `[]`: `has(vars.tasks) ? vars.tasks : []`. + + **What does not change.** An inline array is iterated as written, and its elements are data: a `{…}` inside one was never interpolated, and is not judged now. A `loop` with no `body` is the legacy flat-graph form: it still reads `collection` as a bare variable name and sets `$loopItems` and `$loopIndex`, and no door judges it. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `collection: '{tasks}'` | `collection: { dialect: 'cel', source: 'tasks' }` | an absent `tasks` fails the node with an evaluation fault instead of a returned failure | + | `collection: '{record.items}'` | `collection: { dialect: 'cel', source: 'record.items' }` | as above | + | `collection: '{tasks}'` where an absent list should iterate nothing | `collection: { dialect: 'cel', source: 'has(vars.tasks) ? vars.tasks : []' }` | the node iterates nothing where the template failed it | + | `collection: 'tasks'` (a bare name, on a `loop` with a `body` or a `map`) | `collection: { dialect: 'cel', source: 'tasks' }` | the node iterates; it failed every run before, so decide whether it should | + | `collection: ['a', 'b']` | unchanged | an inline array is the list | + | `collection: 'tasks'` on a `loop` with no `body` | unchanged | the legacy flat-graph loop is untouched | + + **The one-line fix: write a `loop`'s or a `map`'s `collection` as a CEL value envelope naming the list, `{ dialect: 'cel', source: 'tasks' }`, or as an inline array.** + + **Who is affected, measured.** In this repository: the showcase example (two `loop` collections and one `map` collection), the todo example (two `loop` collections), the flows guide, the service-automation README, and test fixtures; all are migrated in this change. hotcrm writes a `{…}` collection on eleven structured loops in nine flow files (and one test fixture) and no bare name; it is on spec 17.7.0, so nothing there moves until it upgrades. + + **Newly accepted.** A CEL value envelope at `loop.collection` and `map.collection`: the published JSON Schema for both gains the envelope arm (a list, or the envelope object), and so do the inferred `LoopConfig['collection']` and `MapConfig['collection']`, which lose `string`. `FlowCollectionSlotSchema` (`@objectstack/spec/automation`) is the contract both take. + + **Still accepted, unchanged.** An inline array at both, and on a body-less `loop` any `collection` its legacy form reads. + + ### The kit + + - **The contract.** `LoopConfigSchema.collection` and `MapConfigSchema.collection` take `FlowCollectionSlotSchema`: a list or a CEL value envelope, so the executor's contract parse refuses a string as a guard, in the words above. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS`' `loop.collection` and `map.collection` rows take the `value` role, and `loop.collection` is read only on a `loop` with a `body` (`onlyWithKey`). The `loop` and `map` descriptors declare the slot with `xExpression: 'value'`. The single-brace `flow-template` role, which recorded these two slots and no other, is gone from `FlowNodeExpressionRole`, so a descriptor marker `'template'` fails the reconciliation ratchet as unknown. + - **The executors.** The `loop` and `map` nodes evaluate an envelope through `AutomationEngine.evaluateValueEnvelope`, as every value slot does, and iterate an inline array as written. A value that is not a list fails the node, naming what it evaluated to and its source. + - **The lint.** `validate-flow-template-paths` reads a loop iterator's object off an envelope `collection` that names a variable, as it read the token before. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 968acc8: An `http` node reads each config key by what it is now. Its `url` and every `headers` value are text slots, like a notify `title`: they render `{{ }}` holes, `'https://api.example.com/v1/orders/{{ record.id }}'`. Its `body` is a value slot: a CEL value envelope, `{ dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }`, that the executor evaluates in the run's scope to the payload, or a literal sent as written. Its `method`, `durable`, `timeoutMs` and `signingSecret` are literals, read as written. A single-brace `{…}` token at any of them is refused, naming its `{{ }}` spelling at a text slot, its CEL spelling in the body, and at a literal the remedy to write the value: at `objectstack validate`, at `registerFlow` and by the executor, except at `signingSecret`, which the build doors leave to the run because the flow credential store may hold the secret it signs with, and which the executor's parse refuses there. + + Clause-②: yes (narrowing: a {…} token is refused at http url, each headers value, any string of a literal body, method, durable, timeoutMs and signingSecret, and as a whole headers map, and a body value that names a dialect must be a valid CEL value envelope; widening: the exported FlowNodeTextSlot type gains the optional eachValue field, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains http, and the published HttpConfig JSON Schema marks body as a value slot) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a url and a header are text. Until now the executor handed its whole config to the single-brace interpolator and parsed the result, so every key read the retired dialect, a whole token in a typed key (`timeoutMs: '{t}'`) resolved to its value's type, and a CEL envelope written as the body was sent to the endpoint as the object it spells. + + **The request is rendered once.** The executor parses the config as authored — after the flow credential store's `signingSecret`, unchanged — then renders the url and the headers and evaluates the body, before either mode. The durable outbox enqueues exactly the request the inline call would send, and nothing renders at delivery. + + **What changes where a value may be absent.** A url that renders to no text refuses the node, as an empty one did. A header that renders to no text is not sent, where the template sent it empty. In the body an absent variable or key fails the run under CEL, naming it, where the template wrote `null` for a whole token and nothing inside text; guard a value that may be unset, `has(record.owner) ? record.owner : null`. CEL holds a map's values to one type, so in a CEL map literal a literal or a text beside a field read is wrapped in `dyn(…)`. + + **The literals.** `method`, `durable`, `timeoutMs` and `signingSecret` passed through the interpolator too, but no shipped flow computed one: the census found no `{…}` token at any of them in this repository's examples, docs or dogfood fixtures, only in the tests that pinned the interpolation itself. So no value slot is built there. They are read as written, and a `{…}` token in one is refused with the literal's remedy; an envelope there is refused too, since nothing evaluates it. A token written as the whole `headers` map, which the template resolved to a map, is refused the same way. A signing secret that arrived through a template (`signingSecret: '{signing_key}'`) is refused: write the secret itself, which a flow saved through the metadata API keeps in the write-only flow credential store, or `''` to send unsigned on purpose. + + **What does not change.** Every literal is sent as written. The method still defaults to `GET` inline and `POST` durable. An authored `signingSecret: ''` still sends unsigned, and a secret the credential store holds still takes the literal's place. A url or header with no token, and a body with no token, behave as before. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `url: 'https://api.example.com/v1/orders/{record.id}'` | `url: 'https://api.example.com/v1/orders/{{ record.id }}'` | a `Date` in the url renders as its ISO text, not JSON-quoted | + | `headers: { 'X-Ref': 'r-{record.id}' }` | `headers: { 'X-Ref': 'r-{{ record.id }}' }` | a header that renders to no text is not sent | + | `body: { id: '{record.id}', status: 'done' }` | `body: { dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }` | an absent `id` fails the run instead of sending `null` | + | `body: { text: 'Done: {record.title}' }` | `body: { dialect: 'cel', source: "{'text': 'Done: ' + record.title}" }` | an absent `title` fails the run instead of sending `'Done: '` | + | `body: '{payload}'` | `body: { dialect: 'cel', source: 'payload' }` | an absent `payload` fails the run instead of sending no body | + | `timeoutMs: '{timeout}'`, `durable: '{flag}'`, `method: '{verb}'` | the value itself: `timeoutMs: 5000`, `durable: true`, `method: 'POST'` | the key is a literal now; nothing computes it | + | `headers: '{headers}'` | the map itself: `headers: { 'X-Ref': '{{ record.id }}' }` | a map is not computed into `headers` | + | `signingSecret: '{signing_key}'` | the secret itself, or `''` to send unsigned | a secret read from a flow variable is refused at the run | + + **The one-line fix: double the braces in `url` and the `headers` values, write `body` as one CEL value envelope that builds the payload, and write `method`, `durable`, `timeoutMs` and `signingSecret` as the values themselves.** + + **Who is affected, measured.** In this repository, at the base: the showcase example (three bodies holding four tokens) and the flows guide (one body); both are migrated in this change. No url, header, method, `durable`, `timeoutMs` or `signingSecret` in the examples, docs or dogfood fixtures carries a token. hotcrm was not measured at these keys. + + **Newly refused in the body.** An object naming a `dialect` was data before and was sent as written; it is an expression now, evaluated per run, and a malformed one is refused. An envelope nested inside a literal body is still data, sent as written. + + **Still accepted, unchanged.** Every literal at these keys, and the published shapes of `url` (a string), `headers` (a map of strings), `method`, `durable`, `timeoutMs` and `signingSecret`. + + ### The kit + + - **The contract.** `HttpConfigSchema`: `url` and each `headers` value refuse a single-brace token through the text-slot judge; `body` takes `FlowValueSlotSchema`; `method`, `durable`, `timeoutMs` and `signingSecret` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. The token refusals at `method`, `durable`, `timeoutMs` and a whole `headers` map are marked read-as-written, so the build doors' value arm reports them at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains the `http` body row. `FLOW_NODE_TEXT_SLOTS` gains `http.url` and `http.headers`, whose new `eachValue` flag reads each value of the map at its own path (`headers.X-Ref`). `HttpConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the body's marker to the reconciliation ratchet. The build doors' carve-out for a config parsed after interpolation is gone with the interpolation. + - **The executor.** The `http` node parses its config as authored, renders the url and headers through the text renderer, evaluates the body through the shared value-slot resolver, and reads the literals as written; it no longer imports the single-brace interpolator. + - **The lint.** The `flow-double-brace-interpolation` hint names the http url and headers among the text slots, and at an http literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract. + - **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused`, `flow-text-slot-unbound-dollar-root-refused` and `flow-builtin-node-config-values-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 111ffff: A `notify` node's computed values are value slots now: `recipients`, `sourceId` and the values of `templateData` take a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, that the executor evaluates in the run's scope, or a literal written as it is. `recipients` holds one recipient or a list of them, each entry a string or an envelope, and an entry that evaluates to a list adds each of its recipients. The node's link, `actionUrl`, is a text slot like `title`: it renders `{{ }}` holes, `'/crm_case/{{ record.id }}'`. Its `sourceObject`, `actorId` and `payload` are literals, read as written: no flow computed one, so nothing is evaluated there. A single-brace `{…}` token at any of these positions is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling at a value slot, its `{{ }}` spelling at `actionUrl`, and, at a literal, the remedy to write the value. + + Clause-②: yes (narrowing: a {…} token is refused at notify recipients (the value or a list entry), templateData values, sourceId, actionUrl, sourceObject, actorId and any string of a payload value, a templateData value that names a dialect must be a valid CEL value envelope, and a CEL value envelope written as a payload value is refused; widening: recipients and sourceId accept a CEL value envelope they refused, a recipients list accepts envelope entries, and their published JSON Schema and inferred types gain the envelope arm) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (four positions accept a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a link with holes is text. Until now the executor ran each of these positions through the single-brace interpolator, and one of its answers reached nobody: a whole token holding a list, written as an entry of a `recipients` list — `recipients: ['{owners}']` with `owners = ['u1', 'u2']` — was handed to the messaging service as ONE recipient, `'u1,u2'`, and the run reported success. A token holding a record there became the recipient `'[object Object]'`, and so did one in a link or a `sourceId`. + + **What changes where a value may be absent.** A recipient envelope that evaluates to `null` or a blank string adds no recipient, as a token that resolved to nothing did, and a node left with none fails, naming the expressions that evaluated to nothing. An absent variable or key fails the run under CEL, naming it, where the template dropped the recipient; guard a field that may be unset, `has(record.assignee) ? record.assignee : null`. A relation hop on a scalar id (`record.account.owner` without the start node's `expand`) fails the evaluation itself, naming the key it could not read, where the template resolved it to nothing. `sourceId` must evaluate to a string; `null` leaves the key unset, and a half-specified click-through pair is still dropped. A recipient, or a `sourceId`, that evaluates to anything else — a map, a number — fails the node, naming what it evaluated to. + + **The literals.** `sourceObject`, `actorId` and the values of `payload` passed through the single-brace interpolator too, but no flow computed one: the census found no `{…}` token at any of them, in this repository or in hotcrm. So no value slot is built there. They are read as written, and a `{…}` token in one — in any string of a `payload` value, at any depth — is refused with the literal's remedy: a single-brace token is not resolved here; write the value. An envelope written there is refused too, since nothing evaluates it (a deeper one inside a `payload` value is data, as it always was). + + **What does not change.** A literal at every value slot is written as it is: a recipient string (`'team:ops'`, a user id, an email address) is one recipient, as before, and is not split. `template`, `topic`, `channels` and `severity` are read raw, as before. An explicit `actionUrl` still wins over the link synthesized from `sourceObject` / `sourceId`, and a link that renders no text yields to it, as an empty one did. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `recipients: '{record.owner}'` | `recipients: { dialect: 'cel', source: 'record.owner' }` | an absent `owner` fails the run instead of leaving the node without a recipient | + | `recipients: ['{record.assignee}']` where it may be unset | `recipients: [{ dialect: 'cel', source: 'has(record.assignee) ? record.assignee : null' }]` | none: the `null` is dropped, as the unresolved token was | + | `recipients: ['{owners}']` (a list variable) | `recipients: [{ dialect: 'cel', source: 'owners' }]` | each of its recipients is notified, where the template sent one joined string | + | `recipients: ['{$User.Id}']` | `recipients: [{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }]` | none: a run with no user adds no recipient | + | `sourceId: '{record.id}'` | `sourceId: { dialect: 'cel', source: 'record.id' }` | an absent id fails the run instead of dropping the link | + | `sourceObject: '{record.object_name}'`, `actorId: '{record.owner}'`, `payload: { who: '{record.owner}' }` | the value itself: `sourceObject: 'crm_case'` | the key is a literal now; nothing computes it | + | `templateData: { name: '{record.name}' }` | `templateData: { name: { dialect: 'cel', source: 'record.name' } }` | the template gets `null` (a guard's answer) where the key was absent | + | `actionUrl: '/crm_case/{record.id}'` | `actionUrl: '/crm_case/{{ record.id }}'` | a `Date` in the link renders as its ISO text, not JSON-quoted | + + **The one-line fix: write each `notify` value position as a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, double the braces in `actionUrl`, and write `sourceObject`, `actorId` and `payload` as the values themselves.** + + **Who is affected, measured.** In this repository, at the base: the showcase example (16 recipient strings, 7 `sourceId`s and 4 links), the todo example (2 recipients and 2 `sourceId`s), the flows guide, the email-templates and common-patterns guides, two dogfood fixtures and test fixtures; all are migrated in this change, and a recipient field that may be unset is guarded. hotcrm at `1d7148bf2d` writes 19 notify nodes in 15 flow files, each with one `{…}` recipient (2 of them `{$User.Id}`), 35 `{…}` `templateData` values and a link with holes; it is on spec 17.7.0, so nothing there moves until it upgrades. + + **Newly accepted.** A CEL value envelope at `recipients` (the value, or each entry of a list) and `sourceId`: their published JSON Schema gains the envelope arm, and so do the inferred `NotifyConfig` types. At a `templateData` key an object naming a `dialect` was data before; it is an expression now, evaluated per run, and a malformed one is refused. `sourceObject`, `actorId` and `payload` keep their published shapes (a string, a string, a map of any values), with no envelope arm. + + **Newly refused at the literals.** A `{…}` token in `sourceObject`, in `actorId` or in any string of a `payload` value, and a CEL value envelope written as a `payload` value, which was handed on as the object it spells. + + **Still accepted, unchanged.** Every literal at these positions. + + ### The kit + + - **The contract.** `NotifyConfigSchema`: `recipients` takes a string, a list of strings and envelopes, or one envelope; `templateData` takes `FlowValueSlotSchema` per key; `sourceId` takes a string or an envelope; `actionUrl` refuses a single-brace token through the text-slot judge; `sourceObject`, `actorId` and `payload` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. That refusal is marked read-as-written, so the build doors' value arm reports it at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains three `notify` value rows. `recipients` carries `eachElement`: a list is read entry by entry, at its index (`recipients[1]`). `FLOW_NODE_TEXT_SLOTS` gains `notify.actionUrl`. `NotifyConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the markers to the reconciliation ratchet. + - **The executor.** The `notify` node evaluates each envelope through the shared value-slot resolver, renders `actionUrl` through the text renderer and reads the literals as written; it no longer imports the single-brace interpolator. + - **The lint.** The `flow-double-brace-interpolation` hint names `actionUrl` among the text slots, and at a notify literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract. + - **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- d85615d: A flow VALUE slot now refuses the date macros as well — `{TODAY()}`, `{NOW()}`, with an optional `± N` day offset — so it reads no single-brace `{…}` token at all. Each is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL string form: `isoDate(today())`, `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, `isoDatetime(now())`, `isoDatetime(addDays(now(), N))`. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The template dialect is retired from the value slots: one dialect per slot, with a remedy for every spelling. The date macros were the last spelling kept, because CEL had no string form for a timestamp: `today()` and `now()` reach the data engine as a `Date` object, not the ISO text the macros wrote. The CEL stdlib now has one — `isoDate(t)` (`YYYY-MM-DD`) and `isoDatetime(t)` (`YYYY-MM-DDTHH:mm:ss.sssZ`), on the UTC calendar the macros rendered on — so the macros can be refused with a remedy that writes the same text. + + **What is refused.** In every value slot — `create_record` / `update_record` `fields`, the `assignment` node's `assignments` map, and both legacy `assignment` shapes — a string carrying a date macro, alone or beside other text and tokens. A string that mixed a date macro with another token used to be kept whole, the run-user token included (`'Due {TODAY()} by {$User.Id}'`); it is refused now, with a remedy for every token. + + **Where the remedy parts from the template.** For no offset or a whole number of days the CEL form writes the template's text byte for byte, at every instant (measured live through both engines over DST transitions, month and year ends and a leap day). The refusal names the three places it does not: + + - a fractional offset: the template added it to the day of the month and truncated the sum, `addDays` truncates the offset itself — going back, a day apart on most days of a month — and `daysFromNow(1.5)` / `daysAgo(1.5)` are refused at build; + - a variable offset (`{TODAY() + days}`): the template looked the offset up as one variable name and added 0 days, without a word, when it found none or the value was not a number; CEL reads the path, and fails the run on an absent variable or a value that is not a number; + - an offset that is neither a number nor a variable name (`{TODAY() + 3d}`): the template added 0 days. + + **Where an envelope is literal data, the remedy names none there.** The refusal used to prescribe an envelope at whatever position it refused a token. Inside an object or list value, and in the legacy `assignment` shapes, the executor reads an envelope as data: the prescribed metadata registered and stored the envelope object. The refusal there now names what evaluates — the whole value built as one CEL map or list literal (mixed values each wrapped in `dyn(…)`), or the assignment moved into the `assignments` map. This holds for every token, not only the date macros. An envelope-shaped object in those positions is still data, unchanged. + + **The `has()` guard a refusal names now holds for an absent variable.** For a path that may be absent, the refusal named a guard on its last key, `has(source.id) ? source.id : null`. `has()` evaluates everything but its last selection, so where the variable itself was never bound — an `isInput` variable the caller left out, with no default — that guard failed the run (`Unknown variable: source`). The guard now tests each step off `vars`, which holds only the variables the run has bound: `has(vars.source) && has(vars.source.id) ? vars.source.id : null` answers `null` there, and where a key on the way is absent or `null`. `flow-bare-dollar-reference`'s value-slot hint carries the same guard. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{TODAY()}'` | `{ dialect: 'cel', source: 'isoDate(today())' }` | nothing | + | `'{TODAY() + 7}'`, `'{TODAY() - 3}'` | `{ dialect: 'cel', source: 'isoDate(daysFromNow(7))' }`, `{ dialect: 'cel', source: 'isoDate(daysAgo(3))' }` | nothing for a whole number of days; a fraction truncates differently, and `daysFromNow` / `daysAgo` refuse one at build | + | `'{NOW()}'` | `{ dialect: 'cel', source: 'isoDatetime(now())' }` | nothing | + | `'{NOW() + 2}'`, `'{NOW() - 1}'` | `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), 2))' }`, `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), -1))' }` | nothing — `addDays` keeps the time of day, where `daysFromNow` would land on midnight | + | `'{TODAY() + days}'` | `{ dialect: 'cel', source: 'isoDate(addDays(today(), days))' }` | an absent `days`, or one that is not a number, fails the run where the template silently added 0 | + | `'Due {TODAY()} by {$User.Id}'` | `{ dialect: 'cel', source: "'Due ' + isoDate(today()) + ' by ' + current_user.id" }` | guard the run user where a flow can run without one | + | `payload: { due: '{TODAY()}' }` (a string inside an object value) | `payload: { dialect: 'cel', source: "{'due': isoDate(today())}" }` | an envelope written at `payload.due` would be stored as an object | + | the guard an earlier refusal named, `has(source.id) ? source.id : null` | `has(vars.source) && has(vars.source.id) ? vars.source.id : null` | `null` where `source` was never bound, where the last-key guard failed the run | + | a text slot's `assignments: { due: '{TODAY() + 7}' }`, then `'Due {{ due }}'` | `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' } }`, then `'Due {{ due }}'` | nothing for a whole number of days | + + **The one-line fix: write a date macro as `isoDate(…)` / `isoDatetime(…)` in a CEL envelope, with a whole number of days.** + + **Who is affected, measured.** This repository's examples carry no date macro in a value slot; its own test fixtures and docs that did are migrated in this change. The census taken for this retirement's first change measured 15 date-macro value-slot sites in hotcrm at `c529de2` (8 `{NOW()}`, 3 `{TODAY()}`, 3 `{TODAY() + N}`, 1 `{TODAY() + var}`); they migrate there. Deployed metadata and other repositories were not measured here. + + **Still accepted, unchanged.** A CEL value envelope and every literal. + + ### The kit + + - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`) keep no token; the text-slot judge's date-macro remedy (`textSlotTemplateRefusal`) names the CEL envelope. `FlowValueSlotSchema` / `AssignmentValueSchema` say so in their descriptions. + - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the date macros and name their forms) and `flow-text-slot-single-brace-refused` (its date remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. + - **`@objectstack/lint`.** `flow-double-brace-interpolation`'s hint in a value slot names the CEL envelope for the holes the author meant, where it named `{record.title}`, which the slot refuses; `flow-bare-dollar-reference`'s value-slot hint asks the judge at the string's own position, so a nested or legacy-shape string gets the spelling that evaluates there, and carries the judge's step-by-step `has()` guard. + - **`@objectstack/service-automation`'s README.** Its *Expressions* section names the date macros' CEL forms. +- 6a06e3b: A flow VALUE slot now refuses the run-user template token `{$User.}` as well, and every CEL expression in a flow sees **`current_user`**, the run's user. `{$User.Id}` in a `create_record` / `update_record` `fields` value or an `assignment` value is refused at `objectstack validate`, at `registerFlow` and by the executor, naming `current_user.id` and, for a flow that can run without a user, its guard; every other `$User` path is refused saying it never resolved. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The template dialect is retired from the value slots, one dialect per slot, with a remedy for every spelling. The run user was the one path spelling kept, because the flow CEL scope bound no user: `current_user.id` passed `objectstack validate` and `registerFlow`, then failed the run with `Unknown variable: current_user`. The scope now binds it, so the token can be refused with a remedy that evaluates. + + **What `current_user` is.** In every flow CEL expression — a start-node `condition`, an edge `condition`, a `decision` condition, a screen field's `visibleWhen`, an `assignment` or `fields` value envelope — `current_user` is the run's user, built through `createEvalUser` from what the run context holds: `id` (the run's `userId`), `positions`, `organizationId` (the run's `tenantId`) and the derived `isPlatformAdmin`. No run carries an email or a name, so neither is bound. A run with no user — a schedule, a record change made by a system write — sees `null`, never a stand-in user: `current_user.id` then fails the run loudly, and `current_user != null` can guard it. A `runAs: 'system'` run sees the user that triggered it, as `{$User.Id}` did. A flow variable named `current_user` is shadowed by the binding, as one named `vars` is by the variables namespace; both are read as `vars["current_user"]` / `vars["vars"]`, and the refusal of a `{vars.…}` or `{current_user.…}` path token prints that spelling. + + **The expression remedy.** A refused `{…}` arithmetic token's CEL spelling now reads every variable path in it the way a lone path token's does — `{int * 2}` is `vars["int"] * 2`, `{items.0 * 2}` is `items[0] * 2` — instead of rewriting only its divisors, which printed envelopes that did not evaluate. + + **Still accepted, unchanged.** A CEL value envelope and every literal. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{$User.Id}'` | `{ dialect: 'cel', source: 'current_user.id' }` | nothing when the run has a user | + | `'{$User.Id}'`, in a flow that can run without a user | `{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }` — or skip the node with `current_user != null` as a start condition or a `decision` | the guarded form writes `null` where the template wrote nothing, so on `update_record` it clears a stored value the template left alone | + | `'{$User.Email}'`, `'{$User.Name}'`, any other `$User` path | a `get_record` on `sys_user` with `filter: { id: { dialect: 'cel', source: 'current_user.id' } }` and `outputVariable: 'me'`, then `{ dialect: 'cel', source: 'me.email' }` | these never resolved in any shipped run: they wrote nothing | + | `'Owner: {$User.Id}'` | `{ dialect: 'cel', source: "'Owner: ' + current_user.id" }` | in a user-less run, write the hole as `(current_user != null ? current_user.id : '')` | + | a text slot's `assignments: { by: '{$User.Id}' }`, then `'By {{ by }}'` | `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `'By {{ by }}'` | nothing when the run has a user; where the flow can run without one, compute `current_user != null ? current_user.id : null`, which renders nothing where the template did | + + **The one-line fix: write `current_user.id` where you wrote `{$User.Id}`, and guard it where the flow can run without a user.** + + **Who is affected, measured.** This repository's two authored value-slot sites are migrated in this change: the `examples/app-todo` quick-add screen flow (a screen flow always has a user, so the bare read) and the `os explain flow` catalog example, an `update_record` under a record-after-create trigger, which a system write fires with no user; it now gates on `current_user != null` in its start condition, so the stored value is left alone there as before. `examples/app-showcase`'s `{$User.Id}`, a `notify` `recipients` entry, is migrated to `current_user` with the `notify` value slots on this line. Other repositories and deployed metadata were not measured here. + + ### The kit + + - **The binding.** `AutomationEngine.celScope` (`@objectstack/service-automation`); `evaluateCondition` and `evaluateValueEnvelope` take the run context as an optional last argument, which every engine site and the `assignment`, `decision`, `create_record` and `update_record` executors pass. Without one, `current_user` is `null`. + - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`); the text-slot judge's run-user remedy (`textSlotTemplateRefusal`) now computes the id with the CEL envelope. + - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the run-user paths), `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` (their run-user remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. + - **`@objectstack/cli`.** `os explain flow`'s example writes `current_user.id` and gates on `current_user != null`. + - **`@objectstack/lint`.** `flow-bare-dollar-reference`'s hint for a bare `$name.path` in a value slot names the CEL envelope the value-slot refusal writes for it — `current_user.id` and its guard for `$User.Id`, the variable the reference names for any other — where it named `{source.id}` and `{$User.Id}`, both refused there. A text slot's hint keeps the `{{ }}` hole, and the hint at a node's literal, or at any other position, names the value itself. + - **`@objectstack/service-automation`'s README.** Its *Expressions* section states the value-slot envelope and `current_user`. +- 78f841b: A `script` or `subflow` flow node whose `config` carries a key its executor contract does not declare is refused at parse, with a location, in the contract's own words: a `script` `bogusKey`, a `subflow` `timeoutMs` written inside `config`, and the like no longer pass the build doors and registration and then fail every run. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** The `script` and `subflow` executors parse the node's `config` against a strict contract (`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared key. No door before the run judged one: `registerFlow`'s undeclared-key check reads the node type descriptor's `configSchema`, and these two descriptors publish none, while the build doors' executor-contract arm judged required keys and present values but not key membership. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied it into `dist/objectstack.json`), registered, and failed every run that reached the node: ``script 'n': config does not satisfy the script contract — config: Unrecognized key(s) on this script node config: `bogusKey` ``. + + **What is refused.** A `script` node, at any depth, whose config carries a key other than `function`, `inputs` and `outputVariable`, or a `subflow` node whose config carries a key other than `flowName`, `input` and `outputVariable`. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`), from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the metadata save door. + + **What stays as it was.** + + - Every other builtin node type: its undeclared keys are judged at registration against its descriptor's `configSchema`, with that check's own prescriptions, and the build doors do not judge them. + - `decision`: it publishes no descriptor `configSchema` either, but its executor parses no contract, so an undeclared key fails no run and stays unjudged. + - A retired `script` key (`actionType`, `template`, `recipients`, `variables`, `script`) keeps its tombstone path. + - A spelling an ADR-0087 D2 conversion still rewrites at load (`functionName` and `input` on a `script`, `flow` on a `subflow`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key, as its missing canonical key already was. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | a typo of a declared key (`funtion`, `outputVariabel`) | the declared key: `function`, `inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow` | + | a value the function or child flow should receive, as its own config key (`config: { function: 'f', taskId: '{record.id}' }`) | inside the input map: `config: { function: 'f', inputs: { taskId: { dialect: 'cel', source: 'record.id' } } }` (`input` on a `subflow`) | + | a `subflow` `config.timeoutMs` | on the node: `{ id, type: 'subflow', timeoutMs: 30000, config: { … } }` | + | a key nothing reads | delete it | + + **The one-line fix: rename, move or delete the key the refusal names.** The runtime never ran such a node, so the fix changes nothing a working flow does. + + **Who is affected, measured.** At `15ec50e528`, every `script` and `subflow` node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (8 nodes: 6 `script`, 2 `subflow`) carries only declared keys, and so does every one in hotcrm at `c9678036d9` (5 `subflow`, no `script`). The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys for both types (its `timeoutMs` field writes the node, not `config`), and seeds a new node with an empty `config`. Deployed metadata, and other repositories, were not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. + + **`@objectstack/lint`.** `validateStackExpressions` keeps the pre-conversion tolerance it declares: on a raw source, a `script` node's `functionName` alias stays the callable check's to read, not an undeclared-key error, while every other undeclared `script` key is refused there as at the build doors. + + ### The kit + + - **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, judged for the builtins in the spec's schemaless class (`SCHEMALESS_NODE_CONFIG_SCHEMAS`) that have an executor contract; no new code joins `FLOW_SLOT_REFUSAL_CODES`, and `getBuiltinNodeConfigContracts()` keeps its 13 entries. + - **The ledger.** The D3 semantic entry `flow-script-subflow-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. +- 6c17a50: The runtime publish gate no longer charges a write with a stored sibling's finding that only shows when the written item is present. Re-saving a master object with only its label changed answered `422 INVALID_METADATA` for a stored detail the author never touched: a detail's `lookupColumns` entry naming no field of the master, or a detail's `readonlyWhen` read through `parent`. + + Clause-②: no + + - On an update into a context collection (`object`, `permission`, `book`, `dataset`) the gate also judges the stored universe: the live collections with the written item's stored self in place. A finding located on another entry that the stored universe already holds is not this write's. + - A finding located on the written item itself is judged as before, even when the stored row carries the same finding. Re-saving a row is writing it. + - A write that newly breaks a sibling is still refused, for example removing the master field a detail's `lookupColumns` names. + - A create is judged as before. + - On a permission write the same change drops a stored detail's `security-master-detail-ungranted` advisory from a label-only re-save of the tenant's only permission set. + - A finding whose path names no entry the gate can locate keeps the previous verdict. + - ⛔ Nothing you author changes, and no export or signature changes. +- 9fe7448: fix(spec, lint): the functional-completeness findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** The nine rule ids the ADR-0078 completeness predicate emits (`field/summary-without-operations`, `field/formula-without-expression`, `field/relationship-without-reference`, `field/choice-without-options`, `view/layout-without-binding`, `view/tree-without-parent-field`, `view/row-color-without-colors`, `view/row-color-unresolvable-value`, `webhook/without-triggers`) printed a `message` of 239 to 793 characters. Each `message` is now one verdict sentence of at most 200 characters (for `view/row-color-unresolvable-value`, plus the offending `key` = "value" entries it lists). It still names the runtime site that makes the verdict true, for example `` A `summary` field with no `summaryOperations` reads 0/null everywhere: the engine's summary index skips it (`engine.ts` — `if (!d.summaryOperations) continue`) ``. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`) is unchanged, and so are every rule id, severity, `path`, and what each rule accepts or refuses. For `view/layout-without-binding`, the keys the old body told the author to declare are on that `fix:` line. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these nine ids**, for example `os explain view/layout-without-binding`. It prints the reasoning the verdicts no longer carry: each view type's measured renderer path, refusal screen and required keys, the exemptions (`user` relationships, `multiselect`, `gallery`), the hex trap behind the two `rowColor` rules, and why an empty `triggers` is not an off switch. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the nine ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the nine entries. + - **Where the new text prints.** On the CLI, `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face. `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. At the runtime publish gate, the rule runs on `object` writes only (Studio, REST `/meta`, MCP), so only the `field/*` ids reach it. A `summary` field with no `summaryOperations` or a `formula` field with no `expression` is an `error`: the 422 issue's `message` changes, and so does the server's refusal log line under the unlinted-writes hatch. A `checkboxes` field with no `options` is a `warning`: the `message` in the 2xx response's `advisories` changes, and so does the deduped `[Protocol] authoring advisory` server log line. Each issue's `hint` is unchanged. A `lookup`/`master_detail` with no `reference` and a `select`/`radio` with no `options` never reach this gate: the object schema's own parse refuses them first, with its own message, which this change does not touch. + - **Unchanged doors.** The registry's boot-time line (`[Registry] Object "…" registered with N functionally-incomplete field(s) …`) prints each finding's rule id and `fix`, never its `message`, so it reads as before. The webhook auto-enqueuer's `webhook/without-triggers` boot warning has its own text, also unchanged. The `view/*` and `webhook/*` ids do not run at the runtime publish gate. +- d8c7d38: fix(lint): the flow-credential, AI skill, managed-object API method, organization-axis business-unit grant, retired permission residue, seed and semantic-role provenance findings print one verdict line, `os explain ` carries their reasoning, and `field-no-consumers` stops listing every object that shares a field name + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 9 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 194 characters, and the runtime publish gate's suite at most 181; before, the longest of each ran from 222 to 441 characters. The ids: + - flows: `flow-credential-literal` (a literal credential in an `http` node's headers or url query, or in a `connectorConfig.input`); + - AI agents and skills: `ai-skill-surface-mismatch` and `ai-skill-tool-unresolved`; + - managed objects: `object/managed-api-method-unaffordable`; + - sharing rules: `org-axis-cross-org-bu-grant` (its sibling `org-axis-permission-inheritance` already printed one sentence of at most 174 characters and is unchanged); + - permission sets: `permission-retired-lifecycle-residue`; + - seed datasets: `seed-insert-mode-duplicates-on-replay` and `seed-value-outside-state-machine`; + - semantic roles: `semantic-role-field-unprovisioned`. + + A tenth id this change shortened, `flow-filter-token-unknown`, is retired in this same release with its rule: a flow filter operand is a value slot, and a token there is refused as `expression-invalid` by `validateStackExpressions` (its `ExprIssue`), with the CEL envelope that replaces it. + + A verdict no longer repeats what the finding's `where` already names: the skill (`ai-skill-tool-unresolved` now opens on the tool), the managed object, the sharing rule's object and the semantic-role object. `flow-credential-literal` keeps a short route (`move it to a connector's \`auth.credentialRef\``, or `drop it` for a connector input) because `os lint` prints the fix only under `--fix`; the route by auth variant stays the fix line's. `seed-value-outside-state-machine` names the first three declared states, then `(and N more)`. `object/managed-api-method-unaffordable` no longer appends the spec's `describeManagedApiMethodConflicts` sentence; it names the refused verbs, the `managedBy` bucket and the affordances they need. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`field-no-consumers`** names the first other object that declares a field of the same name and counts the rest (`a consumer of the same name on "showcase_account" (and 9 more) does not count`), where it listed every one. On `os validate` of the showcase app its longest finding drops from 341 characters to 199, and that 341-character one, which named ten objects, now prints 143. Its `inert` and `carrier-only` verdicts and its explanation are otherwise unchanged. + - **`os explain ` takes these 9 ids**, for example `os explain flow-credential-literal`. It prints the reasoning the verdicts no longer carry: which flow positions are served to every flow reader and where a credential belongs, by auth variant; how an agent's and a skill's surfaces must match, and why an unresolved skill name is skipped; how a skill's tool name resolves and when an action materialises as a tool; which affordance each write verb needs and what the registry does with a refused one; why business-unit trees stay inside one organization and which recipients may share a platform-global object; why the retired `allowRestore` / `allowPurge` default is accepted in silence and which channels miss it; why seeds must be safe to replay and which states a seeded value may name; and why an injected column on an external object is blank. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 9 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 9 entries. + - **Where the new text prints.** On the CLI, all 10 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs when it compiles at startup, and on each watch recompile under `--verbose`), `os lint` (as `where: message`) and the scaffold check `os init` runs print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. `os verify` prints the error-severity ids' new text in its refusal list (`ai-skill-surface-mismatch`, `object/managed-api-method-unaffordable`, `org-axis-cross-org-bu-grant`) and counts the warnings without printing them. At the runtime publish gate (Studio, REST `/meta`, MCP): an `object` write's `object/managed-api-method-unaffordable` changes the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`; a `flow` write's `flow-credential-literal` and a `permission` write's `permission-retired-lifecycle-residue` change the 2xx `advisories` entry and the `[Protocol] authoring advisory` log line. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** `org-axis-cross-org-bu-grant`, the two seed ids, `semantic-role-field-unprovisioned` and `field-no-consumers` (CLI-only rules), and the two AI ids, whose rules run at that door only for a `flow` write, whose snapshot carries no agents or skills of their own. +- 2e11d90: fix(lint): 15 author-time rule ids print a `fix:` line of one remedy sentence, and `os explain ` carries what those lines used to enumerate + + Clause-②: no + + - **Shorter fix lines.** Each finding of these 15 rule ids now carries a `hint` (the CLI's `fix:` line, the runtime issue's `hint`) of one remedy sentence of at most 200 characters (the lengths here are the `hint` alone; the CLI prints it after `fix: `). Before, the longest of each ran from 128 to 2,280 characters. The ids: + - sharing rules: `sharing-rule-runtime-variable-condition` (2,280 → 195) and `sharing-rule-unlowerable-condition` (968 → 199); + - approvals: `approval-approvers-may-resolve-empty` (its `manager` arm 2,243 → 198, its group arm 440 → 200); + - RLS predicates: `rls-predicate-unenforceable` (1,056 → 191), `rls-predicate-over-budget` (1,044 → 197) and `rls-predicate-unknown-field` (766 → 154); + - filters: `filter-empty-combinator` (764 → 154); + - permission sets: `permission-retired-lifecycle-residue` (681 → 14: `Delete the key`); + - view visibility: `visibility-predicate-over-budget` (651 → 179); + - hook bodies: `hook-api-update-readonly-field` (647 → 182); + - flows: `flow-time-relative-descriptor-invalid` (342 → 176); + - pages: `component-type-unknown` (229 → 158), `react-chart-aggregate-invalid` (363 → 191) and `react-chart-drilldown-invalid` (128 → 181: longer, because it now carries the remedy instead of a pointer); + - fields: `field-no-consumers` (on `os validate` of the showcase app, 624 → 199). + - **Four fix lines pointed at text the verdict no longer carries.** `component-type-unknown` said "Apply the prescription above", `react-chart-drilldown-invalid` and `react-chart-aggregate-invalid` said "the rejection above carries the fix", and `flow-time-relative-descriptor-invalid` said "satisfies each message above", after earlier releases cut those verdicts to one sentence. Each now names the remedy itself: the retired component type's "Delete the …" sentence cut from `RETIRED_PAGE_COMPONENT_TYPES` (for example `Delete the \`element:form\` component and use the object-bound \`object-form\` block instead`), the schema refusal's rename or the layer a misplaced key belongs to, and the descriptor key the verdict names. + - **Enumerations are cut to the first item and a count.** `field-no-consumers` names the first carrier site and counts the rest (`… together with its 16 carrier site(s): translations[0].en.objects.showcase_account.fields.website (and 15 more)`), where it listed every one. `rls-predicate-unenforceable` gives the first faulty site's rewrite and counts the rest; for a bare `current_user` it names the key slot (`current_user.`) instead of listing every kernel key. `rls-predicate-unknown-field` no longer lists every field of the object: the verdict's "Did you mean" already names the nearest one. `permission-retired-lifecycle-residue` carries the retirement's own "Delete the key" sentence, cut from the tombstone's prescription, not the whole prescription. + - **`os explain ` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`, which `os explain ` prints: the lowerable predicate subset and its traps, the comparison classes with the declared types each holds, the rule each `current_user` holding breaks, which mechanism replaces a `current_user` read on which sharing model (an RLS policy narrows only `public_read` / `public_read_write`; it grants nothing on `private`), the graded routes that populate `sys_user.manager_id`, the four ways to shrink an oversized RLS predicate (and why a top-level `&&` must never be split), and the declared "match nothing" filter spelling. The entries already existed; they gain paragraphs. + - **Where the new fix lines print.** On the CLI: the `fix:` line under each finding of `os validate`, `os build` (and `os compile`, which `os dev` runs at startup and, under `--verbose`, on each watch recompile), the scaffold check `os init` runs, and `os verify`'s refusal list; `os lint` prints it as `→ fix:` under `--fix` and as `fix` in `--json`; `os validate --json` (`errors`) and `os build --json` (author-time `issues`) carry it on an error-severity finding; their `warnings` carry an advisory as one `where: message` string, without it. At the runtime publish gate (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), the `hint` FROM → TO, measured at the door: + - a `flow` write: `flow-time-relative-descriptor-invalid` (422 `issues[].hint`, 342 → 176), `filter-empty-combinator` (422, 647 / 764 → 134 / 154) and `approval-approvers-may-resolve-empty` (2xx `advisories[].hint` and the `[Protocol] authoring advisory` log line, 2,243 / 440 → 198 / 200); + - a `permission` write: `rls-predicate-unenforceable` (422, 912 / 312 → 186 / 121), `rls-predicate-unknown-field` (422, 708 → 154), `rls-predicate-over-budget` (422, once the platform CEL bounds refuse) and `permission-retired-lifecycle-residue` (2xx advisory, 573 / 681 → 14); + - a `view` write: `visibility-predicate-over-budget` (422, 651 → 179). + + Studio's save-advisory toast prints an advisory's `hint` after its message, so the approval and residue toasts shorten; 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. + - **Never at the runtime gate:** the two sharing-rule ids, `field-no-consumers`, `component-type-unknown`, the two `react-chart-*` ids and `hook-api-update-readonly-field` run on the CLI only. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- 7dbb89d: fix(lint): 16 more author-time rule ids print a `fix:` line of one remedy sentence, and `os explain ` carries what those lines used to enumerate + + Clause-②: no + + - **Shorter fix lines.** Each finding of these 16 rule ids 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: + - validation rules: `validation-rule-json-schema-unknown-format` (646 → 183); + - datasets and cubes: `measure-aggregate-field-type-refused` (645 → 178) and `dimension-json-stored-field-refused` (262 → 177); + - form predicates: `predicate-rhs-path-shaped` (643 → 184), `predicate-path-unrooted` (214 → 122) and `predicate-path-unresolved` (201 → 153); + - RLS predicates: `rls-predicate-unknown-user-variable` (618 → 193) and `rls-predicate-unparseable` (523 → 171); + - hook bodies: `hook-api-update-readonly-when-field` (607 → 173); + - action names: `action-name-undefined` (560 → 195); + - AI skills: `ai-skill-tool-unresolved` (548 → 189); + - organization axis: `org-axis-permission-inheritance` (542 → 145) and `org-axis-cross-org-bu-grant` (315 → 141); + - object names: `object-reference-unknown` (522 → 134) and `object-reference-unregistered-platform` (500 → 187); + - filters: `filter-empty-node` (515 → 113). + - **Lists are no longer echoed.** `object-reference-unknown` no longer lists every object the stack and its artifact define, and `action-name-undefined` no longer lists every action of the stack or of the related object: the verdict's "Did you mean" already names the nearest one. `validation-rule-json-schema-unknown-format` names the nearest registered format and no longer lists all of them, and `ai-skill-tool-unresolved` no longer lists the tool family prefixes. `rls-predicate-unknown-user-variable` names the kernel keys and the nearest one, and so holds no author text. + - **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: why a missing object costs what it costs at each site (the record picker, the view container's index, the dashboard filter, the dataset's reports, the nav entry), the doors that refuse a refused aggregate pair later (a dataset's compile, a cube's query), how an app stages an RLS membership set, why no system context waives a `readonlyWhen` lock, and the declared "match nothing" filter spelling. Where the namespace-prefixed object exists, a view container's fix line is that spelling alone (`write "my_app_order_line", not "order_line"`). + - **`os explain ` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`, which `os explain ` prints. Two ids had no entry and gain one, so their `rule:` line now ends with the `os explain` pointer: `object-reference-unknown` ("how an object name resolves, and what a miss costs") and `org-axis-permission-inheritance` ("why access never walks the organization tree"). The other 14 entries gain paragraphs. The registered format names and the tool family prefixes are written out in their entries and held to the live sets by the rules' tests. + - **Where the new fix lines print.** On the CLI: the `fix:` line under each finding of `os validate`, `os build` (and `os compile`, which `os dev` runs at startup and, under `--verbose`, on each watch recompile), the scaffold check `os init` runs, and `os verify`'s refusal list; `os lint` prints it as `→ fix:` under `--fix` and as `fix` in `--json`; `os validate --json` (`errors`) and `os build --json` (author-time `issues`) carry it on an error-severity finding; their `warnings` carry an advisory as one `where: message` string, without it. At the runtime publish gate (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), the `hint` FROM → TO, measured at the door: + - an `object` write: `validation-rule-json-schema-unknown-format` (422 `issues[].hint`, 638 / 606 → 175 / 143, with and without a nearest name); + - a `dataset` write: `measure-aggregate-field-type-refused` (422, 606 / 581 → 147 / 122), `dimension-json-stored-field-refused` (422, multi-value 259 → 166, structured JSON 177 unchanged), `object-reference-unknown` (422 on the dataset's `object`, 331 → 134) and `object-reference-unregistered-platform` (2xx `advisories[].hint` and the `[Protocol] authoring advisory` log line, 449 → 187); + - a `view` write of a schema-bound metadata form: `predicate-rhs-path-shaped` (422 on a dotted right-hand side, 384 → 156; 2xx advisory on a bare word, 631 → 180), `predicate-path-unrooted` (422, 214 → 122) and `predicate-path-unresolved` (422, 100 → 36 with a nearest key); + - a `permission` write: `rls-predicate-unparseable` (422, 523 → 171) and `rls-predicate-unknown-user-variable` (422, 566 / 634 → 158 / 190, without and with a nearest key); + - a `flow` or `report` write: `filter-empty-node` (422, 515 / 278 / 170 → 113 / 107 / 69 for the whole filter, a `$or` branch and a `$and` branch). + + Studio's save-advisory toast prints an advisory's `hint` after its message, so the platform-object and bare-word toasts shorten; 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. + - **Never at the runtime gate:** `hook-api-update-readonly-when-field`, `action-name-undefined`, `ai-skill-tool-unresolved` and the two `org-axis-*` ids run on the CLI only, as do the object-reference ids on every site but a dataset's `object`. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- 6a7eca6: fix(lint): 18 more author-time 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 18 rule ids 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: + - flow credentials: `flow-credential-literal` (646 → 177); + - flow writes: `flow-update-readonly-when-field` (549 → 174) and `flow-update-readonly-field` (400 → 143); + - list-view bulk wiring: `action-dispatch-contract-mismatch` (514 → 157); + - security posture: `security-controlled-by-parent-ambiguous-relation` (496 → 174), `security-master-detail-ungranted` (314 → 153), `security-controlled-by-parent-no-relation` (294 → 194), `security-fls-unknown-field` (293 → 132) and `security-wildcard-vama` (218 → 103); + - sharing rules: `sharing-rule-object-not-shareable` (482 → 144) and `sharing-rule-object-controlled-by-parent` (395 → 169); + - interface pages: `page/visualization-without-binding` (480 → 183); + - action bodies: `action-api-update-readonly-when-field` (470 → 188); + - flow triggers: `flow-api-trigger-secret-missing` (461 → 171), `flow-time-relative-descriptor-unroutable` (441 → 166), `flow-trigger-unroutable` (295 → 163), `flow-trigger-unknown-event` (268 → 101) and `flow-trigger-unknown-object` (210 → 156). + - **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: why `runAs: 'system'` is the channel for a static readonly field and why a `beforeInsert` stamp survives the strip; why a hook-derived value lands on a `readonlyWhen`-locked record, the drop-the-field alternative and that the warning never blocks a build; how to pick between the two bulk contracts; the controlled-by-parent tier precedence and what each sharing baseline means; how to tell which half of a sharing rule on a public object is wrong; why `sourceView` is an interface page's one binding channel; the descriptor's optional keys and the schedule's default; the api trigger's signature format; the connector's `provider`, the Basic auth variant, where `credentialRef` resolves and that a `{variable}` template draws nothing. + - **A list becomes a nearest name.** `security-fls-unknown-field` no longer lists up to twelve of the object's fields; it names the nearest declared or registry-injected column when one is close (`'crm_account.description' is the nearest`), and none otherwise. `page/visualization-without-binding` names the field TYPE the derivation looks for and no longer repeats the name pattern it also tries. + - **`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 had no entry and gain one, so their `rule:` line now ends with the `os explain` pointer: `security-wildcard-vama` ("why the '*' entry cannot carry View All / Modify All") and `flow-trigger-unknown-object` ("why an unknown trigger object only warns"). The other 16 entries gain paragraphs. The auth variants and the descriptor keys the lines and entries spell out are held to the spec's `ConnectorInstanceAuthSchema` and `TimeRelativeTriggerSchema` by the rules' tests. + - **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 13 of the 18 fire; the `hint` FROM → TO, measured at the door: + - a `flow` write: `flow-credential-literal` (2xx `advisories[].hint`, header / query / connector input 646 / 528 / 488 → 173 / 177 / 141), `flow-update-readonly-field` (422 `issues[].hint`, update / create 239 / 396 → 116 / 139), `flow-update-readonly-when-field` (2xx advisory, 527 → 161, under `runAs: 'user'` and `'system'` alike), `flow-trigger-unknown-object` (2xx advisory, record / time-relative 210 / 179 → 156 / 125), `flow-trigger-unknown-event` (422, token / array 191 / 268 → 101 / 96), `flow-time-relative-descriptor-unroutable` (422, 441 → 166), `flow-trigger-unroutable` (422, 295 → 163) and `flow-api-trigger-secret-missing` (422, 461 → 171); + - an `object` write: `security-controlled-by-parent-no-relation` (422, 294 → 194), `security-controlled-by-parent-ambiguous-relation` (422, 496 → 167) and `security-master-detail-ungranted` (2xx advisory, 304 → 147); + - a `permission` write: `security-fls-unknown-field` (422, 324 → 132), `security-wildcard-vama` (422, 218 → 103) and `security-master-detail-ungranted` (2xx advisory, 304 → 147). + + Studio's save-advisory toast prints an advisory's `hint` after its message, so the credential, readonlyWhen, unknown-object and ungranted-detail toasts shorten, and Studio's draft-changes panel shows the `hint` in place of the message for the two controlled-by-parent errors its in-browser security check finds on object drafts. 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. + - **Never at the runtime gate:** `action-dispatch-contract-mismatch`, `page/visualization-without-binding`, `action-api-update-readonly-when-field` and the two `sharing-rule-object-*` ids run on the CLI only. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- 63d5db2: fix(lint): 16 more author-time 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 16 rule ids 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: + - list-view search: `searchable-field-unknown` (448 → 129), `searchable-field-unprovisioned` (362 → 175) and `searchable-field-unsearchable` (328 → 139); + - list-view sort: `sort-field-unprovisioned` (378 → 191), `sort-field-unsortable` (332 → 154) and `sort-field-unknown` (301 → 142); + - visibility predicates: `visibility-predicate-unknown-function` (375 → 165), `visibility-bare-identifier` (221 → 102) and `visibility-predicate-syntax` (214 → 113); + - actions: `action-no-placement` (424 → 171); + - import mappings: `mapping-target-field-unknown` (405 → 189); + - dashboards: `unconsumed-widget-option` (396 → 135); + - app navigation: `nav-object-ungranted` (370 → 118); + - hook bodies: `hook-body-write-unprovisioned-anchor` (365 → 178), `hook-body-write-unknown-field` (275 → 110) and `hook-body-source-unparseable` (254 → 105). + + A seventeenth id this change shortened, `flow-filter-token-unknown`, is retired in this same release with its rule: a flow filter operand is a value slot, and a token there is refused as `expression-invalid` by `validateStackExpressions` (its `ExprIssue`), with the CEL envelope that replaces it. + - **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: why a stale or inadmissible `searchableFields` entry is a `400 INVALID_FIELD` on list search, that a view narrows the object's searchable set and never widens it, and that a lookup column stores only an id; that both runtime doors refuse a formula sort with `400 INVALID_SORT`; how to vouch for an unprovisioned anchor (`columnMap`) or opt the object out of the injection (`ownership: 'none'`, `systemFields: { audit: false }`); the other-language operator spellings CEL does not parse, why re-spelling cannot fix an unregistered function, and which namespaces each surface binds; why gating a nav entry does not clear a missing grant; which hook-body write shapes are checked; what a partially parsed body leaves unchecked. + - **A list the verdict already covers is not echoed.** `searchable-field-unknown`, `sort-field-unknown` and `mapping-target-field-unknown` no longer list every field of the object: the verdict's "Did you mean" names the near miss. `mapping-target-field-unknown` still lists the object's compound fields and their parts, as every import-mapping refusal does. + - **`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 had no entry and gain one, so their `rule:` line now ends with the `os explain` pointer: `action-no-placement` ("where an action can be placed") and `unconsumed-widget-option` ("which widget options a renderer reads"). Five entries gain text. The action locations the new entry spells out are held to the spec's `ACTION_LOCATIONS` by the rule's test. + - **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 9 of the 16 fire; the `hint` FROM → TO, measured at the door: + - a `view` write: `sort-field-unknown` (422 `issues[].hint`, a name / a dotted path 114 / 261 → 47 / 135), `sort-field-unsortable` (422, 318 → 140), `sort-field-unprovisioned` (2xx `advisories[].hint`, 363 → 176), `searchable-field-unknown` (422, a name / a dotted path 281 / 430 → 48 / 129), `searchable-field-unsearchable` (422, a formula / outside the declared set / a lookup / a json column 222 / 142 / 323 / 157 → 110 / 68 / 135 / 64), `searchable-field-unprovisioned` (2xx advisory, 361 → 174), `visibility-predicate-syntax` (422, a recognised spelling / none 106 / 214 → 106 / 107), `visibility-predicate-unknown-function` (422, 371 → 165) and `visibility-bare-identifier` (422, a bare name / an unbound namespace 160 / 215 → 41 / 96); + - an `object` write: `searchable-field-unknown` (422, the object's own set / a built-in list view 266 / 261 → 55 / 50), `searchable-field-unsearchable` (422, 222 → 110) and `searchable-field-unprovisioned` (2xx advisory, 361 → 174). + + Studio's save-advisory toast prints an advisory's `hint` after its message, so the two `*-unprovisioned` toasts shorten; 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. + - **Never at the runtime gate:** `action-no-placement`, `mapping-target-field-unknown`, `unconsumed-widget-option`, `nav-object-ungranted` and the three `hook-body-*` ids run on the CLI only. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- a9bcbc6: fix(lint): 17 more author-time 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 17 rule ids 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: + - react pages: `react-chart-field-unprovisioned` (362 → 175), `react-prop-missing-required` (277 → 131), `react-page-source-unparseable` (254 → 105) and `react-block-needs-record-context` (228 → 195); + - action bodies: `action-body-write-unprovisioned-anchor` (358 → 171), `action-body-write-unknown-field` (323 → 91), `action-record-write-discarded` (309 → 104) and `action-body-source-unparseable` (254 → 105); + - agents: `agent-authoring-withdrawn` (363 → 158) and `default-agent-outside-roster` (237 → 118); + - flow nodes: `flow-node-write-unprovisioned-anchor` (358 → 171) and `flow-node-write-unknown-field` (289 → 110); + - object validation rules: `validation-rule-json-schema-uncompilable` (341 → 124) and `validation-rule-regex-uncompilable` (300 → 135); + - translations: `translation-section-name-missing` (359 → 172); + - managed objects: `object/managed-api-method-unaffordable` (357 → 169); + - app navigation: `nav-object-unservable` (348 → 156). + - **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: how to vouch for an unprovisioned anchor (`columnMap`) or opt the object out of the injection (`ownership: 'none'`, `systemFields: { audit: false }`), now on the react-chart, action-body and flow-node anchor ids too; what a partially parsed source leaves unchecked; which action-body write shapes are checked and that the warning never blocks a build; why the flow-node unknown-field finding gates where the body rules advise; that skills attach to the platform agents by `surface` affinity, so dropping a custom agent loses nothing; the escaped regex spellings and the named `format`s; the shapes the common JSON Schema keywords take; that a suggested section name is never a key derived from the label; and that a record block's react-tier replacement binds the record itself. + - **Shared one-line forms.** The anchor and parse ids of the react-page, action-body and flow-node rules print the same one-line forms the hook-body ids print (`If the remote table carries '…', declare it in …'s own fields (mapped through columnMap); otherwise drop the reference or opt the object out of the injection`, and `Fix the syntax error and re-run: until the source parses, the checks cover only what the parser recovered`). + - **Quoted from the contract, cut to one sentence.** A missing required react-tier binding still prints the contract's own description of it, now its first sentence, which names the binding and its spelling (`Pass data={…}: The object this block binds to, written as the metadata-tier data source: data={{ provider: 'object', object: '…' }}`); the rest of the description is in the published react-blocks reference. A record block's line is the spec's named replacement behind the lead-in `Instead:`. + - **`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; eleven entries gain text. The named formats the regex entry spells out are held to the spec's `FormatValidationSchema` by the rule's test. `react-prop-missing-required` has no entry, as before, so its `rule:` line carries no pointer. + - **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 5 of the 17 fire; the `hint` FROM → TO, measured at the door: + - an `object` write: `object/managed-api-method-unaffordable` (422 `issues[].hint`, two refused verbs / every write verb 335 / 372 → 132 / 169), `validation-rule-regex-uncompilable` (422, the pattern `([` / a 28-character pattern 288 / 314 → 123 / 149) and `validation-rule-json-schema-uncompilable` (422, 341 → 124); + - a `flow` write: `flow-node-write-unknown-field` (422, with / without a near miss 271 / 250 → 92 / 71) and `flow-node-write-unprovisioned-anchor` (2xx `advisories[].hint`, 361 → 174). + + Studio's save-advisory toast prints an advisory's `hint` after its message, so the flow-node anchor toast shortens; 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. + - **Never at the runtime gate:** the four react-page ids, the four action-body ids, the two agent ids, `translation-section-name-missing` and `nav-object-unservable` run on the CLI only. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- 9fbabd7: fix(lint): 17 more author-time 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 17 rule ids 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: + - approval nodes: `approval-approver-not-membership-tier` (313 → 168), `approval-expression-no-empty-policy` (294 → 121) and `approval-expression-invalid` (292 → 144); + - datasets: `dataset-include-unknown` (336 → 97) and `dataset-field-unknown` (206 → 139); + - record titles: `title-format-retired` (267 → 148) and `title-unresolvable` (237 → 122); + - list views: `list-view-field-unknown` (258 → 62) and `list-view-field-dotted` (233 → 158); + - translations: `translation-target-unknown` (317 → 164); + - seeds: `seed-insert-mode-duplicates-on-replay` (309 → 131) and `seed-value-outside-state-machine` (251 → 139); + - charts: `chart-measure-unknown` (285 → 115); + - object field-name lists: `object-field-ref-unknown` (284 → 186); + - stack views: `view-container-shape` (256 → 187); + - agents: `ai-skill-surface-mismatch` (235 → 171); + - source-tier pages: `page-source-className-tailwind` (231 → 167). + - **Lists are no longer echoed.** `dataset-include-unknown`, `dataset-field-unknown`, `list-view-field-unknown` and `object-field-ref-unknown` no longer append the object's whole field list, `chart-measure-unknown` no longer lists the dataset's measures, and `translation-target-unknown` no longer lists the declared fields, views, sections, tabs, rules, actions, apps, navigation ids, dashboards, widgets, header actions, flows, screens, screen fields, refusing end nodes, params, outcomes or result paths: the verdict's "Did you mean" (on `chart-measure-unknown`, the fix line's own) names the nearest one. Two facts a list used to carry stay on the line as one bounded clause: a stale `_views` key is told that a nameless default `list` is keyed `default`, and a `_tabs` key over an object with no preset bar is told to declare the tab in a page's `interfaceConfig.userFilters.tabs`, because a list view's own `tabs` has no renderer. `dataset-filter-field-unknown`, outside this change, keeps its field list. + - **One line, one remedy.** Where a fix line explained its remedy, the explanation moved: what an expression approver's three roots hold, why `record` and `previous` are unbound at an approval node, and what `resolveAs` does; what each `onEmptyApprovers` policy does; that a business role is always a position; that a dataset join is derived from the object graph, up to three hops, with `a.b` including `a`; why `titleFormat` is retired and how a record title resolves, with its fallback; what a denormalised field is; which presentation positions a chart measure lands on, and that none carries a `suppressWarnings` key; the composite `externalId` of a junction table; that the seed exemption never licenses an undeclared state; when a skill may declare `surface: 'both'`; and why a Tailwind class in page source produces no CSS, with the theme-token and overlay advice. + - **`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. Five ids gain an entry, so their `rule:` line now carries the `os explain` pointer: `approval-expression-invalid`, `approval-expression-no-empty-policy`, `title-format-retired`, `title-unresolvable` and `page-source-className-tailwind`. Ten existing entries gain text. The enumerations an entry spells out are held to their sources by the rules' tests: the empty-slate policies to the spec's `onEmptyApprovers` enum, the expression roots to `APPROVER_EXPRESSION_ROOTS`, and the title-eligible types to `TITLE_ELIGIBLE_TYPES`. + - **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), 9 of the 17 fire; the `hint` FROM → TO, measured at the door: + - a `flow` write with an approval node: `approval-approver-not-membership-tier` (2xx `advisories[].hint`, 313 → 138), `approval-expression-invalid` (422 `issues[].hint`: an empty expression 216 → 90, a `record` root 292 → 144; a parse failure 129, a bare root 110 and the `resolveAs` advisory 102 unchanged) and `approval-expression-no-empty-policy` (2xx advisory, 294 → 121); + - a `dataset` write: `dataset-include-unknown` (422, an entry naming nothing / an ordinary field 239 / 187 → 93 / 93) and `dataset-field-unknown` (422, a dimension / a measure 184 / 182 → 132 / 130); + - an `object` write: `object-field-ref-unknown` (422, 180 → 128; the same text is the `fix` of the publish receipt's `object_field_ref_unknown` runtime probe issue), and on its `listViews`, `list-view-field-unknown` (422, 110 → 57) and `list-view-field-dotted` (422, 231 → 157); + - a `view` write: `list-view-field-unknown` (422, 110 → 57) and `list-view-field-dotted` (422, a projection / a filter 231 / 143 → 157 / 95); + - a `report` write: `chart-measure-unknown` (422 at the query position, 117 → 106; 2xx advisory at a presentation position, 301 → 113). + + Studio's save-advisory toast prints an advisory's `hint` after its message, so the three approval advisories and the chart presentation advisory 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. + - **Never at the runtime gate:** `title-format-retired`, `title-unresolvable`, `translation-target-unknown`, both seed ids, `view-container-shape`, `ai-skill-surface-mismatch` and `page-source-className-tailwind` run on the CLI only. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- 33ae4bf: fix(lint): 26 more author-time findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 26 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 200 characters; before, the longest of each ran from 205 to 697 characters. The ids: + - dashboard widgets: `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`, `dashboard-filter-field-not-included`, `dashboard-filter-field-unprovisioned`, `widget-filter-field-unknown`, `widget-filter-field-not-included`, `widget-sortby-unselected`, `chart-field-unknown`, `chart-measures-missing`, `widget-measures-missing`, `chart-dimensions-missing`; + - datasets: `dataset-include-unknown`, `dataset-field-unknown`, `dataset-field-not-included`, `dataset-filter-field-unknown`; + - security posture: `security-controlled-by-parent-ambiguous-relation`, `security-fls-unknown-field`, `security-controlled-by-parent-no-relation`, `security-master-detail-ungranted`, `security-owd-alias`, `security-delegation-missing-reason`; + - visibility predicates: `visibility-root-mislayered`, `visibility-predicate-over-budget`, `visibility-predicate-syntax`, `visibility-predicate-unknown-function`, `visibility-bare-identifier`. + + The values the author wrote still close each verdict — the field path, the candidate roster, the selection list, the predicate excerpt (now at most 72 characters; `visibility-predicate-over-budget` no longer echoes the predicate at all, its `path` locates it) — so a verdict over a long authored value grows with it. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these 26 ids**, for example `os explain chart-field-unknown`. It prints the reasoning the verdicts no longer carry: how a dashboard filter reaches every widget, why the renderer ignores `chartConfig` binding keys, what an unresolved dataset path does to the analytics query, how the master relation of a `controlled_by_parent` object is chosen, how the runtime resolves a field-permission key, why a visibility predicate that cannot evaluate renders its element anyway. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 26 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 26 entries. + - **Where the new text prints.** On the CLI, `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. `os doctor`'s dashboard widget check prints the 11 widget ids as `where: message`, with no `rule:` line. At the runtime publish gate (Studio, REST `/meta`, MCP), by write type: a `dashboard` write carries the widget ids, a `dataset` write the dataset ids, a `view` write the visibility ids, an `object` write the two `controlled_by_parent` ids and `security-master-detail-ungranted`, a `permission` write `security-fls-unknown-field` and `security-master-detail-ungranted`, a `seed` write `security-delegation-missing-reason`. An `error` changes the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`; a `warning` (`dashboard-filter-field-unprovisioned`, `chart-field-unknown`, `chart-measures-missing`, `widget-measures-missing`, `chart-dimensions-missing`, `security-master-detail-ungranted`, `visibility-root-mislayered`) changes the `message` in the 2xx response's `advisories` and the deduped `[Protocol] authoring advisory` server log line. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** `security-owd-alias`. The object schema's closed `sharingModel` / `externalSharingModel` enums refuse those values at parse, with their own message, before the gate runs; the rule speaks only on the unparsed doors (`os lint` on a raw config, a direct call). +- ebbb8f9: fix(lint): 14 more author-time findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 14 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire, and the CLI suite's handler-hook variants, is at most 200 characters; before, the longest of each ran from 212 to 792 characters. The ids: + - hook bodies: `hook-body-write-unknown-field`, `hook-body-write-unprovisioned-anchor`, `hook-body-source-unparseable`; + - action bodies: `action-body-write-unknown-field`, `action-body-write-unprovisioned-anchor`, `action-record-write-discarded`, `action-body-source-unparseable`; + - flow nodes: `flow-node-write-unknown-field`, `flow-node-write-unprovisioned-anchor`; + - readonly writes: `flow-update-readonly-field`, `flow-update-readonly-when-field`, `hook-api-update-readonly-field`, `hook-api-update-readonly-when-field`, `action-api-update-readonly-when-field`. + + The three write surfaces now share one wording per question: an undeclared field ends on "so the write is refused (INVALID_FIELD / 400)", an unprovisioned anchor reads "'FIELD' is an injected column with no storage on external object 'OBJECT', so … can never land", an unparseable hook or action body reads "L2 body did not parse (…), so writes in its unread part go unchecked", and a readonly write says it is "silently stripped" (a `readonlyWhen` field "where its predicate is TRUE"). The values the author wrote still close each verdict — the field, the object, the `ctx.api` call, the run identity — so a verdict over a long name grows with it, and a hook lowered from an inline `handler` keeps its " (judged on the metadata body lowered from the inline handler)" suffix. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these 14 ids**, for example `os explain hook-body-write-unknown-field`. It prints the reasoning the verdicts no longer carry: how each write channel reaches the engine's declared-field door and what the refusal takes down with it, why a write to an unprovisioned anchor on an external object passes the validator and never lands, what a partially parsed body leaves unchecked, why an action's `ctx.record` is never written back, and which readonly strip a system context waives and which it does not. The paragraphs the three surfaces share are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 14 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 14 entries. + - **Where the new text prints.** On the CLI, `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP), a `flow` write carries the four flow ids: `flow-node-write-unknown-field` and `flow-update-readonly-field` are errors, so the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change; `flow-node-write-unprovisioned-anchor` and `flow-update-readonly-when-field` are warnings, so the `message` in the 2xx response's `advisories` and the deduped `[Protocol] authoring advisory` server log line change. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** the ten hook and action ids. A `hook` or `action` write does not dispatch these rules (they parse authored JavaScript, which the publish path never loads), and a `flow` write's snapshot carries no hook or action body for them to read; they speak only on the CLI doors above. +- 07ee6f6: fix(lint): the RLS-predicate and sharing-rule findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 9 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites, the CLI's RLS admission suite and the runtime publish gate's suite fire is at most 195 characters; before, the longest of each ran from 492 to 2,056 characters. The ids: + - RLS policies (`permissions[].rowLevelSecurity[].using` / `.check`): `rls-predicate-unparseable`, `rls-predicate-unenforceable`, `rls-predicate-unknown-user-variable`, `rls-predicate-unknown-field`, `rls-predicate-over-budget`; + - sharing rules (`sharingRules[]`): `sharing-rule-unlowerable-condition`, `sharing-rule-object-not-shareable`, `sharing-rule-object-controlled-by-parent`, `sharing-rule-runtime-variable-condition`. + + A finding no longer echoes the predicate or the condition: its `path` names the clause, and the predicate is author text of any length (the old message quoted up to 200 characters of it, or all of it). Each verdict names what is wrong and closes on what it costs: an RLS policy the runtime drops ends "so the policy is dropped and grants no access" (a `using`) or "… and admits no write" (a `check`); a field compared with a field that holds a list or an object, or across comparison classes, quotes the first such comparison, names the column or the two classes, counts the rest, and ends on the `INVALID_FILTER / 400` refusal; a sharing condition that does not lower ends "so the rule is never seeded and grants nothing"; an anchor object a grant cannot widen names its `sharingModel` and the `SHARING_NOT_ENABLED` refusal. A quoted refusal — the compiler's, the platform's shared filter check's, or the engine's (`RLS using (CODE / STATUS): …`) — is quoted up to its own suggested rewrite, or to its first sentence. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these 9 ids**, for example `os explain rls-predicate-unenforceable`. It prints the reasoning the verdicts no longer carry: what `RLSCompiler` does with a policy it cannot compile (the deny sentinel and its ZERO rows, the OR with other policies, the INSERT check a `using` doubles as, the `check` set), the one WARN line each drop leaves, which `current_user` keys the kernel resolves and with which type, why a column that holds a list or an object — or two columns of different comparison classes — cannot be compared, what the engine's own filter admission refuses and what that costs analytics, and, for sharing rules, why a condition is compiled once at boot, what an unlowerable or refused condition leaves behind, and why a grant on a public or `controlled_by_parent` object is refused. The paragraphs the RLS and sharing rules share are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 9 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 9 entries. + - **Where the new text prints.** On the CLI, all 9 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP), a `permission` write carries the five `rls-predicate-*` ids, all errors: the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** the four `sharing-rule-*` ids. The rule is registered for the CLI doors only (no write type dispatches it at the publish gate), so they speak only on the CLI doors above. +- 61bea24: fix(lint): the flow-trigger, react-page, dataset-measure and validation-rule findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 15 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites and the runtime publish gate's suites fire is at most 194 characters; before, the longest of each ran from 211 to 1,049 characters. The ids: + - flow triggers (`flows[].nodes[]` start node): `flow-time-relative-descriptor-invalid`, `flow-time-relative-descriptor-unroutable`, `flow-trigger-unroutable`, `flow-api-trigger-secret-missing`, `flow-trigger-unknown-event`; + - react pages (`pages[].source`): `react-chart-drilldown-invalid`, `react-chart-aggregate-invalid`, `react-chart-field-unprovisioned`, `react-block-needs-record-context`, `react-page-source-unparseable`; + - dataset and cube members: `measure-aggregate-field-type-refused`, `dimension-json-stored-field-refused`; + - object validation rules: `validation-rule-json-schema-uncompilable`, `validation-rule-regex-uncompilable`, `validation-rule-json-schema-unknown-format`. + + A verdict no longer repeats what the finding's `where` and `path` already name: the validation rule and its object, the JSON Pointer of a `format`, the measure or dimension name, the source of an uncompilable `regex` (the `fix:` line still quotes it). A refusal the verdict quotes is quoted to its verdict. `TimeRelativeTriggerSchema`'s refusal of a descriptor is quoted for ONE issue (an unrecognized key first) and the rest are counted, `(and N more)`; ajv's metaschema refusal is quoted to its first violation, with the rest counted; a `@objectstack/spec` unknown-key refusal is quoted to the key and the schema's rename (`Did you mean …?`), without the wrong-layer prescription and the history sentence that follow it; and when no form of an `` `groupBy` union matches, the verdict keeps the forms whose complaint is about the value's content. A measure or dimension over a column its join chain reaches names a `joined object` (it read "(reached through this dataset's join chain)"). The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these 15 ids**, for example `os explain flow-trigger-unroutable`. It prints the reasoning the verdicts no longer carry: the engine's trigger routing chain, the record-trigger token grammar and the time-relative routing predicate, what nothing reports at run time, and where the publish gate refuses the flow; the inbound api secret (ADR-0041) and why the engine refuses to register a flow without one; the write path's fail-open skip of a rule whose `regex` or `schema` does not compile, the runtime's ajv environment, and what ajv does with an unregistered `format` name; each aggregate's accepted field types (the `AGGREGATE_FIELD_TYPE_COMPATIBILITY` rows, written out), what a JSON-stored column is, and why analytics refuses to group by one; how a forwarded `` or `aggregate` refusal is quoted and which keys the schema answers with a prescription instead of a rename (`schedule` and `runAs` on a time-relative descriptor, `drilldown`, `mode`, `report`, `view` and `sort` on a drill block, five aggregate and two `groupBy` keys); what an unprovisioned anchor is; why a `record:*` block renders empty on a react page; and why an unparseable react source is a warning. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 15 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 15 entries. + - **Where the new text prints.** On the CLI, all 15 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify`, and the scaffold check `os init` runs print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP): a `flow` write carries the five flow ids (all errors, refusing an active flow's publish), a `dataset` write the two dataset ids, and an `object` write the three validation-rule ids — the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** the five `react-*` ids. Their rule runs at that door only for a flow write, whose snapshot carries no pages, so they speak only on the CLI doors above. +- 762db99: fix(lint): the list-view sort and search, form-predicate path, bulk-dispatch, component-type and preset-comparand findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 12 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 193 characters, and the runtime publish gate's suites at most 137; before, the longest of each ran from 287 to 927 characters. The ids: + - list-view `sort` (`objects[].listViews`, `views[]` lists, list overlays and ViewItem records): `sort-field-unknown`, `sort-field-unsortable`, `sort-field-unprovisioned`; + - `searchableFields` (the object's own set, list views, and a react page's ``): `searchable-field-unknown`, `searchable-field-unsearchable`, `searchable-field-unprovisioned`; + - metadata-form `visibleWhen` predicates (`views[]` forms bound to a schema): `predicate-path-unresolved`, `predicate-path-unrooted`, `predicate-rhs-path-shaped`; + - list-view bulk wiring: `action-dispatch-contract-mismatch`; + - page component types (`pages[]`): `component-type-unknown`; + - filter comparands: `filter-preset-comparand`. + + A verdict no longer repeats what the finding's `where` already names: the view that wires an action (`action-dispatch-contract-mismatch`). The unprovisioned-anchor ids print the same one-clause cause the other converted anchor rules print (`'owner_id' is an injected column with no storage on external object 'x'`), and the two virtual-entry ids (`sort-field-unsortable`, `searchable-field-unsearchable`) state the storage fact in one shared wording. `searchable-field-unsearchable` quotes at most three names of an object's declared set, then `(and N more)`. A retired component type (`user:profile`, `element:filter`, `element:form`, `ai:chat_window`) is quoted to the head of its prescription in `RETIRED_PAGE_COMPONENT_TYPES` (`` `element:filter` was removed in @objectstack/spec 17 (ADR-0049) ``), and the verdict says the parse refuses the node by name; the whole prescription is the parse door's refusal of the same name, which `os validate` and `os build` print. `filter-preset-comparand` opens with the first sentence of the refusal the schema door shares (`"last_30_days" is a dashboard date-range PRESET name, not a filter value`), then names the operator and the preset's `{date-macro}` window. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these 12 ids**, for example `os explain sort-field-unknown`. It prints the reasoning the verdicts no longer carry: why an unknown sort field breaks a view's first fetch and every load after it, and which list-view surfaces the sort and search rules walk and skip; what a `formula` field's lack of storage does to an ORDER BY and to a search; what an unprovisioned anchor is and what sorting or searching one measured; how a stale `searchableFields` entry narrows a search or falls through to the auto-default set, and how a list view's narrowing reaches the runtime as the `$searchFields` override; how a metadata-form predicate path is resolved against the edited schema, why a dead predicate fails open, and why the right side of `==` / `!=` is a literal; how the two bulk wirings call an action and why nothing refuses a mismatch at run time; which component types the vocabulary closes and where a retired type's prescription lives; and where a date-range preset name is understood and what each layer does with a bare one. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 12 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 12 entries. + - **Where the new text prints.** On the CLI, all 12 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify`, and the scaffold check `os init` runs print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP), the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES` change for: the three `searchable-field-*` ids on a `view`, `object` or `flow` write; the three `sort-field-*` ids on a `view` or `flow` write; the three `predicate-*` ids on a `view` write; and `filter-preset-comparand` on a `dashboard`, `view`, `object`, `page`, `flow` or `report` write. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** `component-type-unknown`, which runs on the CLI doors only, and `action-dispatch-contract-mismatch`, whose rule runs at that door only for a `flow` write, whose snapshot carries no actions to judge, so both speak only on the CLI doors above. +- 996aa86: fix(lint): the dashboard-action, empty-filter, list-view-field and translation findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 9 rule ids now prints a `message` of one verdict sentence (followed by `Did you mean "…"?` where the rule offers the nearest declared name). Every finding the rules' own test suites fire is at most 199 characters, where the longest of each id ran from 226 to 553 before. The ids: + - dashboard header actions (`dashboards[].header.actions[]`): `dashboard-action-target-undefined` (the `script` and `modal` arms), `dashboard-action-route-unresolved` (one finding per unregistered `COLLECTION/NAME` segment of a `url` target); + - authored filters: `filter-empty-combinator` (`$and: []`, `$or: []`, `$not: {}`), `filter-empty-node` (`{}` as the whole filter, as a `$or` branch, as a `$and` branch); + - list-view field references: `list-view-field-unknown`, `list-view-field-dotted` (the projection door and the filter door's three refused head classes); + - translation bundles (`translations[]`): `translation-target-unknown` (every leg: objects and their fields, views, sections, tabs, validations and actions; global actions; apps and navigation; dashboards, widgets and header actions; flows, toasts, screens, screen descriptions, screen fields and refusals; action params, outcomes and result dialogs), `translation-option-key-unknown` (field, action-param and screen-field options), `translation-section-name-missing`. + + The empty-filter verdicts still take their row-set words ("matches EVERY row" / "matches NO row") from the shared filter reduction. `list-view-field-unknown` keeps the shared field-path sentence and, for a dotted reference, which written name it judged. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these 9 ids**, for example `os explain translation-target-unknown`. It prints the reasoning the verdicts no longer carry: what a `script` or `modal` header-action target must name and how a `url` route is resolved and skipped; the empty-filter identity table, what each shape does on a read scope or beside other branches, and which filters are judged; what a dangling list-view field costs at each position, why only the head of a dotted name is judged, and which positions reach which query door; how the translation resolver reads a key, why an orphan key is an error while a mis-keyed option is a warning, what every bundle leg may name, and why a section with no `name` can never be translated. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 9 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 9 entries. + - **Where the new text prints.** On the CLI: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and the scaffold check `os init` runs print the new `message` on the text face for all 9 ids, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP): on a `flow` or `report` write, `filter-empty-combinator` and `filter-empty-node` change the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`; on the write types the reference-integrity suite dispatches the list-view rule on (`view`, `object` and `flow`), `list-view-field-dotted` and the error-tier positions of `list-view-field-unknown` change the 422 issue's `message` and the refusal log line, and its warning-tier positions ride the 2xx `advisories` and the `[Protocol] authoring advisory` log line. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** the two dashboard-action ids (a CLI-only rule: a single published item cannot see the actions and pages it resolves against) and the three translation ids (the rules run there only on a `flow` write, whose snapshot carries no translation bundles). +- 23419ba: fix(lint): the navigation, object-reference, capability, action-name, import-mapping, view-container and interface-page findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** Each finding of these 10 rule ids now prints a `message` of one verdict sentence. Every finding the rules' own test suites fire is at most 199 characters, and the runtime publish gate's suite at most 95; before, the longest of each ran from 227 to 629 characters. The ids: + - app navigation (`apps[].navigation`, `areas[]`): `nav-object-ungranted`, `nav-object-unservable`, `nav-target-unresolved`; + - object field-name lists (`highlightFields`, `publicSharing.redactFields`, `indexes[].fields` and the field-level `relatedListColumns`, `lookupColumns`, `lookupFilters`, `dependsOn`): `object-field-ref-unknown`; + - object-name references: `object-reference-unregistered-platform` (its sibling `object-reference-unknown` already printed one sentence of at most 175 characters and is unchanged); + - `requiredPermissions`: `capability-reference-unknown`; + - name-bound actions (list views, page components, navigation): `action-name-undefined`; + - import mappings (`mappings[].fieldMapping[].target`): `mapping-target-field-unknown`; + - the stack's `views:` collection: `view-container-shape`; + - interface list pages (`appearance.allowedVisualizations`): `page/visualization-without-binding`. + + A verdict no longer repeats what the finding's `where` already names: the import mapping and its object, and the interface page. `object-field-ref-unknown` prints the shared field-path sentence the list-view rules print (`highlightFields[1] "field_10" is not a field on object "proj_task".`); what a miss costs at each position is its explanation's. `action-name-undefined` names at most three places a related-list action is defined, then `(and N more)`. `nav-object-unservable` names the object's `enable` key and the status the list answers (404 or 405). The flat-list-view arm of `view-container-shape` no longer says `ViewSchema` strips the keys to an empty container — the schema is strict and refuses that shape, and so does the registration loop; it now says so. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`), every rule id, severity and `path`, and what each rule accepts or refuses are unchanged. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these 10 ids**, for example `os explain nav-object-unservable`. It prints the reasoning the verdicts no longer carry: which grants count toward a navigation entry's read access, why an object's `enable` block makes an entry dead for every user and what the served `/meta` does with it, and why a grant and a list are two independent findings; which `defineStack` check `nav-target-unresolved` stands in for; which object each field-name list is judged against and what a miss costs at each position; the object-name resolution ladder and the platform object registry; where a capability may come from; which surfaces bind an action by name and how a related list resolves and places one; what an import mapping target may name, compound-field parts included; the container-only contract of `views:` and the doors that hold it; and the renderer's binding derivation for each interface-page visualization. Paragraphs shared across ids are one text, printed under every id they explain. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the 10 ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the 10 entries. + - **Where the new text prints.** On the CLI, all 10 ids: `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify`, and the scaffold check `os init` runs print the new `message` on the text face, and `os validate --json` and `os build --json` carry it in their `errors` and author-time `issues`. At the runtime publish gate (Studio, REST `/meta`, MCP): an `object` write's `object-field-ref-unknown` changes the 422 issue's `message` and the refusal log line under `OS_ALLOW_UNLINTED_METADATA_WRITES`, and a `dataset` write's `object-reference-unregistered-platform` (its base `object`) changes the 2xx `advisories` entry and the `[Protocol] authoring advisory` log line. Each issue's `hint` is unchanged. + - **Never at the runtime gate:** `capability-reference-unknown` and `view-container-shape` (CLI-only rules), and the three navigation ids, `action-name-undefined`, `mapping-target-field-unknown` and `page/visualization-without-binding`, whose rules run at that door only for a `flow` write, whose snapshot carries no apps, pages, views or mappings of its own. +- 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. +- e375315: fix(lint): the six `data-model-rules.ts` 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 six rule ids now carries a `fix` of one remedy sentence of at most 200 characters on every variant measured, including long object and column names. The three ADR-0120 uniqueness ids carry it as the `hint` that `os validate` and `os build` print after `fix: `; all six print it as `os lint --fix`'s `→ fix:` line. The longest of each, FROM → TO (characters of the fix text alone): + - `unique/legacy-organization-composite` 592 → 161; + - `unique/unscoped-declared-index` 380 → 150; + - `object/missing-name-field` 327 → 188; + - `unique/double-declaration` 303 → 159 (the contradiction arm; the two redundancy arms were already short and now drop one word); + - `rollup/non-numeric-aggregand` 300 → 165 (the line no longer names the parent object, so its length no longer grows with that name); + - `field/select-missing-options` 207 → 180. + - **What moved.** The text the fix lines stopped saying is in each id's `RULE_EXPLANATIONS` entry, which `os explain RULE_ID` prints: + - `unique/unscoped-declared-index`: what `unique: 'organization'` makes the driver build, and the `os migrate meta --from 17` route for existing sources (one new paragraph); + - `unique/legacy-organization-composite`: that the respelling makes the listed organization column NULL-safe in place, what the constraint then means, and that duplicate NULL-organization rows block the `recreate_index` with a report rather than failing a boot (one paragraph extended, one new); + - `unique/double-declaration`: where each scope word may be written (one new paragraph). + - `object/missing-name-field` now points at `os explain title-format-retired`, whose entry already says why a `titleFormat` template does not count. `field/select-missing-options` and `rollup/non-numeric-aggregand` lost only wording; the rollup entry already carries the reasoning. + - **Runtime publish gate:** none of the six runs there. The three uniqueness entries are `cli`-only registry rules, and the other three run only in `os lint`'s `lintDataModel` sweep. So no 422 `issues[].hint` and no 2xx `advisories[].hint` changes. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- cbbd96a: fix(lint): seven more 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 7 rule ids now carries a `hint` (the CLI's `fix:` line, the runtime advisory's `hint`) of one remedy sentence of at most 200 characters on every variant the lint suite fires (lengths are the `hint` alone; the CLI prints it after `fix: `). The longest of each, before → after: + - flow reference syntax (`lint-flow-patterns.ts`): `flow-double-brace-interpolation` (1413 → 197) and `flow-bare-dollar-reference` (1234 → 198); + - `liveness-ledger-unreadable` (270 → 82), `capability-reference-unknown` (241 → 131), `page-section-count-not-multi-value` (228 → 167), `list-view-filters-in-views-mode` (219 → 135) and `page-field-unknown` (206 → 94). + - **The flow ids keep the judge's spelling, and only where it evaluates.** In a value slot the line names the spec value-slot judge's own CEL envelope for the reference, read out of the judge's remedy and never re-spelled: FROM `A value slot reads a CEL value envelope, … so a bare $ reference is stored as written. $source.id names $source, which is not one of the flow engine's own $ variables, … Write {source.id} as { dialect: 'cel', source: 'source.id' }. CEL refuses an absent variable or key … (the guarded form writes null).` (681) TO ``A value slot stores a bare `$` reference as written: write `{source.id}` as { dialect: 'cel', source: 'source.id' }.`` (116). Where an envelope at the string's place would not evaluate (a string inside an object or list literal, a legacy `assignments: [{ variable, value }]` shape, a `$User` path that never resolved), the line names no envelope and points at `os explain`. It never names the build's own refusal for a `{{ }}` string, whose spelling keeps the outer braces as literal text. In a text slot the line names the `{{ }}` hole for each reference the engine binds, and for any other `$` root says to compute the value into a variable with an `assignment` node and write `{{ v }}`. At a notify or http literal it says neither brace dialect resolves there: write the value itself. + - **Lists are no longer echoed.** `page-field-unknown` no longer appends the object's whole field list; it names the nearest declared field ("Did you mean") instead. + - **`os explain RULE_ID` carries the rest.** What the fix lines stopped saying is each id's `RULE_EXPLANATIONS` entry in `@objectstack/lint`: the `has()` guard for a value that may be absent, the run user's `current_user` null guard and the `sys_user` read for any other `$User` path, the whole-value literal and the canonical `assignments` map, what a filter operand's `null` does, which slots read `{{ }}`, and where a brace token in a literal is refused; that a capability another installed package declares needs no change; why page filter controls do nothing on an object list view (ADR-0047); that a missing liveness ledger is a platform packaging fault; which field types take `multiple: true`. Six ids gain an entry, so their `rule:` line now carries the `os explain` pointer: `flow-double-brace-interpolation`, `flow-bare-dollar-reference`, `liveness-ledger-unreadable`, `page-field-unknown`, `page-section-count-not-multi-value` and `list-view-filters-in-views-mode`. `capability-reference-unknown`'s entry gains one sentence. + - **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), the two flow ids fire on a `flow` write as 2xx `advisories[].hint`, FROM → TO measured at the door: a value-slot `$source.id` 681 → 116, `$User.Id` 681 → 121, `$User.Email` 742 → 198, a nested literal 1231 → 198, a legacy assignment 1030 → 198; a text-slot `$User.Id` 767 → 144 and `$error.message` 114 → 83; a value-slot `{{ }}` 746 → 116, a notify literal 297 → 137, a value no slot reads 362 → 158. Studio's save-advisory toast prints an advisory's `hint` after its message. `capability-reference-unknown` and `list-view-filters-in-views-mode` run at the CLI only; the two page ids do not fire on a `page` write; `liveness-ledger-unreadable` fires only on an install whose `@objectstack/spec` lost a ledger file. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- b6805ac: fix(lint): the twelve flow anti-pattern 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 12 rule ids in `lint-flow-patterns.ts` now carries a `hint` (the CLI's `fix:` line, the runtime finding's `hint`) of one remedy sentence of at most 200 characters on every variant the lint suite fires (lengths are the `hint` alone; the CLI prints it after `fix: `). The longest of each, before → after: `flow-loop-body-uncontained` (800 → 174), `flow-multi-write-unfiltered` (695 → 73), `flow-try-catch-without-catch` (594 → 137), `flow-decision-inclusive-overlap` (437 → 159), `flow-approval-revise-target-not-service-owned` (412 → 76), `flow-time-relative-antipattern` (397 → 163), `flow-date-equality-filter` (379 → 123), `flow-runas-unscoped` (372 → 105), `flow-decision-mode-invalid` (295 → 134), `flow-decision-unconditional-branch` (286 → 149; its other arm, 142, is unchanged), `flow-error-label-not-fault` (239 → 81) and `flow-branch-label-unmatched` (224 → 122). + - **Examples.** `flow-loop-body-uncontained` FROM ``Contain the failure per iteration: put a `try_catch` INSIDE the body and move this node into its `try` region — … A `catch` region cannot be empty … Measured: with it, all 5 items are processed … See content/docs/automation/flows.mdx §"Per-iteration containment".`` (800) TO ``Move this node into the `try` region of a `type: 'try_catch'` node inside the loop body, with `catch: { nodes: [ { id: 'handled', type: 'assignment', label: 'Handled' } ] }`.`` (174). `flow-multi-write-unfiltered` FROM ``Write the constraint you mean into `filter` (e.g. `{ status: 'closed' }` — see examples/app-showcase `showcase_inquiry_purge`, …). If emptying 'lead' really is the intent, keep it: … Distinct from the run-time erased-condition guard, …`` (684–695) TO ``Write the constraint you mean into `filter`, e.g. `{ status: 'closed' }`.`` (73). `flow-runas-unscoped` FROM ``Declare `runAs:'system'` to make the elevation explicit and intended (the run reads/writes every record). A schedule flow cannot scope to a user — … (ADR-0049, ADR-0073 D5)`` (362–372) TO ``Declare `runAs:'system'` to make the elevation explicit and intended (the run reads/writes every record).`` (105). `flow-time-relative-antipattern` used to spell its range query as ``BETWEEN {TODAY()} and {TODAY()+N}``, in the single-brace dialect flows no longer resolve; its line now names the declared `timeRelative` sweep, and its entry spells the range with `$gte` / `$lt`. + - **`os explain RULE_ID` carries the rest.** Each of the 12 ids gains a `RULE_EXPLANATIONS` entry in `@objectstack/lint`, so its `rule:` line now carries the `os explain` pointer. What the fix lines stopped saying is there: which node types count as able to fail and how containment is judged; the two empty `catch` spellings the parse refuses, what the minimal handler may omit, and the measured result with it; when failing fast or retry-then-fail is a legitimate reading; the worked `showcase_inquiry_purge` example, keeping a meant whole-object write, and how the rule differs from the run-time erased-condition guard; when to keep `mode: 'inclusive'` and where `os migrate meta` left it on a flow authored against protocol major 17; what ends the revise window and why ADR-0044's amendment moved it off `wait`; the declared `timeRelative` sweep and the hand-written range-query alternative; why abutting one-day windows match each row once; why a user-less run is refused rather than run unscoped, and the anonymous-endpoint exception (ADR-0138 D2b); that `registerFlow` refuses an invalid `mode` with the same sentence; dropping `type: 'decision'` for a meant fan-out; which failures a `fault` edge routes and which labels read as an error path; and one branching mechanism per decision. + - **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), all 12 ids fire on a `flow` write, FROM → TO measured at the door. As 422 `issues[].hint`: `flow-approval-revise-target-not-service-owned` 412 → 76, `flow-runas-unscoped` 367 → 105 (schedule), 372 → 105 (time-relative) and 362 → 105 (api), `flow-decision-mode-invalid` 295 → 134, `flow-branch-label-unmatched` 224 → 122. As 2xx `advisories[].hint`: `flow-loop-body-uncontained` 800 → 174, `flow-multi-write-unfiltered` 695 → 73 and 684 → 73, `flow-try-catch-without-catch` 594 → 137, `flow-decision-inclusive-overlap` 437 → 159, `flow-time-relative-antipattern` 397 → 163, `flow-date-equality-filter` 379 → 123, `flow-decision-unconditional-branch` 286 → 149, `flow-error-label-not-fault` 239 → 81. No write changes channel. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- 9c74d40: fix(lint): the last eight fenced author-time 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 8 rule ids now carries a `hint` (the CLI's `fix:` line, the runtime finding's `hint`) of one remedy sentence of at most 200 characters on every variant the lint suite fires (lengths are the `hint` alone; the CLI prints it after `fix: `). The longest of each, before → after: + - flow template holes (`validate-flow-template-paths.ts`): `flow-template-field-unprovisioned` (362 → 175) and `flow-template-lookup-traversal` (316 → 139); + - page bindings (`validate-page-field-bindings.ts`): `page-field-unprovisioned` (362 → 175) and `page-section-group-unknown` (279 → 151); + - `print-page-block-unprintable` (362 → 170), `form-section-group-unknown` (275 → 147), `filter-token-unknown` (259 → 170) and `component-props-invalid` (258 → 188; its value-rejection line 248–258 → 157–167, while its retired-key line, 164–181, and its node-binding line, 188, were one remedy already and are unchanged). + - **Examples.** `print-page-block-unprintable` FROM ``Replace or remove the block. The printable blocks are `page:section`, `page:card`, … `object-metric`. If the page is not a document, delete its `print` declaration instead.`` (362) TO ``Replace the block with a printable one (`os explain print-page-block-unprintable` lists them) or remove it; if the page is not a document, delete its `print` declaration.`` (170). `flow-template-lookup-traversal` on a flow variable FROM ``A record read into a flow variable is not expanded either — config.expand is the START node's opt-in and covers the trigger record alone. Reference the foreign-key id directly ('{{ currentCase.owner_id }}'), project the value via a formula field on 'crm_case', or add a get_record node that reads the related record.`` (316) TO ``Write '{{ currentCase.owner_id }}' (the id) or a formula field on 'crm_case', or read the related record with a get_record node.`` (128). + - **Lists are no longer echoed.** The two section-group ids no longer append the object's declared field groups: the line names the nearest declared group ("Did you mean") instead. An object with no field groups gets one line: declare `fieldGroups: [{ key, label }]` and `group` on the member fields, or enumerate the section's `fields`. `print-page-block-unprintable` no longer lists the thirteen printable blocks; `os explain` does. `filter-token-unknown`'s line for a placeholder with no near miss names the two vocabularies briefly, and its `os explain` entry in full. + - **The anchor ids converge.** `page-field-unprovisioned` and `flow-template-field-unprovisioned` now print the shared one-line anchor fix the other unprovisioned-anchor ids print (`If the remote table carries 'owner_id', declare it in ext_customer's own fields (mapped through columnMap); otherwise drop the reference or opt the object out of the injection`, 175). + - **`os explain RULE_ID` carries the rest.** Each of the 8 ids gains a `RULE_EXPLANATIONS` entry in `@objectstack/lint`, so its `rule:` line now carries the `os explain` pointer. What the fix lines stopped saying is there: how to vouch for an unprovisioned anchor through `columnMap` or opt the object out of the injection (`ownership: 'none'`, `systemFields: { audit: false }`); what the start node's `config.expand` does (a one-hop re-read of the triggering record as the run's identity) and why it covers the trigger record alone; how a section inherits a field group's members, and that membership is declared on the field; the printable block list, held equal to the spec's `PRINTABLE_PAGE_COMPONENT_TYPES`, and the three reasons a block is refused; the context tokens and date macros a filter value resolves, where `{record_id}` resolves, and that a literal shaped like a placeholder must be renamed; and where a page component's props schema lives, why the page and not the schema is what to fix in almost every case, and the retired-key and node-binding cases. + - **At the runtime publish gate** (Studio, REST `PUT /api/v1/meta`, and the MCP and AI authors that write through that door), FROM → TO measured at the door. On a `page` write, as 422 `issues[].hint`: `print-page-block-unprintable` 362 → 170, `component-props-invalid`'s value-rejection line 248 → 157 (`page:tabs`), 250 → 159 (`object-grid`), 253 → 162 (`record:details`) and 258 → 167 (`record:related_list`). On a `flow` write, as 2xx `advisories[].hint`: `flow-template-field-unprovisioned` 362 → 175, `flow-template-lookup-traversal` 249 → 131 (trigger root) and 314 → 126 (flow variable), on a probe hopping through `crm_case.account`. The two page-binding ids do not fire on a `page` write, and `filter-token-unknown` and `form-section-group-unknown` run at the CLI only. No write changes channel. + - **Unchanged:** every `message` (the verdict), rule id, severity and `path`, and what each rule accepts or refuses. +- 9af0005: The shared build validator (`@objectstack/lint`'s expression rule over `@objectstack/formula`) judges `record..` against an object's declared read attachments (`ObjectSchema.attachedOnRead`) + + - **`@objectstack/lint`.** The field index the expression rule builds now adds each block an object declares under `attachedOnRead` to the names `record.` resolves to, and hands the shared validator the block's declared leaves. A predicate such as `record.viewer.can_act` on an object that declares the `viewer` block is accepted where it was refused as an unknown field. + - **`@objectstack/formula`.** `ExprSchemaHint` gains an optional `attachedOnRead` (block name → declared leaf keys). When a `record.` or `previous.` reference names a declared block, its next segment must be a leaf the block declares. Anything else is refused with the existing `unknown-field` code: `field` is the dotted path as written (`viewer.can_actt`), `suggestion` the nearest declared leaf (`viewer.can_act`), and the new optional `block` and `leaves` params carry the leaves the block declares, which the message names. No new refusal code. + - **`@objectstack/metadata-core`.** The ADR-0106 field-level-security masker classifies the new top-level key as passed through unchanged (`OBJECT_REFERENCE_POSITIONS.attachedOnRead`): a block name and its leaf keys never name a field, so no caller's served object definition loses or gains anything. + - **Unchanged.** An object that declares no block keeps every verdict it had, and a second segment off any other field is not judged. Index access, a method call on a block and a third segment stay unjudged. +- 440bed6: A select option whose `visibleWhen` cannot be evaluated refuses the write instead of admitting the value with one warn line (#22402) + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing on `@objectstack/objectql`, shipped as `minor` under the launch-window convention for breaking changes (recorded in `scripts/check-changeset-no-major.mjs`). The governing decision is ADR-0137 D2, which the maintainer's ruling on #22402 (letter A) extended to the option gate on the write path. + + A select, multiselect, radio or checkboxes option may gate itself with a `visibleWhen` predicate, and the server re-checks it for every value a write picks. ADR-0137 D2 says a field-rule predicate that cannot be evaluated refuses the submit. The option gate is the server's enforcement of who may pick the option (ADR-0124 D1), so D2 now reaches it on the write path too. + + - **Before.** When an option's predicate faulted on a write (an undeclared key, a root or member the gate does not bind, a read through a reference, a computed key or receiver, `current_user.can()` with no permission data), the server logged "the option's gate was NOT enforced on this write" and admitted the value. A role-gated option whose rule was broken was open to every caller. + - **Now.** The write is refused with `VALIDATION_FAILED` (HTTP 400) and one field entry in the envelope a faulting `requiredWhen` or `readonlyWhen` already uses: `code: 'rule_violation'`, `constraint.reason: 'unevaluable'`, `constraint.rule: 'visibleWhen'`, `constraint.fault` naming the fault, and the picked option as `value`. The message names the option, the field and the fault, for example `Option 'gold' of field 'tier' visibleWhen could not be evaluated (runtime: No such key: statsu) — write rejected.` Nothing is written. This holds on insert (one row or a batch), by-id update and bulk update, and so on an import, which writes through them; `validate()` previews the same refusal. + - **Unchanged.** A predicate that evaluates is judged exactly as before: false refuses with `invalid_option`, true admits. A system write with no acting user whose predicate reads the acting user (`'admin' in current_user.positions` on a seed) is still admitted and logged with reason `no-acting-user`, because the gate has nobody to ask about. A system write whose predicate faults for any other reason is refused, as a faulting `requiredWhen` is. The form still offers an option whose predicate faults (ADR-0137 D3); the refusal comes at save. + - **`current_user.can()` with no permission data.** On an engine with no effective-permission resolver registered (no security plugin composed), a write by an acting user that picks a `can`-gated option is now refused, naming the missing permission data, instead of admitted. + - **A preview limit this inherits.** An `update`-mode `validate()` preview (an import dry run of a matched row) reads no stored row, so a cascade predicate whose parent column the patch omits faults there and the preview now refuses the row, as it already refuses a `requiredWhen` that reads an omitted column. The write itself reads the stored row and judges the predicate cleanly. + + **The remedy: fix the predicate, which `os build` names for every statically judgeable shape.** The shapes no build can judge (a computed key, a computed receiver, a row stored before the build verdicts, or a save under `OS_ALLOW_UNLINTED_METADATA_WRITES=1`) now meet this refusal at the first write that picks the option, naming the fault. + + Measured before the change, on `origin/main` 3054516ef1: the objects this repository ships carry 5 option predicates, all on the showcase's `showcase_cascade` (four `record.country == …` cascades and one `'org_admin' in current_user.positions` role gate), and none of them faults on a write. Insert and update evaluate them over a total record, so a payload without `country` reads `null == 'cn'`, a clean false. No seed or dogfood write picks a gated option. + + Earlier entries in this release that say an option predicate which faults is still logged and admitted describe the gate before this change. + + `@objectstack/lint` and `@objectstack/spec`: the published text that said the server admits a faulting option predicate now says it refuses the write. This covers the `objectstack validate` finding for an option predicate that reads through a reference, a root or member the gate does not bind, and the `SelectOption.visibleWhen` description. No verdict moves. +- 3073b72: A `{{ }}` hole in a flow text slot (a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`) whose root is a `$` name the flow engine does not bind is refused. It is refused at the same doors, and by the same judge (`textSlotTemplateRefusal`), as a single-brace token: the node contracts, `registerFlow` and `objectstack validate`. `'By {{ $User.Id }}'` used to pass all three and send `'By '`. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** The hole grammar admits `$` in a name so that the engine's own variables have a spelling (`{{ $error.message }}`). That also makes `{{ $User.Id }}` a well-formed hole, but over a root no flow variable answers to. The text went out with the fragment missing, the run reported success, and nothing warned. Its single-brace spelling, `{$User.Id}`, was already refused with a remedy. The `$` names are reserved for the engine: a resume signal may not write one. + + **What is refused.** + + - A hole whose root is a `$` name other than the variables the engine binds: `$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`. + - `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` raise a `custom` issue at the slot's key. + - `registerFlow` refuses the flow, and a stored flow carrying such a hole is skipped at boot with a warn naming it. + - `objectstack validate` reports `expression-invalid` at `error`. + - The remedy for `{{ $User. }}` is the sentence `{$User.}` gets: for `$User.Id`, compute the run user's id into a variable with an `assignment` node's CEL value envelope (`current_user.id`), then write the variable as a hole; any other `$User` path never resolved, and the refusal says how to read the user record by that id. Any other root is named in the refusal, beside the variables the engine does bind. + - A single-brace path token over such a root (`'Failed: {$caught.message}'`) is no longer prescribed the `{{ }}` spelling, which would be refused in turn; it gets the same remedy. + + **Unchanged.** `{{ $error.message }}`, `{{ record.name }}`, a node output `{{ lookup.result }}` and every hole over an engine-bound `$` variable. The template engine binds no new variable. + + **`@objectstack/lint`.** In a text slot, `flow-bare-dollar-reference` prescribes the hole for a bare `$X.y` written outside the holes only when the judge admits that hole. A bare `$User.Id` gets the judge's refusal and remedy instead of a `{{ $User.Id }}` the judge refuses. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `message: 'By {{ $User.Id }}'` | an `assignment` node first, `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `message: 'By {{ by }}'` | + | `errorVariable: '$caught'` with `message: 'Failed: {{ $caught.message }}'` | `errorVariable: 'caught'` with `'Failed: {{ caught.message }}'`, or keep the default `$error` and write `{{ $error.message }}` | + + **The one-line fix: compute a run-user value into a variable first, and name a variable the flow binds itself without the `$`.** + + **Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), has no text-slot judge. Its `NotifyConfigSchema.title` / `.message`, `ScreenConfigSchema.title` / `.description` and `EndConfigSchema.message` are plain strings, so it accepts `'By {{ $User.Id }}'` in every one of these slots. Its single-brace interpolator substituted the inner `{ $User.Id }` token and left a literal brace on each side. This repository was measured with `git grep` over `examples`, `packages`, `skills`, `apps` and `content`: no flow text slot outside tests carries a `{{ $… }}` hole other than `{{ $error.… }}`. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `textSlotTemplateRefusal` in `automation/flow-text-slot-template.ts` reads one package-internal list of the `$` variables the engine binds. `@objectstack/service-automation`'s `text-slot-template.test.ts` scans that package's sources for every `$` variable they bind by name, and fails when the list misses one. + - **The ledger.** The D3 semantic entry `flow-text-slot-unbound-dollar-root-refused` (protocol 18). There is no D2 conversion: what the hole was meant to read is not in the flow. +- c76edeb: A declared read attachment resolves only where `record` is a row a service serves, so `os validate` refuses a flow condition that reads one + + Clause-②: no + + `ObjectSchema.attachedOnRead` declares the blocks a service attaches to the rows it serves, computed per caller and never stored. An earlier entry in this release adds each declared block to the names `record.` resolves to, at every expression site bound to the object. That covered the sites whose `record` is the stored row, which never carries a block. There `os build`, `os validate` and the object save door accepted a read such as `record.viewer.can_act`, and the expression then faulted with `No such key: viewer` on every row. A record-change flow on `sys_approval_request` whose start condition read `record.viewer.can_act == true` validated, then failed on every record it fired for. + + A declared block now resolves, and its leaves are judged, only at the sites whose `record` is a served row: an action's `visible` and `disabled` predicates. + + - **Refused now.** On an object that declares a block, `record.BLOCK` at any other site gets the refusal it gets on an object that declares none: ``unknown field `viewer` on `sys_approval_request` ``, at `error`. Those sites are a flow's node and edge conditions, a validation rule, a field's `requiredWhen`, `readonlyWhen` and `visibleWhen`, an option's `visibleWhen`, a field formula, a sharing-rule condition and a hook condition. The block is no longer offered as a "did you mean?" candidate there either. The object save door runs the same rule over an object's own slots, so an object write in publish mode that reads a block outside an action predicate is refused with an `expression-invalid` issue. + - **Unchanged.** At an action predicate a declared leaf is accepted, and a misspelt leaf is refused with a message that names the leaves the block declares. `sys_approval_request`'s eight action predicates pass. No refusal code is added, and no export or signature moves. + - **`@objectstack/spec`.** The description of `ObjectSchema.attachedOnRead`, which the JSON schema and the reference pages carry, now says where the validator reads the key: in an action's `visible` and `disabled` predicates `record.` resolves and its leaves are judged, and every other expression site binds the stored row and refuses `record.` as an unknown field. The schema and its parse verdicts are unchanged. + - **Reach.** The only shipped object that declares a block is `sys_approval_request`, and its action predicates are the only shipped expressions that read it. The earlier entry has not been released, so no released version accepted a block read at these sites. +- 9f15939: `os lint`'s `approval-approvers-may-resolve-empty` hint no longer tells an author that the admin bulk import does not write `sys_user.manager_id`; it names the import's `manager_id` column as a working route, beside Set Manager and the admin endpoint + + Clause-②: no + + On a node whose approvers are all `{ type: 'manager' }`, the hint listed the routes that populate `sys_user.manager_id` and said of one of them: "its admin bulk import does not write it either". That has been false since the import admitted the column. `POST /api/v1/auth/admin/import-users` reads a `manager_id` column whose cell holds the manager's email address, or phone number where phone sign-in is enabled. The manager can be someone in the same file or an existing user. The import links each row once every row in the file exists, through the same derivation as `POST /api/v1/auth/admin/set-user-manager`, and reports each outcome on the row's `manager` result. A link it cannot make never fails the user it imported. The old clause sent an author with a whole file of reports away from that route. + + The hint now names the bulk import's `manager_id` column among the routes that work on this platform, ahead of the clause that hands SCIM and directory sync to the deployment, and names Set Manager (Setup → Users) beside the endpoint it posts to. Nothing else about the rule moves: it fires on the same shape, at `info`, and a seeded manager chain remains the one silencer. +- 69d4218: feat(plugin-approvals): an approval step whose approvers resolve to no one opens on the organization's administrators under the default `onEmptyApprovers: 'admin_rescue'`, as ordinary approvers in their inbox + + Clause-②: yes + + **FROM → TO: who receives an `admin_rescue` request.** A node whose approvers resolve to no concrete person (an unstaffed position, an empty team or department, an unset manager, an expression that yields no one) under the default `onEmptyApprovers: 'admin_rescue'`: + + - **FROM:** the request opened on a slate that named no one, such as the literal `position:sales_manager`. It was in no one's "My Pending", the bell counted 0, no one was told, and a `lockRecord: true` record stayed locked until a platform or tenant admin found it under **All** and decided it through the privileged override. + - **TO:** the request opens on the request organization's administrators: the people who hold the organization-admin capability (`organization_admin` or `organization_admin_no_bypass`, ADR-0095 D3) in that organization. That is the same authority that lets a tenant admin override a request, resolved to the people who hold it, through the same grant resolution every request runs. An organization's owners and admins receive the capability automatically. + + They are ordinary approvers. The request is listed and counted for each of them under their own `approverId`, each is told once by `approval.requested`, out-of-office delegation applies to them, and a decision by one of them is an ordinary approval or rejection, recorded with `via_override: false`. The node's `behavior` reads them as one group. A platform administrator is not added for being one; one who also holds the organization's capability, such as its owner, is on the slate for that. + + The request says why. Its timeline carries a note, for example "Routed to this organization's administrators because the approver slate named no one: position 'sales_manager' has no holder.", and its node-config snapshot keeps the abandoned slate and the reason under `__resolvedFrom` (`admin_rescue:abandoned`, `admin_rescue:reason`). The abandoned slate is replaced, not kept beside the administrators, so a holder staffed into the position after the request opened does not receive it; an administrator can reassign it to them. + + **Unchanged.** A slate that names a person; `fallback`, `auto_approve` and `fail`; a `fallback` whose own list resolves to no one still degrades to `admin_rescue`, which now opens on the administrators. The privileged override stays for what the administrators' slate cannot reach: a request whose named approvers have all left after it opened, and an organization with no administrator (or a request with no organization), where the request still opens on the slate that names no one and the server logs a warning. + + **To name the person who should decide instead of the administrators,** declare `onEmptyApprovers: 'fallback'` with a `fallbackApprovers` list. + + **`@objectstack/spec`:** the `onEmptyApprovers` description says that `admin_rescue` opens on the organization's administrators and that `fallback` names who decides instead. + + **`@objectstack/lint`:** `approval-approvers-may-resolve-empty` no longer says the request "waits forever": it says the request opens on the organization's administrators, its remedy names `onEmptyApprovers: 'fallback'` as the way to name who decides, and its `os explain` text says the same. +- eed637c: docs(automation): every text that teaches a sender of an inbound `api` hook teaches the timestamped `x-objectstack-signature` form only — `t=,v1=`, the HMAC-SHA256 of `.` under the flow's secret, refused when `t` is more than 300 seconds from the server clock + + Clause-②: no + + The inbound hook door has accepted the timestamped form since `signHttpBodyAt` landed in `@objectstack/core`, and it is the only form that stops a captured post from verifying forever; the published teaching still showed the body-only `sha256=` form. The guidance converges first; the door's refusal of the body-only form is a later change of its own. + + - **`@objectstack/skills`.** The `objectstack-automation` skill's Inbound webhook section teaches `t=,v1=`: `v1` is the HMAC-SHA256 of `.` under the start node's `secret`, and the door refuses a `t` more than 300 seconds from its clock. The body-only form is no longer shown. + - **`@objectstack/lint`.** The hint of `flow-api-trigger-secret-missing` and its `os explain` entry describe the same form. The rule's id, severity, verdict sentence and judgement are unchanged. + - The showcase example's comment and the platform checklist's intake recipe sign the same way. + + **Nothing to migrate for an author.** A sender that already signs with `signHttpBodyAt` is unchanged. **The outbound contract stays as published:** the messaging outbox and the flow `http` node keep signing body-only (`signHttpBody`) for third-party receivers, and the inbound door's accept set is not changed by this release. +- eb20c33: fix(cli): the nine rule ids the CLI produces print one verdict sentence each, `os explain ` carries their reasoning, and the undeclared-handlers boot line is one line + + Clause-②: no + + - **One verdict sentence of at most 200 characters.** Measured on every finding the CLI's suites fire (message length; before → after): + - `os lint`'s hook-body rules: `hook-body/not-lowerable` 565–661 → 165–178, `hook-body/bundled-fallback` 324–947 → 109–171, `hook-body/unparseable` 469 → 157, `hook-body/extraction-failed` 331 → 140. Over every refusal the extractor can throw (14 forbidden constructs and 7 parameter refusals), `hook-body/bundled-fallback` runs 296–1,011 → 113–199: the verdict quotes the extractor's own verdict clause, cut to fit and marked `…` when cut. + - package docs (`os build`, `os validate`, `os lint`): `docs/uncollected-directory` 433–558 → 124–183, `docs/nav-target` 263–299 → 147–184, `docs/frontmatter-tags` 233–285 → 135–195, `docs/duplicate-name` 330 → 167 (its within-package arm is unchanged at 101). A doc finding no longer repeats its path, which every printer already leads with; lists of files, packages and directories name the first three and count the rest. + - `picklist-reference-unverified` (the `os validate` / `os build` notice): 240–245 → 173–175, naming at most two outside dependencies and counting the rest. + - **A `fix:` line for the hook-body rules.** Their remedy moves out of the message into the finding's `fix`, at most 200 characters: `os lint` prints it as `→ fix:` under `--fix`, and `os lint --json` now carries `fix` on these four ids' issues. The remedy is still chosen per case: inline a module-scope name, or keep a check on a host-only global such as `Intl` in a string handler ref or a validation rule. A doc finding carries no separate fix, so its sentence ends with the remedy; the picklist notice's `hint` is unchanged. + - **`os explain ` carries the rest.** Each of the nine ids gains a `RULE_EXPLANATIONS` entry in `@objectstack/lint`, the one table `os explain` reads, and `os lint`'s `rule:` line now points at it. They hold what the messages stopped saying: why a bundled handler is a change of deployment shape and how to bundle on purpose, what the sandbox cannot run and where `os build` prints the full refusal, why an instrument limit is not a verdict, where package docs are read from and why the collector never guesses, why a doc name may appear once, what a doc navigation item can open, how doc tags are read, and why a reference into a dependency outside the stack is not judged. + - **The `[action-governance]` boot line for registered handlers with no declaration** (`os dev`, `os serve`, and any host that boots ObjectQL) is one line of 192 characters, where it was 628: it names the three sources it read, states the 404 as expected rather than measured, and says to declare each with `defineAction`. Its structured `count` / `handlers` tail is unchanged, and the dead-button line beside it is unchanged. + - **Unchanged:** every rule id, severity and `path`, the boot lines' level and payload, and what each check accepts or refuses. A tool that matched on this message text should match on `rule` and `path` instead. +- b4d573a: fix(spec,core): the rolling `last_7_days`, `last_30_days` and `last_90_days` presets close on today, at the spec's prescription table, the save door's refusal and the analytics preset arm + + Clause-②: no (no accept set widens: the three rolling windows gain the inclusive `{today}` end every calendar preset already has) + + The three rolling windows were the open arm of `DATE_RANGE_PRESET_MACRO_WINDOWS`: `['{30_days_ago}', null]`, prescribed as `{ $gte: '{30_days_ago}' }`. A `$gte`-only window has no upper bound, so on a column that carries future dates (a close date, a due date) "last 30 days" also counted every later day. Measured on a `date` field with one row dated today−5 and one dated today+5: that window counted 2, while the console's preset lowering and the analytics preset arm counted 1. + + - `DATE_RANGE_PRESET_MACRO_WINDOWS` now closes every rolling window on `{today}`: `last_7_days` is `['{7_days_ago}', '{today}']`, and `last_30_days` and `last_90_days` follow the same shape. That is the inclusive last-day end every calendar preset already uses, and the extent the console's lowering already selects. The table's declared type narrows to match, `readonly [start: string, end: string]` (it was `end: string | null`). A reader that compared `end` with `null` still compiles; that branch can no longer be taken. + - The save door's refusal of a bare preset comparand (`bareDateRangePresetComparandMessage`, and the `filter-preset-comparand` lint verdict that quotes it) now prescribes the closed window. For `last_30_days` it says: write `{ $between: ['{30_days_ago}', '{today}'] }`, where it used to say `{ $gte: '{30_days_ago}' }`. + - `@objectstack/core`'s `resolveAnalyticsDateRangePreset`, the preset arm behind `timeDimensions[].dateRange`, selects the same extent. A rolling window now stops before tomorrow begins in the reference timezone, exactly as `today` does, with `endExclusive: true`. It used to end at the reference instant, so it dropped rows stamped later today. Its ends keep this module's own "the day the window stops before" spelling (`tomorrow`). Every preset this resolver returns is now `endExclusive: true`, and a caller's explicit `[a, b]` window keeps its inclusive reading, as before. + - The semantic migration entry `filter-preset-ordering-comparand-refused`, which `spec-changes.json` and `os migrate meta` carry, now names `{ $between: ["{30_days_ago}", "{today}"] }` as the window for `last_30_days` in its replacement, where it named `{ $gte: "{30_days_ago}" }`. + + If you followed the old refusal and authored `{ $gte: '{N_days_ago}' }` for a rolling preset, add `$lte: '{today}'`, or write the `$between` pair above, to stop counting future-dated rows. +- 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. +- 105902f: feat(spec)!: retire the view form's `visibleOn` and the page component's `visibility` as keys; their ADR-0089 renames leave the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** ADR-0089 made `visibleWhen` the one conditional-visibility key, and through 17 the old spellings were declared `[DEPRECATED]` keys that the parse folded onto `visibleWhen` without a word: `visibleOn` on a form view's fields and sections, and `visibility` on a page component. At 18 all three positions carry a `retiredKey()` tombstone naming `visibleWhen`, the parse-time fold is gone, and the two ADR-0087 D2 conversions `view-visibleOn-to-visibleWhen` and `page-component-visibility-to-visibleWhen` (protocol 15) are retired from the load path (`retiredAfter: '17.7.0'`). + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `visibleOn: "…"` on a form field (`view.form.sections[].fields[]`) | `visibleWhen: "…"` | + | `visibleOn: "…"` on a form section (`view.form.sections[]`) | `visibleWhen: "…"` | + | `visibility: "…"` on a page component (`page.components[]`, slots and nested containers included) | `visibleWhen: "…"` | + + **The one-line fix:** rename the key to `visibleWhen`; the CEL predicate is unchanged. Where a node wrote both keys, `visibleWhen` was the one in effect: delete the old key. + + **What an author who still writes it sees.** `tsc` fails at a typed authoring site (`FormFieldInput`, `FormSection`, `PageComponent` type the key as retired), and the parse refuses it by name at its own path — `defineStack` throws `STACK_SCHEMA_INVALID` (422), `os validate` / `os build` print the issue, and the metadata save door answers 422: + + > `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. + + **No tool makes the rename.** Both conversions belong to protocol 15, below the migration floor, so `os migrate meta --from 17` applies no edit for either key and `--write` writes none: it prints each written key among the refusals the migrated stack still has, and lists the D3 entry `ui-visibility-alias-keys-retired` as the hand edit. + + **Stored metadata needs no edit.** Stored-row rehydration and the artifact-ingestion door inside its window still replay both conversions, so a `sys_metadata` view or page row written with the old key keeps loading with `visibleWhen`. A script that saves a view or page through the metadata API with the old key is refused and needs the same rename. + + ## The retirement kit + + - **Tombstones** on `FormFieldSchema`, `FormSectionSchema` and `PageComponentSchema`; `RETIRED_KEYS_BY_MAJOR[18]`: `ui/FormField:visibleOn`, `ui/FormSection:visibleOn`, `ui/PageComponent:visibility`; the authorable-surface rows read `[RETIRED]`. + - **The fold is gone.** The three shapes are plain closed objects again (no `.transform` pipe): a section's group-reference rule no longer lists `visibleOn` (the tombstone answers it first), and a slotted page node's `type` refusal now arrives at its own path instead of nested in an `invalid_union`. + - **The D3 entry `ui-visibility-alias-keys-retired`**, one for both keys. + - **No deprecation window**, per the project's startup-stage posture. + + `@objectstack/lint`: the visibility-predicate rules (`visibility-root-mislayered`, `visibility-bare-identifier`, `visibility-predicate-syntax`) and the predicate-path rules read `visibleWhen` only. `os validate`, `os build`, `os verify`, `defineStack` and the metadata save door parse first and refuse either retired key by name, so a body they judge cannot carry one. `os lint` is the one door that hands these rules an unparsed stack: there a predicate written under a retired key is not judged, by design — the parsing doors refuse the key itself, and `os lint` reports no refused shape. + + `@objectstack/metadata-core`: `detectUnboundFormViewPredicateRoots` reads `visibleWhen` only. Its caller scans an artifact after `applyArtifactForwardConversions`, which renames a legacy `visibleOn` first. + + Not part of it: `normalizeVisibleWhen` and `VISIBILITY_ALIAS_KEYS` stay exported (no schema here calls them any more); objectui's renderer reads of the old keys leave on the objectui side. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + +- 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`. + + +- 4bd7215: fix(lint): the translation-reference rules now judge a locale pack's `picklists` face (#23566) + + Clause-②: no + + Before this change a locale pack's `picklists` entries were never checked. A key that named nothing passed `os lint --strict`, and at runtime the resolver simply never found it, so the label rendered in the source locale. The two existing rules now cover this face, each at the severity its field-level twin already has. No new rule id is added: + + - `picklists.` for a list that no package in the stack declares is `translation-target-unknown` (error). When a declared name is close, the finding offers it as "Did you mean". + - `picklists..options.`, where the key is not a value of the list or of a `picklistExtensions` entry for it, is `translation-option-key-unknown` (warning). A key spelled as an option's display label is told which value to rename it to. + + A list the stack only extends through `picklistExtensions`, and that no package of the stack declares, is skipped: its owner's options cannot be seen from here. A list declared by a sibling package of the same artifact resolves, and its option keys are judged. `os explain translation-target-unknown` and `os explain translation-option-key-unknown` name the new face. +- 5e97907: feat(plugin-sharing,cli)!: declared sharing rules are read from the registry — the boot writes no `sys_sharing_rule` row for them, and the 17 → 18 ownership ceremony deletes the copies the retired seeder wrote + + Clause-②: no (narrowing) + + + + The narrowings, FROM → TO, each with its one-line remedy: + + - `DELETE /api/v1/sharing/rules/:idOrName` on a declared rule — FROM `204`, the seeded row deleted (and written again by the next boot), TO `403 PERMISSION_DENIED`. Remedy: change or remove the rule in its declaration in the package; an organization's own rule is deleted as before. + - `sys_sharing_rule` — FROM one `managed_by: package` row per declared rule, TO none: the boot writes none and the ownership ceremony deletes the copies the seeder wrote (Setup's Sharing Rules object list, which lists rows, no longer shows declared rules). Remedy: read declared rules through `GET /api/v1/sharing/rules`, which lists them beside the organization's own; on an upgraded database run `os migrate organization-ownership --apply` with a fresh plan. + + The retirement note for the seeder is the ADR-0087 semantic entry `sharing-rule-declared-mirror-retired`. + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `@objectstack/plugin-sharing` no longer copies a stack's declared `sharingRules` into `sys_sharing_rule` at boot. ADR-0131 D2 retires that seeder (`bootstrapDeclaredSharingRules`). The sharing-rule service reads each declared rule from the registry whenever it reads the rule set: listing, getting, evaluating, the per-record hooks, the boot backfill and the orphaned-grant sweep. Rules an organization authors keep their `sys_sharing_rule` rows. + + - **A declared rule still grants, and stops granting when its declaration goes.** It is translated from the registry's copy exactly as the seeder translated it: the CEL `condition` is lowered to a `criteria` filter, and `sharedWith` becomes the recipient. A condition that does not lower is not served, and never as a match-all. It says so in one WARN line per rule per process. A declared rule's id is its NAME, and the grants it materialises carry the name as `sys_record_share.source_id`. Remove the declaration and the next boot's orphaned-grant sweep revokes those grants. + - **Scopes follow the posture, as the seeder's passes did.** Under `single` a declared rule is served once, organization-less, so every organization reads it. Under a wall it is served once per organization — every organization, read page by page. An organization created after boot is served from its creation on. If the organizations cannot be read at boot, no declared rule is served until the next boot, and the boot's orphaned-grant sweep revokes none of their grants meanwhile: a failed read is never a revocation. + - **A stored row of the same name answers for it.** Under `single` that is a row of that name in any organization, and under a wall that organization's own row. Such a row is an organization's own rule, a seeded copy an administrator customized, or a seeded copy the ceremony has not deleted yet. The declared rule is not served beside it, the same as while the seeder ran: it never overwrote an admin-authored or customized row. + - **`DELETE /api/v1/sharing/rules/:idOrName` refuses a declared rule** with `403 PERMISSION_DENIED`. It has no row: it is changed or removed in its declaration. Before this release a platform administrator could delete the seeded row, and the next boot wrote it again. An organization's own rule is deleted as before. + - **Setup's Sharing Rules object list no longer shows declared rules.** It lists `sys_sharing_rule` rows. `GET /api/v1/sharing/rules` lists both. + + `@objectstack/cli` — **`os migrate organization-ownership --apply` deletes the declared-rule mirrors.** The `declared-rule-mirrors` rows on `sys_sharing_rule` are the seeder's uncustomized copies (`managed_by` `package` or `platform`, `customized` not true). They now block certification until the ceremony's `mirror-deletion` step removes them. It works in the ruled order. It renames every grant naming a mirror by row id (`sys_record_share.source_id`, `source = 'rule'`) to the rule's name and verifies that no grant names a mirror any more. Only then does it delete the mirrors. Under a wall it deletes the organization-stamped copies too. + - A mirror whose rule no installed package declares, and that grants still name, blocks with nothing written. The blocker names the rows and the remedy. A mirror no grant names is deleted. + - The step also runs on a database whose ceremony already completed while an earlier release still seeded the rows, where they were advisory. Re-run `--apply` with a fresh plan. + - `DECLARED_SHARING_RULE_SEEDER_RUNS` is now `false`. A customized copy and an organization's own rule stay. Under `single` they take the Default Organization, as before. + + `@objectstack/lint` — the sharing-rule enforceability verdicts and `os explain` say what now happens to a declared rule whose condition does not lower. It is "never served", not "never seeded", because it is never written to `sys_sharing_rule`. The rule ids, severities, paths and what each rule accepts or refuses are unchanged. +- 85be049: fix(lint): `filter-token-unknown` states what the runtime does with an unresolvable filter placeholder: the data engine refuses the query with `FILTER_TOKEN_UNKNOWN` / 400 + + Clause-②: no + + The finding's message said the value "is sent to the data engine as a literal string, matches no record, and the surface renders empty". The runtime does not do that. The data engine refuses a query that carries an unresolvable placeholder, `FILTER_TOKEN_UNKNOWN` with HTTP 400, rather than compare it as a literal string. The message now reads: + + ```text + Filter value "{current_user}" is not a resolvable placeholder. The data engine refuses a query that carries it (`FILTER_TOKEN_UNKNOWN`, 400), so the surface never shows the data the filter meant. + ``` + + Unchanged: the rule id, its severity (`error`), the finding's `path`, its `hint` (the `fix:` line), and which values the rule reports. The message's first sentence is unchanged as well. `os explain filter-token-unknown` already stated the refusal and is unchanged. +- de70e97: Retirement prescriptions name `os migrate meta --write`: "… to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand." + + Clause-②: no + + Wording only: no schema, key, type, export or error code changes, and every input that parsed or was refused before gets the same verdict at the same path. Only the closing sentence of the message changes. + + - A prescription covered by an ADR-0087 conversion used to close with "Run `os migrate meta --from N` to list the mechanical edits for existing sources; apply them by hand." It now closes with "Run `os migrate meta --from N` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand." The default run still only lists. `os migrate meta --write` rewrites in place each edit it can trace to one literal in one project file and lists every other edit with the reason it was not written. The sentence never says the tool rewrites your sources automatically, because `--write` does not write every edit. Text up to and including "for existing sources;" is unchanged, so a caller matching that prefix still matches. + - The three prescriptions for conversions that cover only part of a value (dashboard `compareTo.offset`, the script flow node's `config.actionType` and the package manifest `permissions` case) carry the same `--write` clause before their second clause. + - The `record:chatter` / `record:discussion` `position` value prescriptions (`'sidebar'`, `'inline'`, `'drawer'`) used to tell the author to run a bare `os migrate meta`, which the command refuses because `--from` is missing. They now name `os migrate meta --from 17` and close with the same sentence. + - `@objectstack/lint`: the script node's retired dispatch-key diagnostic and the dataset widget's `chartConfig.xAxis.field` hint close with the new sentence. + - `@objectstack/driver-turso`: the `timeout`, `localPath` and `wasm` config tombstones close with the new sentence. +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [4840720] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [345d3f3] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0 + - @objectstack/formula@18.0.0 + - @objectstack/sdui-parser@18.0.0 + ## 18.0.0-next.5 ### Minor Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index 357e75da10b..67fe1aee9d1 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "18.0.0-next.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Static, build-time validation for an ObjectStack metadata graph — dashboard widget bindings, CEL/predicate expressions, and more. Pure (stack) => Issue[] functions shared by the CLI's `os validate` and any other consumer (e.g. AI authoring). Depends on @objectstack/spec; never on a runtime.", "type": "module", diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 70c5432e4e5..5b53850a32f 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,563 @@ # @objectstack/plugin-mcp-server +## 18.0.0 + +### Minor Changes + +- c4e7fa5: fix(mcp)!: on the stdio transport, a nested-relation filter condition asks the related object its declared exposure, and is refused when that object does not serve `list` (#22777) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A nested-relation filter condition, `{ : { : … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one. + + **FROM.** On the MCP stdio transport (`OS_MCP_STDIO_API_KEY`), the `query_records` and `aggregate_records` tools judged only the object they address against its `enable` block, so such a condition in `where` was evaluated whatever the related object's `enable` block declared — including a related object those same tools refuse when it is addressed. + + **TO.** The stdio data bridge asks each condition's related object the same exposure gate it applies to an addressed object, for `list` (the operation a query of that object is judged as), before the engine is asked. A related object the gate does not serve refuses the call with the bridge's existing exposure refusal, the one an addressed object gets: + + - `enable.apiEnabled: false` → `OBJECT_API_DISABLED` (404). + - an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `OBJECT_API_METHOD_NOT_ALLOWED` (405), with the effective operation set. + + The tool's error text names the related object and the condition's position (`where.`), with the remedy. It applies at the top level and under `$and` / `$or` / `$not`. The generic data door has given the same answer for the same condition since #22737. + + **Migration.** Filter on the lookup's stored id (`{ : '' }` or `{ : { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`. + + **Bindings.** + + - **Refused, not withheld.** Dropping a condition would widen the result. + - **One decision, one walk.** The gate is the one the bridge already applies to an addressed object, and the conditions are found by `relationConditionSites` from `@objectstack/core`, the walk the data door uses. + - **The engine is untouched.** A flow's or a hook's own query calls the engine directly and never passes this bridge. + - **No new error code.** +- 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 + +- 4e9fe9f: feat(spec)!: `PROTOCOL_VERSION` is `18.0.0`; the runtime handshake accepts `engines.protocol: '^18'` and refuses `'^17'` + + Clause-②: yes (narrowing: the handshake refuses a manifest pinned to protocol 17 (`'^17'`, `'^17.x.y'`, `'17.x'`), while the exported `PROTOCOL_VERSION` moves to `'18.0.0'` and a `'^18'` manifest is admitted) + + + + **BREAKING**, graded `major`: Changesets is in pre mode (`next`) for the v18 line, so this ships in an `18.0.0-next.N` prerelease. The protocol major moves in this reviewed change, not in the version pass (ruling record 6049734955, Q1 → B). Until that line's first version pass, `PROTOCOL_VERSION` is one major ahead of the package version; `check-changeset-no-major.mjs` admits that only in pre mode with a pending `major` for `@objectstack/spec` (ruling record 6056808625). + + - **`PROTOCOL_VERSION` reads `'18.0.0'`** and `PROTOCOL_MAJOR` reads `18`. The ADR-0087 D1 handshake on every load and install door (the app load seam, `POST /api/v1/packages`, `POST /api/v1/marketplace/install-local`) now admits a package whose `engines.protocol` admits major 18, and refuses one pinned to 17 (`'^17'`, `'^17.x.y'`, `'17.x'`) with `OS_PROTOCOL_INCOMPATIBLE` (422), naming `objectstack migrate meta --from 17`. + - **What you change.** Run `objectstack migrate meta --from 17` to list the edits for existing sources; `--write` applies the ones it can prove, including the declared `engines.protocol` range, rewritten in place from `'^17'` to `'^18'`, and you apply the rest by hand. + - **`spec-changes.json` and the protocol upgrade guide** now carry the protocol 17 → 18 record, generated from the registries: 64 mechanical conversions and 329 semantic entries. The aggregate record spans 16 → 18. + - **`os init`** stamps `'^18'` in the manifests it writes, and `os lint`'s `protocol/missing-engines-range` fix names `'^18'`. The `create-objectstack` template keeps `'^17'` until the version pass stamps it together with its `@objectstack/*` dependency ranges. + - **`@objectstack/mcp`**: the README's `defineStack` examples declare `'^18'`. +- 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 +- 3a734e0: feat(spec)!: a page region named `main` takes only `width: 'full'` — `'small'`, `'medium'` and `'large'` there are refused at publish (#24326) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention. + + `PageRegionSchema.width` is one enum shared by every region, so a `main` region accepted `'small'`, `'medium'` and `'large'`. The console never read them: every layout draws `main` at the width the layout leaves it (the flexible column beside `sidebar` and `aside`; one grid cell on `dashboard`), and the only reader of `main.width` is the page itself, for `'full'`, which lifts the page's max-width cap. A three-column page that asked for a `large` main got the space the two rails left, and every door reported success. + + - `PageSchema` now refuses `width: 'small' | 'medium' | 'large'` on a region named `main` (matched case-insensitively, as the console matches it), at `regions.N.width`, on every page kind and template. The message names the region and `'full'` as the one width it takes. The doors are `definePage`, `defineStack` (so `os validate` and `os build`, `STACK_SCHEMA_INVALID` 422) and the metadata save door (422 `INVALID_METADATA`). + - `'full'` on `main` is unchanged, with its page-wide meaning. Every other region accepts the same widths as before. + - The check is exported as `checkPageMainRegionWidth` from `@objectstack/spec/ui`, so a mirror built from `PageSchema.shape` can attach the same rule. + - Data at rest keeps loading: the retired ADR-0087 conversion `page-main-region-width-removed` deletes those widths on read: a stored page row, a compiled artifact inside the window its declared protocol range opens, and a package body registered with its declared range. The page renders exactly as before. + - `@objectstack/mcp` (the Connect an Agent page) and `@objectstack/cloud-connection` (the Cloud Connection page) drop `width: 'large'` from their `main` region. Both render the same. + + ### Migration: FROM → TO + + | Wrote | Write instead | + | :--- | :--- | + | `width: 'small' \| 'medium' \| 'large'` on a region named `main` | drop it; `'full'` stays | + + Deleting the key changes nothing on screen. Write `width: 'full'` only when you want a full-bleed page, which drops the page's max-width cap. +- 71fa166: Clause-②: no + + fix(mcp): the known-partial listing warning and the Connect-an-Agent bundle description say "this system" + + - **What changed.** Inside the running system, user-facing copy names the system the caller is in as "this system". The warning an MCP client receives with a known-partial object listing (the `objectstack://objects` resource and the `list_objects` tool) now says "this system declares at least that many objects, possibly more", and the Connect-an-Agent UI bundle's description ends "for connecting MCP clients to this system". + - **Unchanged.** The `SERVICE_UNAVAILABLE` code that marks a partial listing, every payload shape, the portable `SKILL.md` (which names the deployment an outside agent connects to), and server log lines. +- 4d12b23: Clause-②: no (copy only: no accept set or public surface moves; owes the seat's review of record at `TIER_DEFAULT` before the queue) + + fix(mcp): the Connect-an-Agent page subtitle says "this system" + + - **What changed.** The Connect-an-Agent page header now reads "Give any MCP-capable AI client governed access to this system — every call runs under the caller's own permissions and row-level security." Inside the running system, user-facing copy names the system the caller is in as "this system". + - **Unchanged.** The page's name, layout, components and navigation entries, and every MCP route and payload. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [4840720] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/formula@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index e0a25958073..1c4eb53d73c 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "18.0.0-next.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "ObjectStack as an MCP server — exposes your app's objects (and AI tools) over the Model Context Protocol (stdio + Streamable HTTP)", "type": "module", diff --git a/packages/metadata-core/CHANGELOG.md b/packages/metadata-core/CHANGELOG.md index e5b45de7fc1..c9fc81797de 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,881 @@ # @objectstack/metadata-core +## 18.0.0 + +### Minor Changes + +- 06cc45d: Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). + + **Why.** The catalog is environment-level, and every organization resolves it by name. Measured on a booted `isolated` and `group` stack: an organization owner granted `manage_metadata` inside their own organization saved `PUT /meta/permission/:name` over a permission set another organization's member holds, and that member's next resolution carried the change. One organization's write changed what another organization's members resolve. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for `position` or `permission`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. + - The same doors for `capability`: admitted where they were admitted. `capability` is a code-only type, so these doors already refused its writes before this change; only the sentence the caller reads changes, to the posture refusal. For `capability`, the accept set narrows at the data door on `sys_capability`, below. + - `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. + - A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. This includes an ADR-0090 D12 delegate whose `adminScope` carries `authorEnvironmentSets`: under a wall it can no longer author environment permission sets at the data door. Its assignment and binding paths are untouched. + + **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. + + **The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-tenant-writes-retired`. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. +- 7895671: feat(metadata-core,rest,runtime,plugin-email,spec)!: the `/meta` doors carry no organization; organization-admin metadata authoring closes and `manage_org_presentation` retires (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio belongs to the whole deployment. The `/meta` doors of both transports (`@objectstack/rest` and the runtime dispatcher's `/meta` branch) used to thread the caller's active organization into writes and reads of the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`). They no longer do, for any type, and the read and the write flip together. + + **What changes.** + + - **Writes land environment-wide.** `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` on `/api/v1/meta/:type/:name` hand the protocol no organization, so the row and its audit and history rows carry `organization_id` NULL, whatever the caller's active organization. + - **Reads are environment → code.** The item read (both cache arms), the list, the layered view (`/layers` and `?layers=true`), `/published`, `?state=draft`, `GET /meta/_drafts`, `/history`, `/diff`, `/audit` (the environment rows, `organizationId: null`), `GET /meta/diagnostics` and `/references` name no organization. + - **An organization admin's metadata write is refused.** `metaWriteCapabilityVerdict` admits `isSystem` or `manage_metadata` only. A caller holding `manage_org_presentation` and not `manage_metadata` is answered `403` on all four item doors (`FORBIDDEN` on REST, `PERMISSION_DENIED` on the dispatcher) with `… requires the \`manage_metadata\` capability.`, for every type and whatever its active organization. + - **`manage_org_presentation` retires** from `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`). A permission set naming it still parses and loads, and its other grants still apply; the grant itself admits nothing. The `sys_capability` row seeded for it earlier is not pruned (the seeder upserts only); an operator may delete it in Setup. + - **The email-template boot sweep** (`@objectstack/plugin-email`) reads the effective templates environment → code, as the door serves them; it no longer reads in the Default Organization. + + **What moves for consumers.** + + - FROM `import { organizationIdForMetaWrite } from '@objectstack/metadata-core'` TO nothing: a `/meta` write carries no organization. Delete the call and the `organizationId` it fed. + - FROM `import { ORG_PRESENTATION_AUTHORING_CAPABILITY } from '@objectstack/metadata-core'` TO nothing: delete the import. + - FROM `metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType, activeOrganizationId, operation })` TO `metaWriteCapabilityVerdict({ isSystem, systemPermissions, operation })`: drop the two members. + - FROM `import { metaReadOrganizationId } from '@objectstack/rest'` TO nothing: a `/meta` read carries no organization. `metaCallerOrganizationId` stays. + - FROM `bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy })` TO `{ protocol }`: the `tenancy` source is gone. + - A permission set granting `manage_org_presentation`: grant `manage_metadata` to whoever must author those five types, and delete the stale grant. + + **What a deployment observes.** An overlay row an earlier release stored under an organization stays in `sys_metadata` untouched, and is no longer served by any `/meta` read or projected by the email-template sweep until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Under the `single` posture that is every earlier Studio save of the five types, because it was filed under the Default Organization: on the `/meta` doors such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. + + **What does not change.** Saves of every other type were already environment-wide. Flow saves, the capability gate's answer for `manage_metadata` holders and `isSystem`, the protocol's own organization-scoped refusals, and the `/packages` doors are untouched by this change. +- c8b062f: feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. + + **What changes.** + + - The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. + - `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. + - `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. + - `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. + - [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling instead: the reads do not decline it, a cold boot refuses a deployment that holds one (ADR-0048), and the offline `os migrate security-catalog-overlays` removes it. + - `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. + - Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. + - The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. + - The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | + | `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | + | `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | + | `SysMetadataRepository.listDrafts` row `organizationId` (`@objectstack/metadata-protocol`) | gone: every listed row is an environment draft, whichever repository asks | + | `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | + | `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | + | `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | + | `organizationId` on `projectPermissionMutation`'s `evt` (`@objectstack/plugin-security`) | drop it: the projection reads the environment → code layered read, whatever organization the event names | + | a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | + | an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | + + **What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. +- 117d34d: feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: `sys_view_definition` retires as inert (ADR-0131 D13) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N` like every other stage. + + `sys_view_definition` was declared for runtime-authored shared and personal views (ADR-0017) and never used for them: no framework code writes or reads its rows, and the Studio console's view doors write the ADR-0005 `view` overlay in `sys_metadata` through the metadata API. ADR-0131 D13 retires it. A runtime-authored view is a `view` metadata item, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`); nothing about that path changes. + + **What moves for consumers.** + + - **The object.** FROM `import { SysViewDefinitionObject } from '@objectstack/metadata-core'` (or from `@objectstack/platform-objects`, or its `/metadata` subpath) TO nothing: delete the import. Neither `MetadataPlugin` nor the metadata protocol assembly registers the object any more, so the generic data door no longer serves it. + - **The migration exports** of `@objectstack/metadata-protocol`. FROM `ensureViewDefinitionActiveIndex`, `resolveIndexExec`, `buildActiveIndexSql`, `VIEW_DEFINITION_TABLE`, `VIEW_ACTIVE_INDEX_NAME`, `VIEW_ACTIVE_PROBE_INDEX_NAME`, `VIEW_ACTIVE_INDEX_COLUMNS`, `EnsureViewIndexLogger`, `EnsureViewIndexStatus` and `EnsureViewIndexResult` TO nothing: delete the import. `classifyIndexFailure` and the `IndexExec` type are still exported, unchanged, from the shared index-migration module. + - **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['metadata-core']` (`@objectstack/spec/system`) lists four objects, and `isPlatformProvidedObjectName` answers `false` for `sys_view_definition`. A stack that names it (a lookup target, a flow trigger, a permission entry, a platform-global declaration) is now flagged as naming an unknown object: remove the reference. + - **`os migrate duplicates`.** `runtimeIndexPreflight` carries three entries (the two `sys_metadata` overlay indexes and `sys_setting`'s row identity), with no `idx_sys_view_def_active` entry. A serving boot no longer runs the active-row index migration for the table, and no longer logs its `sys_view_definition active-row index` lines. + - **Translations.** The platform translation bundles no longer carry `sys_view_definition` keys. + + **Existing databases.** Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_view_definition`, with any rows a caller wrote through the generic data door; nothing reads them. `os migrate apply --allow-destructive` does not drop it either, because it reconciles declared objects only (measured on one database: it dropped an orphaned column of a declared table and left this table and its row in place). On a project with a host config, `os migrate plan` lists it among the platform-prefixed tables nothing declares. Export any row worth keeping; dropping the table is the operator's call, by hand: `DROP TABLE sys_view_definition`. +- f4069c5: fix(metadata): the object read's `sortability` follows the caller's field permission for every caller class + + Clause-②: yes (widening) + + - **What was wrong.** ADR-0106 D4 serves a caller holding `manage_metadata`, `studio.access` or `setup.access` (a platform admin, and an organization admin through `setup.access`) the object schema UNMASKED, because Studio and Setup authoring need the whole definition. The `sortability` projection served beside it on `GET /meta/object/:name` (and the runtime dispatcher's `/metadata/object/:name`) was derived from that unmasked document, so it marked a field `sortable: true` that the same caller's field permission marks unreadable, while the data route refuses a sort or filter on that field with `403 PERMISSION_DENIED`. A client building its sort picker from the projection offered a click the server refuses. + - **What changes.** `sortability` is now derived from the caller's runtime view: the served document for every caller the mask projects (unchanged), and for a D4-exempt caller the fields their own field permission reads. The definition (`item.fields`) is unchanged for every class: whole for an exempt caller, masked for everyone else. The data route and `/auth/me/permissions` are unchanged. + - **Caching.** On the cached `GET /meta/object/:name` branch an exempt caller's ETag folds a fingerprint of the fields withheld from their projection, and the conditional request is judged after it is folded. An exempt caller whose permission withholds nothing keeps the byte-identical ETag. An exempt caller and a projected caller denied the same field never share a validator. + - **Failure posture.** The exempt caller's field permission is asked lazily, only by the item read that serves `sortability`, and never turns their definition read into a fault: if the security service throws or cannot answer, `sortability` is derived from the served document as before, with one `warn`. + - **New in `@objectstack/metadata-core`.** `resolveObjectSchemaRuntimeView(document, posture)` returns `{ document, withheld, fingerprint }`, the view the runtime projections beside an object schema are derived from. The types `ObjectSchemaRuntimeView` and `ObjectSchemaRuntimeFieldPosture` are exported with it, and the `passthrough` member of `ObjectSchemaMaskPosture` gains an optional `runtime` (set on an `exempt` posture for a capability-exempt caller in a deployment with a `security` service). + - **New in `@objectstack/rest`.** `translateMetaEnvelope` takes an optional fifth argument, `runtimeDocument` (default: the served document), and `MetaItemAnswerSources.translateEnvelope` an optional third. Nothing is removed, renamed or narrowed. +- 8532783: feat(service-datasource,metadata-core)!: the datasource-admin plugin reads the environment's stored `datasource` rows only; a legacy organization-scoped row is neither loaded at boot nor served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** Graded `minor` on the v18 prerelease line, as the read narrowing it completes. + + **What changes.** `DatasourceAdminServicePlugin` reads stored `datasource` rows in five places — the boot restore (`restoreRuntimeDatasources`), the by-name read behind cluster convergence and the `/meta` door's datasource projection, and the existence probes behind its own create, update and remove. Each now reads the environment's rows only, judged by the same row-value test the metadata reads apply. A row stored organization-scoped by an earlier release: + + - is no longer registered at boot, so the datasource-admin door and the metadata read door no longer serve it, and no live pool is opened from it; + - is never updated or deleted by an admin create, update or remove under its name — those write and remove an environment row of their own; + - stays at rest, and the boot report `[metadata_org_scoped_unserved]` keeps naming it. Nothing is deleted or rewritten; the v18 migration ceremony (`os migrate`, ADR-0131 D10) carries it. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | a legacy organization-scoped runtime datasource a deployment relied on | re-create it through the datasource-admin door or save it through the `/meta` door to make it environment-wide now, or wait for the v18 migration ceremony | + + `@objectstack/metadata-core` exports `isLegacyOrganizationRow`, the one row-value test for such a row; `@objectstack/objectql`'s authored hook and action binders now read it from there, with no change in what they bind. +- 58f5c90: fix(metadata-core): an artifact declaring `engines.protocol: '^18'` that was built during the v18 prerelease boots on a later runtime that retired one of its shapes — the artifact door replays every retired conversion of protocol 18's step for a floor of `18.0.0` + + Clause-②: yes + + **What an operator saw.** A compiled artifact (`os build` → `dist/objectstack.json`) that declares `engines.protocol: '^18'` and was built before a protocol-18 retirement landed carries the retired shape, for example a defaulted key that every earlier parse emitted. Booted on a runtime that enforces the retirement, the metadata plugin's startup refused it at the strict parse and the boot rolled back. The artifact door compared the floor `18.0.0` with each retirement's `retiredAfter` stamp, and every retirement that lands during the v18 prerelease is stamped with the last 17.x release, below that floor, so nothing was replayed. + + **What an operator sees now.** The same artifact boots. One warning per conversion names it and its site count: `[MetadataPlugin] artifact 'LABEL' declares the first release of protocol 18 (authored engines.protocol floor 18.0.0), which a build from any 18.0.0 prerelease declares too, so it may predate retirements in the conversion step into protocol 18 that this runtime enforces (runtime spec VERSION) — converted N site(s) forward via ADR-0087 conversion 'ID' …`. The artifact file itself is unchanged, so it converts again on every boot until it is rebuilt with `os build`. An artifact built after the retirement boots exactly as before: the replay finds nothing to convert, and nothing is logged. + + - **`@objectstack/metadata-core`.** `applyArtifactForwardConversions` opens its window on a third condition. A declared floor of `M.0.0` at or above the runtime label replays every retired entry whose `toMajor` is `M`, except the default flips the door already refuses. An artifact cannot say which of those entries it predates: it carries no field naming the spec or CLI that built it. Each entry rewrites only its own old shape, so the replay changes nothing in an artifact that lacks that shape. `ArtifactForwardConversionVerdict` gains `'converted-major-step'`, so a consumer that switches exhaustively over the verdict adds that arm. `replayedRetirements` still lists only the entries the per-entry rule opened. The rule holds for every 18.0.0 prerelease label and at 18.0.0 itself. From 18.0.1 on, the label half replays the whole chain for such an artifact, as before. + - **`@objectstack/metadata`.** The artifact door places the new verdict on the open side of its window table and gives it its own clause, so the summary never claims that a `^18` artifact "predates" a runtime labelled below it. The bare-root form-predicate notice uses the same window. + + **Unchanged.** The protocol handshake still refuses a `^17` artifact on a protocol-18 runtime and prescribes `objectstack migrate meta --from 17`. A floor above `M.0.0` (such as `^18.0.1`) and a 17.x floor keep their previous verdicts. +- 430e5f5: fix(security)!: under a walled posture every environment-metadata write is the platform operator's, whatever its type (ADR-0131 D12 items 7–8, D11) + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. ADR-0131 D12 item 7: a walled deployment is "one environment, one schema, one catalog … The metadata-authoring capability (`manage_metadata` / `studio.access`) is what headquarters IT holds and plant admins do not." Item 8: a plant "cannot create a position, a permission set, a capability, a template or any metadata … A plant that needs its own catalog is given its own environment." D11: the rules are identical in every posture; only whether the wall is enforced differs. + + **Why.** The environment definition ledger carries no organization column, so a write lands for every organization. Measured on booted `isolated` and `group` stacks before this change: an organization owner granted `manage_metadata` inside their own organization created an object, a view and a flow at `/api/v1/meta`, edited, published, rolled back and reset the operator's view, ran `POST /api/v1/meta/_migrate-stored`, passed all thirteen `/api/v1/packages` write doors, installed, upgraded and uninstalled packages through `install-local`, and created a flow at `/api/v1/automation`, each with `2xx`; another organization's owner read every result. The security catalog was refused at these doors already; every other type was not. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` of `/api/v1/meta/:type/:name` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for a type outside the security catalog (`object`, `view`, `flow`, `app`, `dashboard`, `translation` and every other type): **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher answers `403 PERMISSION_DENIED`). + - Every `/api/v1/packages` write on the runtime dispatcher — create, the manifest `PATCH`, enable, disable, publish, publish-drafts, discard-drafts, revert, rollback, `commits/:commitId/revert`, duplicate, adopt-orphans and delete — for a package carrying no position, permission set or capability: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is read or written. The refusal no longer reads the package's items. + - `POST /api/v1/marketplace/install-local` (install and upgrade) and `DELETE /api/v1/marketplace/install-local/:id` (uninstall) on `@objectstack/cloud-connection`, for a package carrying no catalog item: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`. The refusal's `error.details` no longer carries `catalogItems`; it carries `manifestId`. + - `POST /api/v1/automation`, `PUT /api/v1/automation/:name`, `DELETE /api/v1/automation/:name` and `POST /api/v1/automation/:name/clone`: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the automation service is touched. + - `POST /api/v1/meta/_migrate-stored`, the install-wide rewrite of the stored metadata, preview and apply alike: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` on `@objectstack/rest` and `403 PERMISSION_DENIED` on the runtime dispatcher, before a row is read. + - Every one of these refusals carries one sentence: it names the written type (`a package` at the package doors, `the environment's stored metadata` at the rewrite), the posture and "ADR-0131 D3, D12 items 7–8, D11", and points at an environment of the organization's own. The catalog refusals that shipped before keep their envelopes and now read the same sentence. + - The activation refusals under a wall (`POST /api/v1/automation/:name/toggle`, `POST /api/v1/actions/_activation/…`, `POST /api/v1/security/_activation/permission/…`) no longer recommend cloning a flow or a permission set, or authoring a sibling action, since those writes are the operator's under a wall too; they name the platform operator. + + **Unchanged.** Reads. Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows. The platform operator and the engine's own writes (seeders, installers, migrations) write as before. Under `single` (and with no `tenancy` service), a `manage_metadata` holder writes exactly as before. + + **The remedy.** The platform operator authors the environment's metadata, in Studio or through a managed package; an organization that needs metadata of its own is given an environment of its own. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-environment-metadata-writes-retired`. `walledCatalogWriteVerdict` (`@objectstack/metadata-core`) now judges every type alike: a custom door that writes environment metadata asks it after its capability gate, with no `isSecurityCatalogType` pre-filter. +- 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. +- d050a8f: fix(spec,objectql)!: an installed or stored page carrying a declaration spec 18 retires converts at load instead of stopping the kernel build — `isDefault` off a bound record page is deleted and `template: 'full-width'` re-spelled `default`, each with a named notice; the authoring, save and publish doors keep refusing + + Clause-②: yes (widening) + + **What changes.** Protocol 18 refused `isDefault: true` on a page that is not a record page bound to an object, and closed the page `template` set (retiring `full-width`), at every door — the page parse (`definePage`, `defineStack`, so `os validate` / `os build`, and the metadata save door) and the kernel's package registration (`SchemaRegistry.installPackage` / `registerItem`, `MisplacedDefaultPageError`). The registration refusal also fired when a kernel was built from a package that was installed BEFORE the retirement: every hosted environment holding such a package failed its kernel build, with no remedy short of upgrading the package in each one. Data at rest now converts, and only data at rest: + + | Door | Before | Now | + | :--- | :--- | :--- | + | `definePage` / `defineStack` (`os validate`, `os build`), the metadata save door, package publish | refused at `isDefault` / `template` | **unchanged** — refused with the same sentence | + | a `sys_metadata` page row read back | served with `_diagnostics.valid: false` | converted on read: the flag deleted, `full-width` → `default`, `OS_METADATA_CONVERTED` notice | + | a compiled artifact inside the window its `engines.protocol` opens | the artifact door refused the artifact | converted before the strict parse, as every retired entry is | + | a package body registered through `manifest.register` / `ObjectQL.registerApp` that declares `engines.protocol` (an installed package, a marketplace install) | `MisplacedDefaultPageError`, kernel build stopped | converted before anything registers, with one `warn` per (package, conversion) naming the conversion id and the first site; the body is left unchanged on disk | + | the same body with NO declared range (a manifest a plugin builds in code), or declaring a floor newer than every retirement | refused | **unchanged** — refused | + + **The two ADR-0087 D2 entries**, both `retiredFromLoadPath` from the day they land (the authoring funnel never replays them): + + - `page-is-default-off-record-page-removed` (`page.isDefault`): deletes `isDefault: true` from exactly the pages `misplacedDefaultPageRefusal` refuses — a `home`, `app`, `utility` or `list` page, or a record page with no `object`. Lossless: nothing read the flag there (an app opens on its first navigation item). A default record page bound to an object keeps its flag. + - `page-template-full-width-to-default` (`page.template`): `'full-width'` → `'default'`. Lossless: the console drew both names with one template component. An unregistered template name (`centered`, `two-column`) and a region the template does not place have no lossless rewrite and are NOT converted — they stay with the parse, and the D3 entries `page-is-default-off-record-page-refused` / `page-template-closed-region-unplaced-refused` name the hand edits. + + **`@objectstack/objectql`** — `ObjectQL.registerApp` (and the `manifest` service, for an artifact carrying `packages[]`) replays the versioned forward conversion (`applyArtifactForwardConversions`, `@objectstack/metadata-core`) over a package body that declares its protocol range, before `installPackage`; the converted copy is what the package record and every registered item carry. Public: `ObjectQL.convertPackageBodyForward(body)`. + + **`@objectstack/metadata-core`** — `applyArtifactForwardConversions` reads the declared range off a FLAT package body (the manifest half beside its collections, no `manifest` key) when the definition carries no `manifest`; a definition with a `manifest` object is read there, as before. + + Migration: none for authors — the authoring doors refuse exactly what they refused. An installed package converts again at every registration until it is rebuilt (`os build`) and re-published with tooling from a release that ships the retirement; the boot log's warn names the conversion and the site. + + +- 2ec6827: feat(metadata-protocol): the draft save records `draftChanges` — which surface, actor and instant last changed each key path of a pending draft — and the strict draft read serves it + + Clause-②: yes (widening) + + - **Recorded by the draft save.** `saveMetaItem({ mode: 'draft', surface })` compares the body it writes with the body it replaces, inside the write's own transaction: the draft row, else (the draft's first save) the active row its publish would replace, else nothing, which records `[]`, the whole body. A path is the object keys from the body root to the deepest changed key; an array is one leaf, with no index. Each changed path is stamped `{ by: surface ?? 'api', actor, at }`, where `actor` is the value the save's history event carries (or `null`) and `at` is the write's instant as `toISOString()`. + - **Kept until publish or discard.** No two entries share a path: a save replaces every entry at or under each path it changes, so the longest prefix answers for a key. A save that changes no key path leaves the entries as they were. Publish and discard delete the draft row, and the entries with it, so the next draft starts clean. A `migrate-stored` rewrite and an active save record nothing. + - **Served on the strict draft read only.** `getMetaItem({ state: 'draft' })` answering a stored draft row carries `draftChanges` beside `item`. It is absent on the active read, the cached branch, the `?preview=draft` read, and a draft row whose saves recorded none (one saved before this release) — never `[]`. + - **Stored on the row.** `sys_metadata` gains a nullable `draft_changes` column (`@objectstack/metadata-core`'s `SysMetadataObject`), never inside the stored body. Schema sync adds it to an existing table on boot with no manual step (measured on SQLite and the memory driver). + - **Translated labels.** `@objectstack/platform-objects`' translation bundles carry the new column's label and help text in every shipped locale. + - **An unknown `surface` is refused.** In-process, a value outside `copilot | studio | api` is refused `400 INVALID_REQUEST` before anything is written. + +### Patch Changes + +- 56c8844: `@objectstack/spec/ui` now exports the rule that decides which forms a `view` body opens to anonymous intake, so a console reads "published" from the same rule the server's anonymous form doors serve + + Clause-②: yes (widening) + + - **New on `@objectstack/spec/ui`:** `publicFormSlug`, `anonymousFormIntakeSlug`, `anonymousFormIntakeCandidates`, `anonymousFormIntakeSlugs` and the `AnonymousFormIntakeCandidate` type. They lived only in `@objectstack/metadata-core`, which a browser console should not depend on. They are pure functions with no imports, beside the `SharingConfigSchema` they read. + - **What they decide is unchanged.** A form is open when its `sharing` has `enabled === true`, `allowAnonymous === true` and a non-empty `publicLink`. The scan covers the same three shapes in the same order: the nested `form`, every `formViews` entry, then the `config` of a `viewKind: 'form'` item. + - **`@objectstack/metadata-core` re-exports the same functions** from `@objectstack/spec/ui`. They are the spec's own bindings, not wrappers or copies, so there is still one copy of the rule. Its exports, names and types are unchanged, and `@objectstack/rest` and `@objectstack/metadata-protocol` keep importing from it. Its built output now loads `@objectstack/spec/ui` to get them. + - **Not covered by the new export:** whether another metadata layer withdraws a form (`anonymousFormIntakeWithdrawnIn`), and whether the deployment's tenancy posture lets the form take an anonymous submission (`anonymousFormIntakeUnavailability`). These two read server state and stay in `@objectstack/metadata-core`. `anonymousFormObjectName`, which names the object a form submits into, stays there beside them; it is a pure read of the form and the view, not of server state. A form the new functions call open can still be withheld by a withdrawal in another layer or by the posture. +- 3d886ee: A field can declare cell formatting rules: `conditionalFormatting: [{ condition, style }]`, the rule a list view already declares for its rows + + Clause-②: yes (widening) + + - **`FieldSchema.conditionalFormatting`** (optional) is an ordered list of `{ condition, style }` rules. The first rule whose CEL `condition` holds applies its CSS `style` map to that field's cell, wherever the field renders. A list view's row rules still style the row, and both may apply to one record. Example: `amount: Field.currency({ conditionalFormatting: [{ condition: P\`value < 0\`, style: { color: '#b91c1c' } }] })`. + - **One rule element, no second style vocabulary.** The `{ condition, style }` element moved out of `ListViewSchema` into its own spec module, and both `ListViewSchema.conditionalFormatting` and `FieldSchema.conditionalFormatting` mount it. The list view accepts exactly what it accepted before. Its refusal of an unknown rule key now reads the same on both members: for a `color` written beside `style`, it says to put the colour in `style` first, then points a list view at `rowColor`. + - **The scope is `value` and `record`.** `value` is this field's value on the record and `record` is the row. `@objectstack/lint` judges each condition at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no other root (`previous`, `parent` and the user roots are refused, naming the field and the rule). + - **For presentation only.** Use it for rules that carry no meaning of their own, such as an amount below zero in red or stock under its safety level in amber. A deadline's overdue state stays a declaration (`dueLike` with `settledWhen`). + - **Display only.** Nothing on the write path reads the key, and every metadata that parsed before parses unchanged. Field-level security drops a rule whose condition reads a field the caller cannot read, keeping the other rules in order (`@objectstack/metadata-core`). The SQL driver classifies the key as presentation, so it never reaches column DDL (`@objectstack/driver-sql`). + - **Who reads it.** objectui's cell renderers read the rules in a follow-up release, once this spec version is published. Until then a rule is validated when it is authored and styles no cell. +- 9af0005: The shared build validator (`@objectstack/lint`'s expression rule over `@objectstack/formula`) judges `record..` against an object's declared read attachments (`ObjectSchema.attachedOnRead`) + + - **`@objectstack/lint`.** The field index the expression rule builds now adds each block an object declares under `attachedOnRead` to the names `record.` resolves to, and hands the shared validator the block's declared leaves. A predicate such as `record.viewer.can_act` on an object that declares the `viewer` block is accepted where it was refused as an unknown field. + - **`@objectstack/formula`.** `ExprSchemaHint` gains an optional `attachedOnRead` (block name → declared leaf keys). When a `record.` or `previous.` reference names a declared block, its next segment must be a leaf the block declares. Anything else is refused with the existing `unknown-field` code: `field` is the dotted path as written (`viewer.can_actt`), `suggestion` the nearest declared leaf (`viewer.can_act`), and the new optional `block` and `leaves` params carry the leaves the block declares, which the message names. No new refusal code. + - **`@objectstack/metadata-core`.** The ADR-0106 field-level-security masker classifies the new top-level key as passed through unchanged (`OBJECT_REFERENCE_POSITIONS.attachedOnRead`): a block name and its leaf keys never name a field, so no caller's served object definition loses or gains anything. + - **Unchanged.** An object that declares no block keeps every verdict it had, and a second segment off any other field is not judged. Index access, a method call on a block and a third segment stay unjudged. +- e5687cb: An `email` field can declare that it identifies a correspondent for inbound mail: `mailAssociation` + + Clause-②: yes (widening) + + - **`mailAssociation: boolean`** (optional, no default) on a field definition. TRUE marks this email field as identifying a message correspondent: when a mailbox service ingests a message, it associates the message with every record of the object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Absent or FALSE: the field takes no part in matching. + - **The platform learns which fields carry a correspondent's address, never what a contact is.** Every object that flags a field is treated alike; no app code and no resolver service is involved. + - **The door that judges it.** `FieldSchema` refuses the key on any type other than `email`, naming the field's type and the fix (change the type to `email`, or drop the key). Every metadata that parsed before parses unchanged. + - **Who reads it.** Nothing yet: the mailbox service's ingest is the reader, and it ships separately. Until then `objectstack validate` and `objectstack lint` warn an author who sets it (`liveness-planned-property`) that no mailbox service reads it yet. + - **Where to author it.** The field designer offers it on email fields as "Match Inbound Mail". + - **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies `mailAssociation` as `presentation`: the mailbox service reads it, never `createColumn`, so a declaration on a builtin column name is not reported as discarded storage. No behaviour changes. + - **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` classifies `mailAssociation` as `keep`: the FLS scrub of a served object definition passes the flag through unchanged, because it names no field. + - **`@objectstack/platform-objects`.** The field designer's new row carries its label and help text in the metadata-form translation bundles for en, zh-CN, ja-JP and es-ES. +- 5677add: fix(metadata-protocol): two locales of one email template saved through the metadata door both survive a restart — an overlay row is keyed by its bundle member (#22877) + + Clause-②: no + + **What an administrator sees.** Saving the same email template in two languages through the metadata door (`PUT /api/v1/meta/email_template/:name`, the Studio editor's save) keeps both. Before, the store kept one overlay row per template name, so the second language's save replaced the first language's row: both read back until the next restart, and then the first language mailed the package wording again. Each language now keeps its own wording across restarts, and each send in that language uses it. + + **What changed in the store.** + + - `sys_metadata` gains one nullable column, `bundle_member`. It holds the member of a metadata type whose identity is a pair — today `email_template`, identified by `(name, locale)`. NULL is the canonical member (`en-US`, the locale a send falls back to). Every other row stays NULL, so rows of every other type, and an `en-US` template row, are byte-identical to before. + - The value is taken from the saved body's `locale`; it is never authored. A body that declares no `locale` is the `en-US` member. + - The overlay uniqueness index becomes `(type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, ''))`, for active rows and for draft rows, and the declared index in `metadata-core` carries the column too. A second row for the same template, language, package and state is refused by the index. + + **What a read or a door addresses.** A save addresses the language its body names. A read, delete, publish, history or revert by name that names no language addresses the `en-US` member: `GET /meta/email_template/:name` serves the `en-US` wording (the stored one when it was customized, else the package's), where before it served whichever row the name had. The layered list and the send path serve every language as before. A non-`en-US` language's own customization is reached through the list, and is replaced by a save that names its `locale`; a by-name reset (`DELETE`) removes the `en-US` customization only. The lineage counter stays one per template name, so a version number names one saved snapshot, and a revert by name restores only a version of the `en-US` member. Resetting, publishing or reading a non-`en-US` language by name needs the by-name member selector, which arrives with stage 2 of #22877; until then those doors address the `en-US` member. + + **What happens on the first boot after upgrading.** Nothing to configure. + + 1. The schema sync adds the `bundle_member` column. + 2. Boot hydration gives every `email_template` row stored before the column existed the member its body declares, so a row an earlier save left holding a second language's wording becomes that language's row, not the `en-US` one. Rows of every other type are not read or written. + 3. The first metadata write rebuilds the overlay index with the new key, through the existing probe-then-replace order. Adding a key part only relaxes uniqueness, so no rows a store already holds can block the rebuild; a store that was already blocked is reported as before, with the duplicate-listing query and `os migrate duplicates`. + + **On a database without partial indexes (MySQL).** The rebuild above is refused there, as before, and reported in the server log at the first metadata write. A store that booted an earlier version keeps the four-column declared index under `idx_sys_metadata_overlay_active`, because the schema sync skips an index whose name already exists. That index has never constrained an environment-wide overlay row: those rows store no organization, and a UNIQUE key does not collide NULLs. So two languages of one template coexist there too, and no save is refused. `os migrate plan` lists the declaration change on that index as a destructive `recreate_index`. To bring the index to the five-column declaration, run `os migrate apply --allow-destructive`. A fresh store gets the five-column declaration. + + `os migrate duplicates` boots read-only and adds no column. Run against a store this version has not booted yet, it lists the two overlay indexes with every row read as the empty member — the value the column will hold — so a duplicate that will block the rebuild is still reported, and the entry's `duplicateSql` is the member-less statement that ran there. +- c5b43b6: fix(metadata-core, runtime): a multi-package artifact built before a retirement boots at `os dev -a` and `os start`. The ADR-0087 forward conversion walks every `packages[].manifest` body, and the runtime's artifact load converts the bundle once, before any reader sees it + + Clause-②: no + + **What an operator saw.** A multi-package release artifact (ADR-0130 D4: `os build` of a `composeStacks(…, { manifest: 'preserve' })` project) carries its collections in `packages[i].manifest`. Take such an artifact built before a retirement, for example with a dashboard still spelled `refreshInterval`. `os dev -a dist/objectstack.json` and `os start` exited at boot with "`packages[1]` is not a package entry (ADR-0130 D4): manifest.dashboards.0.refreshInterval: `dashboard.refreshInterval` was renamed to `refreshIntervalSeconds`…". The metadata plugin's artifact door refused the same artifact at its strict parse. The ADR-0087 conversion converted that dashboard when it sat in the artifact's own `dashboards`, but it never read a package body, and the runtime read the bundle raw before any conversion ran. + + **What an operator sees now.** The same artifact boots at `os dev -a` and at `os start`. Each body's items are served converted (`refreshIntervalSeconds: 300`, valid), each owned by its own package. The boot log names each conversion once, with the body it found it in: `[MetadataPlugin] artifact '…' … converted 2 site(s) forward via ADR-0087 conversion 'dashboard-refresh-interval-to-refresh-interval-seconds' (first at packages[0].manifest.dashboards[0].refreshIntervalSeconds) …`. The artifact file itself is unchanged, so it converts again on every boot until it is rebuilt with `os build`. A body built after the retirement converts nothing and logs nothing. An artifact authored past every retirement is still refused with the tombstone inside its body. + + - **`@objectstack/metadata-core`.** `applyArtifactForwardConversions` runs the same conversion over each package body as over the artifact's own collections. It uses the same registry and the same window, decided once from the artifact's own `engines.protocol`; a body's own `engines.protocol` is not read. A notice from a body is located from the artifact root (`packages[1].manifest.…`). + - **`@objectstack/runtime`.** `loadArtifactBundle` converts the loaded artifact once, before it merges the runtime module and before the stack, `AppPlugin` or the `manifest` service read it. The database resolver reads the artifact file itself, and it runs the same conversion on what it reads. So a multi-package artifact that declares its default datasource in a package body opens that database, not the unified default. + + **Unchanged.** An artifact without `packages[]` converts exactly as before, with the same notices, paths and messages, and it is served the same way. +- 8c10776: fix(metadata-protocol): a stored-metadata migration records the user who ran it, or nobody, never a label + + Clause-②: no + + - **What was wrong.** The stored-metadata migration pass (`os migrate meta --stored`, `POST /api/v1/meta/_migrate-stored`, the runtime dispatcher's `POST /metadata/_migrate-stored`, and `migrateStoredMetadata()` called in process) recorded a label as the writer of every row it rewrote: `'os migrate meta --stored'`, `' (POST /api/v1/meta/_migrate-stored)'`, `'POST /api/v1/meta/_migrate-stored'`, or `'migrate-stored'`. The writer lands in `sys_metadata_history.recorded_by` and in the row's `updated_by`. Both columns are references to `sys_user`, so the label pointed at no user. Nothing refused it, because metadata writes run as the system. + - **What it does now.** The pass records the id of the user who ran it, exactly as every other metadata write does. A REST or runtime call by a signed-in user with `manage_metadata` records that user's id. A run with no user records `null`, which means "system-initiated". That covers the CLI, which opens the database directly with no session, and a system caller. The pass still names itself on each history row's `source` (`migrate-stored`), as it did before. + - **What an operator sees.** History rows and rewritten rows written by a migration from this release on name the person who ran it, or nobody. They never name a command or a route. Rows a migration rewrote before this release keep the label they were given. + - **Contract text.** `MetadataItem.authoredBy`'s description now gives the same rule as `PutOptions.actor`: the acting user's id, or `null` for a system-initiated write, never a label such as `"system"` or `"cli"`. The schema accepts the same values as before. +- 105902f: feat(spec)!: retire the view form's `visibleOn` and the page component's `visibility` as keys; their ADR-0089 renames leave the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** ADR-0089 made `visibleWhen` the one conditional-visibility key, and through 17 the old spellings were declared `[DEPRECATED]` keys that the parse folded onto `visibleWhen` without a word: `visibleOn` on a form view's fields and sections, and `visibility` on a page component. At 18 all three positions carry a `retiredKey()` tombstone naming `visibleWhen`, the parse-time fold is gone, and the two ADR-0087 D2 conversions `view-visibleOn-to-visibleWhen` and `page-component-visibility-to-visibleWhen` (protocol 15) are retired from the load path (`retiredAfter: '17.7.0'`). + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `visibleOn: "…"` on a form field (`view.form.sections[].fields[]`) | `visibleWhen: "…"` | + | `visibleOn: "…"` on a form section (`view.form.sections[]`) | `visibleWhen: "…"` | + | `visibility: "…"` on a page component (`page.components[]`, slots and nested containers included) | `visibleWhen: "…"` | + + **The one-line fix:** rename the key to `visibleWhen`; the CEL predicate is unchanged. Where a node wrote both keys, `visibleWhen` was the one in effect: delete the old key. + + **What an author who still writes it sees.** `tsc` fails at a typed authoring site (`FormFieldInput`, `FormSection`, `PageComponent` type the key as retired), and the parse refuses it by name at its own path — `defineStack` throws `STACK_SCHEMA_INVALID` (422), `os validate` / `os build` print the issue, and the metadata save door answers 422: + + > `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. + + **No tool makes the rename.** Both conversions belong to protocol 15, below the migration floor, so `os migrate meta --from 17` applies no edit for either key and `--write` writes none: it prints each written key among the refusals the migrated stack still has, and lists the D3 entry `ui-visibility-alias-keys-retired` as the hand edit. + + **Stored metadata needs no edit.** Stored-row rehydration and the artifact-ingestion door inside its window still replay both conversions, so a `sys_metadata` view or page row written with the old key keeps loading with `visibleWhen`. A script that saves a view or page through the metadata API with the old key is refused and needs the same rename. + + ## The retirement kit + + - **Tombstones** on `FormFieldSchema`, `FormSectionSchema` and `PageComponentSchema`; `RETIRED_KEYS_BY_MAJOR[18]`: `ui/FormField:visibleOn`, `ui/FormSection:visibleOn`, `ui/PageComponent:visibility`; the authorable-surface rows read `[RETIRED]`. + - **The fold is gone.** The three shapes are plain closed objects again (no `.transform` pipe): a section's group-reference rule no longer lists `visibleOn` (the tombstone answers it first), and a slotted page node's `type` refusal now arrives at its own path instead of nested in an `invalid_union`. + - **The D3 entry `ui-visibility-alias-keys-retired`**, one for both keys. + - **No deprecation window**, per the project's startup-stage posture. + + `@objectstack/lint`: the visibility-predicate rules (`visibility-root-mislayered`, `visibility-bare-identifier`, `visibility-predicate-syntax`) and the predicate-path rules read `visibleWhen` only. `os validate`, `os build`, `os verify`, `defineStack` and the metadata save door parse first and refuse either retired key by name, so a body they judge cannot carry one. `os lint` is the one door that hands these rules an unparsed stack: there a predicate written under a retired key is not judged, by design — the parsing doors refuse the key itself, and `os lint` reports no refused shape. + + `@objectstack/metadata-core`: `detectUnboundFormViewPredicateRoots` reads `visibleWhen` only. Its caller scans an artifact after `applyArtifactForwardConversions`, which renames a legacy `visibleOn` first. + + Not part of it: `normalizeVisibleWhen` and `VISIBILITY_ALIAS_KEYS` stay exported (no schema here calls them any more); objectui's renderer reads of the old keys leave on the objectui side. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + +- 8776119: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + feat(spec): `referenceDeletedWhen` beside `referenceVia` marks a polymorphic pointer's target as platform-deleted + + Clause-②: yes + + `FieldSchema` gains one optional key beside `referenceVia`: `referenceDeletedWhen: { field, value }`. On rows where the sibling `select` field `field` holds the option `value`, the record the pointer names was deleted by the platform's own assertion, and a renderer draws a tombstone from that option's own label instead of a link. Example: `record_id: Field.text({ referenceVia: 'object_name', referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' } })`. + + Any other shape is refused at parse, naming the field and the option: + + - the key on a field that declares no `referenceVia` (`FieldSchema`), or an unknown key inside it; + - a `field` that is the declaring field itself, is not declared on the object, is not a `select`, is a `multiple: true` select, or declares no inline `options` (a `picklist`-backed select); a `value` that is not one of the sibling's option values (`ObjectSchema`, at every door: `ObjectSchema.create()`, `defineStack`, raw `.parse()` and the metadata save door). + + Nothing that parsed before is refused: the key is new. Its liveness row is `planned` until the renderer that reads it ships. + + The other packages classify the new key where every field key is classified: + + - **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` deletes a served field's `referenceDeletedWhen` when its `field` names a field the caller cannot read (field-level security); `value` is an option value and never a field name. + - **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies it `presentation`: no DDL reads it. + - **`@objectstack/platform-objects`.** The field form's new `referenceDeletedWhen` row carries its labels and help text in zh-CN, ja-JP and es-ES. +- 30ce56e: Clause-②: no + + Setup pages describe their records in plain admin sentences. Business Units now reads "Your + company structure — divisions, departments, regions and offices — used to scope data and + reports.", Notification Events "Every notification the platform sent, one row per event.", + Notification Preferences "Which topics and channels each user receives; admins set the + defaults.", Notification Templates "The subject and body each notification topic sends, per + channel and language." and HTTP Deliveries "Outgoing HTTP calls made by webhooks and flows, one + row per call, with its delivery status." Field help on Users, Organizations, Invitations and SSO + Providers, and the metadata-storage and notification-delivery object descriptions, no longer + carry design-record numbers; those references stay in the code comments. The zh-CN, ja-JP and + es-ES translations follow. Wording only: no field, permission or behaviour changes. +- df2c551: fix(metadata-core): the package installs on npm 10 again, and into projects that use vitest 5, because it no longer declares an optional `vitest` peer + + Clause-②: no + + On npm 10, the npm that Node 22 bundles, `npm install @objectstack/metadata-core` exited 1 with `Cannot read properties of null (reading 'edgesOut')`. So did installing a package that depends on it, such as `@objectstack/objectql`. The cause was this package's optional peer `vitest: ^4.0.0`. It led npm 10 to vitest 4.1.12, whose own peer declarations contradict each other, and npm 10's resolver crashes on that. The same peer also made npm refuse (`ERESOLVE`) to install the package into a project that already uses vitest 5. + + The package's main entry never imports `vitest`. Only the test-only `@objectstack/metadata-core/testing` entry does, so the peer is removed. Nothing the package exports changes. + + If you import `@objectstack/metadata-core/testing`, list `vitest` in your own `devDependencies`, as that entry's documentation already says. This package runs that suite on vitest 4. In a pnpm workspace with hoisting turned off (`hoist=false`), that entry can no longer find `vitest` from inside a workspace package. Declare the peer back in your `pnpm-workspace.yaml`: + + ```yaml + packageExtensions: + '@objectstack/metadata-core': + peerDependencies: + vitest: '*' + ``` + + `@objectstack/plugin-auth` still fails the same way on npm 10, because its `better-auth` dependency declares its own optional `vitest` peer. Install it with npm 11, or add `"overrides": { "vitest": "~4.0.0" }` to your project's `package.json`. +- adad73f: feat(spec)!: a select option declares its lifecycle `kind` (`open` / `won` / `lost`), and every option of an object's `stageField` must declare it + + Clause-②: yes (narrowing) + + `SelectOptionSchema` gains one optional key, `kind`, a closed vocabulary of three lifecycle categories that means the same on every object: `open` (a step on the way), `won` (the successful end: a deal's Closed Won, a lead's Converted, a task's Done, a case's Resolved) and `lost` (the unsuccessful end: Closed Lost, Unqualified, Cancelled). It is the spelling `record:path` `stages[].terminal` already takes, so the data layer and the page say one thing one way. Example: `{ label: 'Closed Lost', value: 'closed_lost', kind: 'lost' }`. The exported type `SelectOptionKind` names the three values. + + - On a plain single-value `select` / `radio` the key is optional, and an absent kind reads as `open`. Nothing is written in at parse: an option without `kind` parses without it. + - On any other field's options (`multiselect`, `checkboxes`, `tags`, a `multiple: true` select, a lookup's static options) `FieldSchema` refuses it at `options..kind`: a lifecycle is one value per record. + - A shared `picklist`'s options accept it (the same option shape). A form view's option list does not (`FormSelectOptionSchema`): it is refused there as an unknown key whose message points at the object field's own option. + + **BREAKING (authoring):** when an object's `stageField` names a field, every option of that field must declare `kind`. `ObjectSchema` refuses the object otherwise, at every door that parses one (`ObjectSchema.create()`, `defineStack`, `os validate`, `os build`, the metadata save door), with one issue per option at `fields..options..kind` naming the field and the option. There is no default under `stageField`. Also refused, at `stageField`: a stage field that takes its options from a `picklist` (the object cannot see them to check), and a stage field carrying options that holds no single option code per record (`multiselect`, a `multiple: true` select). Unchanged: `stageField: false`, a `stageField` naming no declared field, and one naming a field with no options. + + Migration: add `kind` to each option of the stage field, marking each terminal state `won` or `lost` and every other state `open`: + + - `status: Field.select({ options: [{ label: 'New', value: 'new' }, { label: 'Converted', value: 'converted' }, { label: 'Unqualified', value: 'unqualified' }] })` with `stageField: 'status'` → the same options with `kind: 'open'`, `kind: 'won'` and `kind: 'lost'`. + - A picklist-backed stage field → declare its options inline on the field. + - An object with no linear lifecycle → `stageField: false`. + + No conversion makes this edit, because which option ends a record's life successfully is the author's to say. `os migrate meta` lists the D3 entry `stage-field-option-kind-required` with the prescription. + + The Studio object designer's options repeater offers `kind`; the field form's options repeater lists it. + + The other packages follow the new key: + + - **`@objectstack/platform-objects`.** The metadata-form translation bundles (`./metadata-translations`) carry the options repeater's new `kind` row, with its label and help text in zh-CN, ja-JP and es-ES. + - **`@objectstack/metadata-core`.** The published contract fixture `FLS_CONTRACT_OBJECT` (`@objectstack/metadata-core/testing`) names `salary_grade` as its `stageField`, so its two options now declare `kind: 'open'`, and the fixture meets the stage-field rule: `ObjectSchema` raises no issue at `stageField` or under `fields.salary_grade.options`. The fixture is not otherwise a spec-valid object (its `script` actions carry no `body` or `target`), and this change leaves that as it was. + + +- 077b75f: fix(metadata-core): the protocol handshake reads an `engines.protocol` `||` union, so `'^17.7.0 || ^18.0.0'` loads on protocol 18 instead of being refused as incompatible + + Clause-②: no + + - **What was wrong.** `rangeAdmitsMajor` had no branch for `||`. A union reached the caret branch and was read as a caret on its first alternative, so `'^17.7.0 || ^18.0.0'` answered `OS_PROTOCOL_INCOMPATIBLE` on protocol 18. Every door that runs the ADR-0087 D1 handshake refused it: `sys_packages` boot rehydration, the marketplace install-local POST and rehydrate, and `protocol.installPackage`. + - **What it does now.** A union admits a major when any alternative admits it. When none does and any alternative is unrecognized (an empty one included, `'^17 ||'`), the union answers `unparsed-range`: the package loads with the existing `[protocol]` warning and is never refused. A union whose every alternative refuses is refused as before. Each alternative is read with the unchanged single-range grammar (caret, tilde, hyphen, comparators, `x`, `*`), so a range without `||` reads exactly as it did. + - **The refusal's `targetMajor`.** For a refused union it is the lowest alternative's major, whatever order the alternatives are written in, so `'^17 || ^16'` and `'^16 || ^17'` both name `objectstack migrate meta --from 16`. It used to read the first alternative. + - **The artifact forward-conversion floor.** `parseRangeFloor` reads a union's floor as its lowest alternative's (`'^18.0.0 || ^17.1.0'` → `17.1.0`, as for `'^17.1.0 || ^18.0.0'`), and `null` when an alternative is unreadable. It used to read the first alternative, so a union written high-first opened a narrower conversion window than the same range written low-first. +- 31e7d0b: feat(spec): an object can declare its default row scope, `defaultRowScope`, the row-level security policy shape without `object` + + Clause-②: yes (widening) + + `ObjectSchema` gains one optional key, `defaultRowScope`: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object`, which the declaring object implies. The shape is exported as `DefaultRowScopeSchema` from `@objectstack/spec/security` (types `DefaultRowScope` and `DefaultRowScopeParsed`). + + - **What it parses.** Every member of `RowLevelSecurityPolicySchema` except `object`, the same member schemas: `name`, `label`, `description`, `operation`, `using`, `check`, `positions` and `enabled` (default `true`). The policy's two rules hold as written: at least one of `using` / `check`, and no `check` on a `select` or `delete` policy. + - **What it refuses.** `object` is refused by name, with a prescription to delete it: the target is implied. Any other unknown key is refused too, the policy's retired `priority` and `tags` included. + - **What it does.** The security plugin's row-policy collection (`collectRLSPolicies`), the one step every read, write and explain passes through, applies the key: when the permission sets the caller holds carry no row policy for the object and the operation (a `'*'` policy counts; one switched off, outside the caller's positions, or for another operation does not), the object's default is used in their place and judged like a held policy. A policy an authored set carries for the object is used as authored, with the default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object without the key keeps its behaviour. A `using` the row-filter compiler cannot lower makes the reads it governs return no rows, with one server warning. The platform's identity objects are to declare it for their own tables in a separate change. See the `@objectstack/plugin-security` entry in this release for the reader. + - **`composeStacks` with `objectConflict: 'merge'`** now refuses two stacks that declare a different `defaultRowScope` on the same object, the way it refuses every other fixed-shape config object (`access`, `enable`, `publicSharing`, …). The refusal message's list of those objects names `defaultRowScope` too. Nothing composed before is affected, because no stack declared the key. + - **`@objectstack/metadata-core`: the field-level-security projection classifies the new key.** When an object document is served to a caller who cannot read some of its fields, `defaultRowScope` is treated like a validation rule: the whole policy is dropped from the served document when its `using`, `check` or prose names a field that caller cannot read, and served unchanged otherwise. No behaviour moves until an object declares the key, and no object declares it yet. + - Additive: every object that parsed before still parses, and its parsed output is unchanged. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0 + ## 18.0.0-next.5 ### Minor Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index 7c5fef91cbb..96e855b4194 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Metadata Repository contracts: types, canonicalization, errors, interface (ADR-0008).", "type": "module", diff --git a/packages/metadata-fs/CHANGELOG.md b/packages/metadata-fs/CHANGELOG.md index d3e56cc9526..9c379d93d4e 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,46 @@ # @objectstack/metadata-fs +## 18.0.0 + +### Patch Changes + +- a3d47c2: fix(metadata-fs, runtime): metadata writes record the acting user's id or nobody, never the label `'fs'`, and a signed-in save through the runtime dispatcher records the user + + Clause-②: no + + - **What was wrong (filesystem repository).** `FileSystemRepository` named the label `'fs'` as the writer whenever no user was given. That covered every edit it saw on disk, every file it found that no recorded write produced, and an actor-less `put()`, which read back as `'fs'`. `MetadataPlugin` attaches the repository without naming a user, so this happened on every host that boots it. The contract for these fields (`PutOptions.actor`, `MetadataItem.authoredBy`, `MetadataEvent.actor`) is the acting user's id, or `null` for "nobody". + - **What it does now.** With no user named, the repository records `null`. A write that names a user records that user, and a write that records `null` reads back as `null`. The `fsActor` option keeps its type. When it is set, it is the user id reported for changes the repository did not write itself, and it no longer replaces a writer that a write recorded. Every event still names its origin on `source: 'fs'`, as before. + - **What was wrong (runtime dispatcher).** `PUT /api/v1/meta/:type/:name` served by the runtime dispatcher (the `@objectstack/hono` catch-all) saved without the caller's identity, so a signed-in author's save recorded `null` in `sys_metadata.updated_by` / `created_by` and `sys_metadata_history.recorded_by`. `RestServer`'s route recorded the user. + - **What an operator sees.** Filesystem-backed metadata history and events from this release on no longer name `'fs'` as the writer. A save through the runtime dispatcher records the signed-in user's id, as the REST route does, and a system caller with no user records `null`. Entries written before this release keep what they recorded. +- Updated dependencies [06cc45d] +- Updated dependencies [7895671] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [56c8844] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [9af0005] +- Updated dependencies [8532783] +- Updated dependencies [e5687cb] +- Updated dependencies [5677add] +- Updated dependencies [58f5c90] +- Updated dependencies [c5b43b6] +- Updated dependencies [8c10776] +- Updated dependencies [430e5f5] +- Updated dependencies [dd29bbe] +- Updated dependencies [105902f] +- Updated dependencies [c2bb302] +- Updated dependencies [8776119] +- Updated dependencies [30ce56e] +- Updated dependencies [df2c551] +- Updated dependencies [adad73f] +- Updated dependencies [d050a8f] +- Updated dependencies [077b75f] +- Updated dependencies [31e7d0b] +- Updated dependencies [2ec6827] +- Updated dependencies [ef1fcb2] + - @objectstack/metadata-core@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index e16d09067fe..1cdbc65a552 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "FileSystemRepository: Node-only Repository implementation backed by JSON files and a JSONL change log (ADR-0008).", "type": "module", diff --git a/packages/metadata-protocol/CHANGELOG.md b/packages/metadata-protocol/CHANGELOG.md index 1a9f3948f15..e6dd17ea3e6 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,2682 @@ # @objectstack/metadata-protocol +## 18.0.0 + +### Major Changes + +- e23eccb: feat(spec,metadata-protocol)!: the metadata type registry's `allowOrgOverride` is renamed to `allowEnvironmentOverlay`, on the registry and on `GET /meta/types` + + Clause-②: yes (narrowing) + + + + **BREAKING**: a renamed authorable key and a renamed response field, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0131 D6 retired the per-organization overlay axis: every metadata write and read is environment-wide. The registry flag kept the name that said "per-org" while it decided one environment-scope question at every door — whether an environment may overlay an item of the type that a managed package ships: the metadata protocol's save door, the managed-content seal on write and on the served read, the repository's put gate, and Studio's write gate through `GET /meta/types`. It is renamed to say that. One key, not two, and not the old name with a new meaning; no window in which both spellings are accepted or served. + + ## FROM → TO + + | where | you wrote / read | write / read instead | + |:--|:--|:--| + | a `DEFAULT_METADATA_TYPE_REGISTRY` entry (`MetadataTypeRegistryEntry`, `@objectstack/spec/kernel`) | `allowOrgOverride: true` | `allowEnvironmentOverlay: true` | + | a reader of a registry entry | `entry.allowOrgOverride` | `entry.allowEnvironmentOverlay` | + | each `entries[]` item of `GET /api/v1/meta/types` (`GetMetaTypesResponse`) | `allowOrgOverride` | `allowEnvironmentOverlay` | + + **The one-line fix: rename the key, and every read of it; the value is unchanged.** + + Every value is unchanged: `true` on `view`, `dashboard`, `report`, `translation` and `email_template`, `false` on every other built-in type. On the wire the value is still the registry's grant OR the `OS_METADATA_WRITABLE` hatch naming the type (`overrideSource: 'env'`), and the hatch still opens only items no managed package ships, never a managed one. `overrideSource` keeps its `registry` / `env` values; its describe now names the new field. + + ⚠️ **Read the old key nowhere.** Writing `allowOrgOverride` fails to compile (its input type is `never`) and fails to parse with the rename prescription (issue code `invalid_type` at path `allowOrgOverride`). A READER does not fail: `if (!entry.allowOrgOverride)`, a filter on it or a partial-match assertion still compiles against the tombstone and reads `undefined`, which treats every type as closed to environment overlays. A client that declares its own type for the `/meta/types` response, instead of importing `GetMetaTypesResponse`, sees no compile error at all and reads every type as locked. Search for the old name, do not wait for `tsc`. + + **`supportsOverlay` keeps its name; its describes now say what it gates.** It decides one thing: a stored overlay row of an item a managed package ships may be removed through the ordinary delete door without the type's environment-overlay permission: the removal removes no managed definition, and changes what is served for `permission` alone. It opens no save and it decides no read. The registry's describe, the `GET /meta/types` describe and `MetadataTypeInfoResponse`'s describe used to promise that "the loader can merge overlays", in the retired per-organization wording. + + **Who is affected, measured.** No manifest, stack, plugin configuration or stored row can carry a registry entry (`contributes.kinds` is closed, `config.additionalTypes` is a tombstone, the registry is a TypeScript literal), so no authored metadata changes. The readers are code: every reader in this repository moved in this change, and a reader of the `/meta/types` field outside it moves with it — the Studio console reads the field and moves in its own repository's paired change, which the console pin carries. + + ### The kit + + - **The tombstone.** `MetadataTypeRegistryEntry.allowOrgOverride` is a `retiredKey()` naming `allowEnvironmentOverlay` (`kernel/metadata-plugin.zod.ts`); the 28 built-in rows spell the new key. + - **The ledger.** The retired-keys entry `kernel/MetadataTypeRegistryEntry:allowOrgOverride` and the D3 semantic entry `metadata-type-registry-allow-org-override-renamed` (protocol 18), which records the registry key and the wire field together. No D2 conversion: there is no authored or stored source a conversion could rewrite. + - **The wire.** `getMetaTypes` writes the hatch elevation onto `allowEnvironmentOverlay` on both of its arms — a registry type and a runtime-registered type with no registry row — and no entry carries `allowOrgOverride`. + - **The refusal sentences** that named the flag name the new key: `… takes no environment overlay (allowEnvironmentOverlay=false) …`, `… has neither allowEnvironmentOverlay nor allowRuntimeCreate …`, `… allowRuntimeCreate=false and allowEnvironmentOverlay=false …`. Codes and statuses are unchanged. + +### Minor Changes + +- 1e8b628: feat(spec,metadata-protocol)!: the `/meta` door no longer authors sharing rules — `sharing_rule` is a code-only metadata type; Setup lists declared sharing rules + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D6, regime C). + + A sharing rule has two sources: a package declares it (the stack's `sharingRules`, switched off through the activation ledger), or an organization authors its own (`sys_sharing_rule`). A runtime metadata item was a third, and it bypassed the same-name guard and the organization wall. The narrowings, FROM → TO: + + - `PUT /api/v1/meta/sharing_rule/:name`, with a name nothing declares, in publish or `?mode=draft` — FROM `200`, with an environment-wide item stored and served by `GET /api/v1/sharing/rules`, TO `403 NOT_CREATABLE`, nothing stored. With a declared rule's name — TO `403 NOT_OVERRIDABLE`. + - `GET /api/v1/meta/types` — FROM `sharing_rule` advertised `allowRuntimeCreate: true`, TO `allowRuntimeCreate: false` and `allowEnvironmentOverlay: false`. + + **The fix:** author an organization rule, or clone the declared one. `POST /api/v1/sharing/rules` (Setup > Sharing Rules) creates an organization rule. To customize a declared rule, copy it to an organization rule under a new name, then switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/:name`, body `{ "enabled": false }`. A package declares its rules in the stack's `sharingRules`, as before. An item stored through the old door is still served until you remove it with `DELETE /api/v1/meta/sharing_rule/:name`. On an environment kernel, removing it needs `OS_METADATA_WRITABLE=sharing_rule`. That hatch is an explicit operator opt-in, not an authoring route: as for any code-only type, it reopens every write on the type, including creates of new names, and a sharing rule grants access. Unset it after the removal. + + Also in this change: + + - **`@objectstack/spec`.** `MetadataTypeSchema` (so the `MetadataType` union) and `DEFAULT_METADATA_TYPE_REGISTRY` gain `sharing_rule`. Its schema binding (`SharingRuleSchema`) moves from the non-kind collections into the kind map. `META_URL_TO_SINGULAR` gains the `sharing_rules` spelling, so `/api/v1/meta/sharing_rules` now folds to `sharing_rule`; before, that plural was not a recognised spelling. + - **`@objectstack/metadata-protocol`.** The refusal names the two sanctioned paths, not a source glob. `sharing_rule` gains its ADR-0126 Regime C row (clone, and the ledger switch). + - **`@objectstack/plugin-sharing`.** Setup's Access Control group gains **Declared Sharing Rules** (`nav_declared_sharing_rules`). It opens `/apps/setup/metadata/sharing_rule?scope=environment` beside **Sharing Rules**, which lists the organization's own rules. + - **`@objectstack/platform-objects`.** That entry's label, in `en`, `zh-CN`, `ja-JP` and `es-ES`. +- 34dba5a: Every seed row of an object that carries an organization is stamped with the install's organization, platform-namespace seeds included, or the seed loader refuses the row + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3, D9). + + - **The exemption is withdrawn.** When a seed load names no organization (`config.organizationId`), the loader stamps the install's sole organization on every row of an object that carries an `organization_id` column. Seeds of `sys_`, `cloud_` and `ai_` objects used to be exempt as "intentionally global" and landed NULL. There are no platform-global seeds left, so they now carry the organization too: a seeded `sys_business_unit` is the organization's own business unit. + - **No owner, no row.** When the load names no organization and the install holds none, or holds several, a row of an object that carries an `organization_id` column is refused, counted in the result's errors and named in the message. Nothing of it is written. Before this change those rows were written NULL. A row that sets its own `organization_id` still loads. + - **No column, no stamp.** A row of an object with no organization column (`tenancy: { enabled: false }`, or a platform object whose injected column was dropped) is written without one, whether the organization was named by the load or derived. Before this change such a row was stamped anyway, and the engine refused it as an unknown field, so the row was lost. + - **Unchanged.** A load that names its organization (the per-organization replay under a walled posture) stamps it on every row that carries the column, as before. A composition that registers no organization object at all keeps loading its seeds unstamped. Rows written before this change keep their bytes; attributing that residue is ADR-0131 C7's. + + **The remedy.** Under the `single` posture the Default Organization exists before seeds load, so a boot seed always has its owner. A load that is refused names the organization it needs: pass it as `config.organizationId` (the organization the seed populates on a walled deployment), or set `organization_id` on the record. +- c8a9783: feat(plugin-security)!: no seeder or materializer writes a `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and the `permission` publish materializer are deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `SecurityPlugin` no longer writes a `sys_permission_set` row for any permission set: not for the platform defaults (`admin_full_access`, `member_default`, …), not for a set a package declares, not for an organization's copy, and not on a metadata reload or an organization's creation. Permission sets are served by the security catalog (ADR-0131 D3), and since the grant names its set (`sys_user_permission_set.permission_set`), no grant path needs a row. A fresh database still promotes its first platform administrator, grants organization administrators and self-registered members, and grants every principal what it was granted before. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. Until a later stage retires the ADR-0094 projector, publishing a permission set (a package-door publish, or a writable base's direct publish) still has it write a projection row, `managed_by: 'admin'` with no `package_id` (a package-door publish wrote `managed_by: 'package'` before this release); no authorization path reads it, and uninstalling the package removes it. + + - **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. + - **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. + - **Uninstalling a package still revokes every grant of its permission sets** (ADR-0090 D5, "no ghost grants"). With no row standing for a package's set, both uninstall doors (`deletePackage` and `DELETE /api/v1/marketplace/install-local/:id`) capture the set names the package held just before they withdraw it from the registry, and the `security.package-permissions` cleanup revokes every `sys_user_permission_set` grant naming them. The withdrawal still runs before the cleanups. A grant naming such a set would otherwise survive the uninstall and be live again on reinstall. Grants written before the name column, and position bindings, are still reached through a legacy `sys_permission_set` row where an already-seeded database holds one. + - `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once. + + Added (additive, nothing an author writes changes): + + - `@objectstack/metadata-protocol`: the optional `declared?: UninstallDeclared` argument of `UninstallCleanup` (and of `runUninstallCleanups`' request), the `UninstallDeclared` type, and `captureUninstallDeclared(registry, packageId, stored?)`, the one capture both doors call. A cleanup that ignores `declared` behaves as before. + - `@objectstack/objectql`: `SchemaRegistry.securityCatalogNamesHeldBy(packageId, type)`. + - `@objectstack/plugin-security`: `cleanupPackagePermissions` takes an optional fourth argument, the captured names. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `bootstrapDeclaredPermissions` + - `normalizeManagedByVocab` + - `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` + - `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` +- e02833c: feat(metadata-protocol)!: managed content is sealed — `OS_METADATA_WRITABLE` no longer opens a write onto, or a removal of, an item a managed package ships (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6: no door edits a managed definition. The operator hatch `OS_METADATA_WRITABLE` (and its legacy spelling `OBJECTSTACK_METADATA_WRITABLE`) used to open one: an item a managed package ships, of a type whose registry entry allows no environment overlay, could be overlaid and its overlay row removed once the type was named in the variable. Measured on the CRM example with `OS_METADATA_WRITABLE=flow,object,permission,position`, a shipped flow, an object field and a position each took an overlay row, `PUT /api/v1/automation/:name` re-registered the shipped flow, and `DELETE /api/v1/meta/object/crm_lead?dropStorage=true` took the managed object off the data plane. + + **What changes.** With the hatch open or shut, the metadata doors now answer the same thing for an item a managed package ships: `403 NOT_OVERRIDABLE` on a write, and on a removal of a type whose overlay does not merge at read. The protocol's two package doors (`PUT` / `DELETE /api/v1/meta/:type/:name`, and the `/automation` definition doors that ask them), the repository's write path (draft promotion, restore, revert) and the read envelope (`editable` / `deletable`) all read one predicate: the item is artifact-backed and its type's registry entry opens no overlay channel. + + - The refusal's first sentence names the managed package ("… is provided by a managed package and is sealed …"). A type with an ADR-0126 regime row (`flow`, `action`, `permission`) still names its sanctioned route, the clone or the switch. Every other type now reads the seal, says the hatch does not open a managed item, and keeps the source remedy; it no longer prescribes setting `OS_METADATA_WRITABLE`, which would no longer help. + - A write naming a read-only package (`?package=`) still answers `403 ITEM_LOCKED`, now without a hatch-dependent remedy. `SysMetadataRepository.readOnlyBaseOverrideError` takes `(type, packageId)`; its third `hatchOpen` parameter is gone, because nothing it chose survives the seal. + + **What does not change.** + + - The environment overlay of a `view`, `dashboard`, `report`, `translation` or `email_template` a package ships, which the registry allows. + - Everything about items no managed package ships: the hatch still opens their runtime creation for a type that allows none, and their organization-scoped write. + - Disabling a managed flow or action (`POST /api/v1/automation/:name/toggle`, `POST /api/v1/actions/_activation/:object/:action`), operator-gated under a wall as before, and cloning a flow under a new name, which records no linkage. + - Removing a stored overlay row of a type whose loader merges it at read (`permission`, `position`, `page`, `app`, `dataset`, `book`, `tool`, `skill`): that removal restores the package's definition and stays allowed. + - Every overlay row a deployment already holds keeps loading and serving as before. + + **For an operator who set the hatch to customize a managed item.** Customize it through its type's route: an environment overlay for the five presentational types, the switch or a clone under a new name for a flow, the clone for a permission set, an extension package for an object. An overlay row the hatch wrote earlier onto a flow, action, hook, object or another type whose overlay does not merge at read keeps serving, and can no longer be edited or removed through the metadata API, with the hatch set or not. +- b389e43: feat(metadata-protocol,runtime,service-automation,spec)!: the metadata protocol refuses every organization-scoped write, and an uninstall is environment-wide (ADR-0131 D6/D12) + + Clause-②: yes (narrowing) + + + + **BREAKING, in both directions.** It narrows: an organization-scoped metadata write is refused, and request keys retire. It widens one refusal: a package uninstall naming neither `organizationId` nor `allTenants`, refused before with `400 TENANT_SCOPE_REQUIRED`, is now accepted and runs environment-wide. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The `/meta` doors already carry no organization; now the metadata protocol itself refuses an organization-scoped write from every door, and the per-organization write path behind it is deleted. + + **What changes.** + + - Every protocol write that names an organization is refused with `403 NOT_OVERRIDABLE`, before anything is read or written, for every metadata type and every tenancy posture. This covers `saveMetaItem` (draft and publish), `publishMetaItem`, `deleteMetaItem`, `rollbackMetaItem`, `revertCommit`, `rollbackToPackageCommit`, `publishPackageDrafts`, `discardPackageDrafts`, `revertStoredPackage`, `duplicatePackage` and `reassignOrphanedMetadata`. The message's first sentence names the tenancy posture in force. The five types that declared `allowOrgOverride` (`view`, `dashboard`, `report`, `translation`, `email_template`) and the `OS_METADATA_WRITABLE` hatch no longer open an organization scope. + - The audit ledger (`sys_metadata_audit`) and the commit ledger (`sys_metadata_commit`) record `organization_id` NULL. + - The `/packages` doors of the runtime dispatcher thread no organization into any verb: publish-drafts, discard-drafts, the commit list, commit revert, rollback, revert, adopt-orphans, duplicate, delete, and the package manifest read. + - `deletePackage` retires its `organizationId` and `allTenants` request keys and both `400 TENANT_SCOPE_REQUIRED` refusals. The dispatcher's `DELETE /api/v1/packages/:id` no longer refuses an operator with no active organization. An uninstall removes every row bound to the package in this environment. Who may uninstall is the door's operator gate, as before. + - A seed published with a package no longer takes the publisher's active organization: a seed dataset names the organization it populates (ADR-0131 D9). + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `SaveMetaItem` / `PublishMetaItem` / `DeleteMetaItem` request (`@objectstack/spec`) | drop it: the write lands environment-wide. The key is stripped at a spec parse (the schemas are not strict) and refused at the protocol: a request still naming one answers `403 NOT_OVERRIDABLE` | + | `organizationId` on any other protocol write verb's request | drop it, same refusal | + | `deletePackage({ packageId, organizationId })` or `deletePackage({ packageId, allTenants: true })` | `deletePackage({ packageId })`; either retired key answers `400 INVALID_REQUEST` and removes nothing | + | `DeletePackageRequest.organizationId` / `.allTenants` (`@objectstack/metadata-protocol`) | gone from the type | + | `UninstallCleanup`'s `organizationId` argument | gone; a cleanup receives `{ packageId, actor? }` | + | `TENANT_SCOPE_REQUIRED` in the error-code ledger | retired; no producer emits it | + | `findPlatformScheduleOrgGaps`' `organizationId` input | gone: every write is platform-level | + | a `seed` draft whose records carry no `organization_id`, relying on the publisher's active organization under `group` | set `organization_id` on each record (ADR-0131 D12, item 12); under `group` a seed record that names no organization is refused at load, and under `single` the loader still derives the Default Organization | + + **What a deployment observes.** + + - Rows stored organization-scoped before this release are not touched by any write. `POST /meta/_migrate-stored` reports each one as `skipped` and names the promotion ceremony (ADR-0131 C7); the stored-flow credential move reports such a flow as not moved (`NOT_OVERRIDABLE`) and logs that its credential is still in cleartext in that row — rotate it. A legacy organization-scoped draft is no longer promoted, discarded or reverted by a package verb, a commit recorded in a legacy organization layer is refused by `revertCommit`, and `duplicatePackage` and adopt-orphans copy or adopt the environment's rows only. + - An uninstall removes the package's legacy organization-scoped rows with it, as the declared cross-tenant uninstall did. + + **What does not change.** The protocol's reads still accept an organization and still serve legacy organization rows; that narrowing is a later stage. The anonymous form doors' read of the Default Organization's layer is unchanged. +- c8b062f: feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. + + **What changes.** + + - The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. + - `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. + - `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. + - `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. + - [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling instead: the reads do not decline it, a cold boot refuses a deployment that holds one (ADR-0048), and the offline `os migrate security-catalog-overlays` removes it. + - `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. + - Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. + - The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. + - The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | + | `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | + | `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | + | `SysMetadataRepository.listDrafts` row `organizationId` (`@objectstack/metadata-protocol`) | gone: every listed row is an environment draft, whichever repository asks | + | `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | + | `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | + | `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | + | `organizationId` on `projectPermissionMutation`'s `evt` (`@objectstack/plugin-security`) | drop it: the projection reads the environment → code layered read, whatever organization the event names | + | a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | + | an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | + + **What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. +- 117d34d: feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: `sys_view_definition` retires as inert (ADR-0131 D13) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N` like every other stage. + + `sys_view_definition` was declared for runtime-authored shared and personal views (ADR-0017) and never used for them: no framework code writes or reads its rows, and the Studio console's view doors write the ADR-0005 `view` overlay in `sys_metadata` through the metadata API. ADR-0131 D13 retires it. A runtime-authored view is a `view` metadata item, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`); nothing about that path changes. + + **What moves for consumers.** + + - **The object.** FROM `import { SysViewDefinitionObject } from '@objectstack/metadata-core'` (or from `@objectstack/platform-objects`, or its `/metadata` subpath) TO nothing: delete the import. Neither `MetadataPlugin` nor the metadata protocol assembly registers the object any more, so the generic data door no longer serves it. + - **The migration exports** of `@objectstack/metadata-protocol`. FROM `ensureViewDefinitionActiveIndex`, `resolveIndexExec`, `buildActiveIndexSql`, `VIEW_DEFINITION_TABLE`, `VIEW_ACTIVE_INDEX_NAME`, `VIEW_ACTIVE_PROBE_INDEX_NAME`, `VIEW_ACTIVE_INDEX_COLUMNS`, `EnsureViewIndexLogger`, `EnsureViewIndexStatus` and `EnsureViewIndexResult` TO nothing: delete the import. `classifyIndexFailure` and the `IndexExec` type are still exported, unchanged, from the shared index-migration module. + - **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['metadata-core']` (`@objectstack/spec/system`) lists four objects, and `isPlatformProvidedObjectName` answers `false` for `sys_view_definition`. A stack that names it (a lookup target, a flow trigger, a permission entry, a platform-global declaration) is now flagged as naming an unknown object: remove the reference. + - **`os migrate duplicates`.** `runtimeIndexPreflight` carries three entries (the two `sys_metadata` overlay indexes and `sys_setting`'s row identity), with no `idx_sys_view_def_active` entry. A serving boot no longer runs the active-row index migration for the table, and no longer logs its `sys_view_definition active-row index` lines. + - **Translations.** The platform translation bundles no longer carry `sys_view_definition` keys. + + **Existing databases.** Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_view_definition`, with any rows a caller wrote through the generic data door; nothing reads them. `os migrate apply --allow-destructive` does not drop it either, because it reconciles declared objects only (measured on one database: it dropped an orphaned column of a declared table and left this table and its row in place). On a project with a host config, `os migrate plan` lists it among the platform-prefixed tables nothing declares. Export any row worth keeping; dropping the table is the operator's call, by hand: `DROP TABLE sys_view_definition`. +- c7d6037: A package declares how it may be installed — `installModes: ['managed', 'unmanaged']` — and `os package install --install-mode unmanaged` copies it once into the environment as the environment's own metadata (ADR-0131 D6) + + Clause-②: yes (widening: the new optional manifest key `installModes`; the new exports `INSTALL_MODES`, `InstallModeSchema`, `InstallMode`, `DEFAULT_INSTALL_MODES`, `resolveInstallModes`, `UNMANAGED_INSTALL_KEY_DISPOSITIONS`, `UNMANAGED_INSTALL_REFUSING_DISPOSITIONS`, `UnmanagedInstallKeyDisposition`, `unmanagedInstallValueCarries` and `unmanagedInstallPostureRefusal`; the install-local body field `installMode`; the CLI option `--install-mode`; the error codes `STACK_UNMANAGED_INSTALL_UNCOPYABLE` and `INSTALL_MODE_NOT_PERMITTED`) + + A package installs one of two ways. **Managed**: it is registered as code, stays the authority, upgrades, and nothing it ships is edited in place. **Unmanaged**: its metadata is copied once into this environment under a new writable base; the copy's items are this environment's own metadata, the next save edits them, and it follows no upgrade. This entry adds the manifest key that declares the modes a package permits, and the install door that honours it; the copy itself is the companion entry `15213-unmanaged-import-verb`. + + - **`installModes`** (`@objectstack/spec`, `ManifestSchema`). Optional, a non-empty list of `'managed'` / `'unmanaged'`. Absent means `['managed']`, read through one resolver, `resolveInstallModes(manifest)`; there is no parse-time default, so no existing manifest changes by a byte. A package may permit `unmanaged` alone. + - **Refused at authoring.** `defineStack` — so `os validate` and `os build` — refuses a package that permits `unmanaged` while it carries what the copy cannot write as environment metadata: a non-empty collection of a type the metadata-type registry declares runtime-uncreatable (`agents`, `apis`, `capabilities`, `jobs`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), or an extension of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`). The refusal is `STACK_UNMANAGED_INSTALL_UNCOPYABLE` (422), one `issues` entry per key. Fix: remove `'unmanaged'` from `manifest.installModes`, or move what cannot be copied out of the package. The install door judges the same body with the same key classification (`UNMANAGED_INSTALL_KEY_DISPOSITIONS`), so a package that passes `os validate` is not refused at install for a key nobody named. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local`). The body takes `installMode: 'managed' | 'unmanaged'`; absent means `managed`, so every existing caller is unchanged. Before anything is written, on the catalog and the air-gapped source alike: + - a mode the package's `installModes` does not permit answers `422 INSTALL_MODE_NOT_PERMITTED`, with `details.permitted`; + - `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value) answers `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture; + - a managed install of a package whose namespace a writable base holds — its unmanaged copy — answers `409 RESOURCE_CONFLICT`, naming that base: a managed install would otherwise take the namespace and stop serving the edited copies. + An unmanaged install writes no install-ledger entry, registers nothing under the package's id and does not load its sample data (the answer says so under `seeded`). The answer names the new base, the items copied and `installedFrom` (the source package id and version, for information only). The package's translation bundles are copied as `translation` items, so they reload at every boot with the environment's own translations. + - **`os package install --install-mode managed|unmanaged`** (`@objectstack/cli`). Without the option, nothing is sent and the runtime installs managed. An unmanaged answer prints the base, the item count and the source. A runtime that predates the install mode ignores the option and installs managed; the command then exits 1 and says so. The command's help now calls the catalog and the air-gapped artifact the two *sources*, so "mode" means one thing. + - **Copy semantics** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` copies `translations` (each locale of each bundle as one `translation` item named `_`, bundles merged in order), reads `installModes` without writing it onto the base, and no longer refuses an empty code container (`functions: {}`, `plugins: []`). `unmanagedInstallPostureRefusal` is exported, so the door and the import answer the walled posture with one sentence. + + Nothing existing is removed: every new key, field and option is optional, and an install that does not name a mode is managed, as before, with one stricter answer. That answer is the refusal above: a managed install of a package whose namespace a writable base holds now answers `409 RESOURCE_CONFLICT` before anything is written, where a catalog install of it answered `200`. +- 72b26ed: `importUnmanagedPackage` copies one package into the environment ledger, editable from then on — the import behind the `unmanaged` install mode (ADR-0131 D6) + + Clause-②: yes (widening) + + A package installs one of two ways. Managed: the package is the authority, it upgrades, and nothing it ships may be edited. Unmanaged: every metadata item is copied once into this environment, is fully editable, and never follows an upgrade. This release adds the import; the install door calls it for an unmanaged install, and the manifest key `installModes` declares the modes a package permits. + + - **The verb** (`@objectstack/metadata-protocol`). `ObjectStackProtocolImplementation.importUnmanagedPackage({ manifest, targetPackageId?, targetName? })` writes every metadata item of ONE package body into a NEW writable base through the metadata save door: each row is owned by the base, carries no organization, is served as environment-authored (`_provenance: 'org'`), and the next save edits it. The base's id differs from the source (`targetPackageId`, defaulting to `-copy`), it keeps the source namespace so every object name stays valid, and nothing is registered under the source id. The answer names the source, the base, its namespace and every item copied, in write order. + - **All or nothing.** Every refusal that can be judged is raised before the base exists, so a refused import writes nothing: a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value); a body carrying what cannot be copied; a malformed name or an unusable target id; a protocol-incompatible package; a target id that names an installed package, or a namespace another package owns (a managed install of the same package included); an item that does not convert to this protocol; a name an environment row or a managed package already holds. A write that fails afterwards deletes the base — its rows and its tables — and the failure is thrown with the item's own code and status, naming the item and what the rollback did. + - **What is copied, and what is refused.** The items of every manifest collection and the assembled `viewItems` are copied. A body carrying a runtime-uncreatable type (`jobs`, `agents`, `apis`, `capabilities`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), extensions of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`) or an artifact envelope (`packages`) is refused whole; `OS_METADATA_WRITABLE` does not open a type for it. The package's `translations` are copied too, each locale of each bundle as one `translation` item, and an unmanaged install loads no seed `data`; deployment configuration (`i18n`, `requires`, `datasourceMapping`, …) is not part of a package install in either mode. + - **Two new error codes** (`@objectstack/spec`, registered to `@objectstack/metadata-protocol`). `UNMANAGED_INSTALL_WALLED_POSTURE` (409) and `UNMANAGED_INSTALL_UNCOPYABLE` (422). Both mean the package can be installed managed instead. A collision, an existing target or an owned namespace answers the standard `RESOURCE_CONFLICT` (409). + - **Two new schemas** (`@objectstack/spec/api`). `ImportUnmanagedPackageRequestSchema` is parsed by the verb, so a request key it does not declare is refused with `400`. `ImportUnmanagedPackageResponseSchema` declares the answer. + + Nothing existing changes: no schema, door or code is removed or narrowed. +- 2d21b9c: An unmanaged install no longer copies a package's protection lock, in either spelling: every copied item is editable, and the install's answer names each lock it dropped with the package's reason (ADR-0131 D6, ADR-0010) + + Clause-②: yes (widening: the new response field `strippedLocks` on `ImportUnmanagedPackageResponseSchema`, relayed on the install-local answer for `installMode: 'unmanaged'`) + + An unmanaged install copies a package into this environment as the environment's own metadata. A package can lock an item in two spellings: the `_lock` envelope (`_lock`, with `_lockReason`, `_lockSource` and `_lockDocsUrl`), or the author-facing `protection: { lock, reason, docsUrl }` block that a managed install translates into it. Until now an item locked with `_lock` was copied with that lock, so the next save of the copy answered `403 ITEM_LOCKED`; an item locked with `protection` was copied with an inert block that bound nothing, so the vendor's lock vanished without a word. The package permitted the unmanaged install through its `installModes`, so it chose to let environments take editable copies; a lock whose source is the package does not survive the copy. + + - **The copy** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` writes every item without `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl`, and, on a type whose schema declares one, without its `protection` block. Its answer gains `strippedLocks`: one entry per lock that bound (`no-overlay`, `no-delete` or `full`), in the order the items were written — `{ type, name, lock, reason? }`, with `reason` the package's reason verbatim and absent when it gave none. Each entry is the lock a managed install of the same item would have bound: an item carrying both spellings is reported once, with the `protection` block's lock and reason, because that is the one a managed install binds. A package that locks nothing answers `strippedLocks: []`. + - **Refusals are unchanged.** A lock the spec's declarations refuse (an unknown `_lock` value, a reason that is not a string, a malformed `protection` block) is not stripped: the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back. So is a `protection` block on a type whose schema does not declare one (`action`, `hook`, `page`). A managed install is unchanged: the package stays the authority, and its locks bind. + - **The declaration** (`@objectstack/spec`). `ImportUnmanagedPackageResponseSchema` declares `strippedLocks`; `lock` takes the three binding values of `MetadataLockSchema`. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local` with `installMode: 'unmanaged'`). The answer carries the import's `strippedLocks` as the import states it. + - **`os package install --install-mode unmanaged`** (`@objectstack/cli`). Each dropped lock prints as one line under `Locks removed`, spelled `.: — `, so the installer reads why the vendor locked an item before editing it. An answer from a runtime that states no list prints no lock line. + + Nothing is removed or narrowed: the answer gains a field, and every refusal of the unmanaged install is unchanged. +- 8caa131: A package's stored copy of a view container it ships overlays that package's shipped views, so a withdrawal saved in the copy holds at the anonymous form endpoints; the runtime save door refuses three copies it accepted before + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing at the runtime save door, shipped as `minor` under the repo's launch-window convention for breaking changes, the grade the same door's earlier view container refusals shipped with. + + - **What was wrong.** The source loaders register a view container a package ships as `OBJECT.KEY` views for that package, whichever package owns the object. When that package stored a copy of the same container (a `PUT /api/v1/meta/view/NAME` of the container) and the object belonged to another code package, the copy expanded under its own name instead, as `OBJECT.CONTAINER.KEY` (a bare `list` as `OBJECT.CONTAINER`). So the copy overlaid none of the views its package ships: a form withdrawn from anonymous intake in the copy stayed open in the package's shipped form of that name, and the anonymous form endpoints kept serving it. + - **What it does now.** A package's stored copy of a container that package ships, bound to the same object, expands as the loaders expand the shipped container: each member is served under the loaders' name, `OBJECT.KEY`, in the copying package's own slot on the view list and on the by-name read naming that package. So a withdrawal saved in the copy holds at the anonymous form endpoints. The copy still declares no default view for an object another package owns. Any other container on another package's object keeps expanding under its own name, unchanged. + - **The by-name read on an unscoped kernel.** On a kernel with no environment id, a stored container's expanded views are also registered in the schema registry, under the bare name. A view whose name another package also ships is no longer registered there: the registry answered that bare entry ahead of the other package's own view, so `getMetaItem` naming the other package served this container's view. Every kernel's by-name read already serves such a view from its stored row, for its own package and for a read that names no package. Two packages that ship one container, with one of them storing a copy, were affected before this change too. + + **What is refused now.** `saveMetaItem`, which `PUT /api/v1/meta/view/NAME` and the dispatcher's metadata save both call, judges a copy at the names it now expands to, so three copies it accepted before are refused with `VALIDATION_ERROR` / 400, before anything is stored. Each is a package's copy of a container it ships on another package's object: + + - The copy adds a bare `list` whose name, `OBJECT.default`, only the other package ships. Before: accepted, served as `OBJECT.CONTAINER`. After: refused, naming the package that ships `OBJECT.default`. + - The copy adds a keyed member (a `formViews` or `listViews` entry, a named `list`, or a `form`) whose name, `OBJECT.KEY`, only the other package ships. Before: accepted, served as `OBJECT.CONTAINER.KEY`. After: refused, naming the package that ships `OBJECT.KEY`. + - Another stored container, saved under a different name, already expands a name the copy now expands. Before: accepted. After: refused, naming that stored container. + + **The fix.** For the first two, give the added member a key of its own that no package ships and no stored container expands, or save a view item (`name`, `object`, `viewKind`, `config`) under that name to override the other package's view. For the third, add the view as a member of the stored container that already expands the name, or save a view item under that name. + + **What still saves.** A copy that keeps the members its package's shipped container has, their contents edited, under the container's own name. A copy that adds a member under a key no package ships and no stored container expands. A view item under any of these names. Every container that is not a copy of its own package's shipped container, as before. + + **Rows stored before this change.** They keep their bytes. A stored copy of a container its package ships, on another package's object, is now served under the loaders' names (`OBJECT.KEY`) instead of `OBJECT.CONTAINER.KEY`, so it overlays the package's shipped views from the next read on, with no re-save. A reference to one of its old names (a navigation `viewName`, a form action `target`) no longer resolves; point it at `OBJECT.KEY`. A new save of a stored copy in one of the refused shapes, a re-save included, is refused until its body stops colliding. Delete stays open. +- 2f70c22: A key a builtin flow node's executor contract does not declare is refused at parse, with a location, on every builtin but `try_catch`: a `notify` `bogusKey`, a screen field's `visibleIf`, a `create_record` `fieldValues`, an `http` `outputVariable` and the like no longer pass `objectstack validate` and `objectstack compile` and then get the whole flow refused at registration. + + Clause-②: yes (narrowing: an undeclared config key on 10 more builtins, every strict-contract builtin but try_catch, is refused at the build doors and the save door, where it passed; widening: a save door with no flow canonicalizer judges the D2-converted body, so a D2 alias spelling it refuses today, script functionName / input and subflow flow, is accepted again, as at every other door) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** Each of these executors parses the node's `config` against a strict contract before it acts. The build doors' executor-contract arm judged required keys and present values on these types, but held key membership back, because `registerFlow`'s undeclared-key walk judged it against the node type descriptor's `configSchema`. So a `notify` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied the key into `dist/objectstack.json`), and then registration refused the whole flow: at boot it was skipped with a warn, and a flow saved from Studio was stored and then silently not registered. + + **What is refused.** A `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node, at any depth, whose config carries a key its executor contract does not declare: at the config itself, on a `screen` field, or on one of a field's `options`. That includes a body-less legacy `loop`, which is judged on key membership alone. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`, `nodes.N.config.fields.0.visibleIf`). It comes from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share. Its message carries the contract's own sentence, with the contract's prescription for a known slip (`fieldValues` → `fields`, `bulk` → `multi: true`, `visibleIf` → `visibleWhen`, a did-you-mean for a near miss), and closes with the remedy: rename the key to one the contract declares there, or remove it. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the save door. `script` and `subflow` keys were already refused this way. The new `builtinNodeConfigKeysJudged(nodeType)` export names exactly the builtins the spec judges. + + **`@objectstack/service-automation`.** `registerFlow`'s descriptor walk (`validateNodeConfigKeys`) stands aside for every type `builtinNodeConfigKeysJudged` names, so each node type has one judge. Before the move, the declared key sets were measured equal: on each of these types the descriptor's declared keys, at every position the walk descends to, equal the keys the contract accepts there. So registration refuses exactly what it refused before. The refusal now arrives as the parse's located issue instead of the walk's `Flow '…' rejected: N undeclared config key(s)` text. + + **`@objectstack/metadata-protocol`.** The save door judges a flow in its canonical spelling on every path. When no flow canonicalizer resolved (a host with no automation service), or the canonicalizer threw (a draft with a temporary cycle), the schema gate and the runtime authoring gate now judge the body with the ADR-0087 D2 conversions applied (`applyConversionsToFlow`), for the verdict only. The stored body stays the raw request body, exactly as before. A D2 spelling the load path still rewrites (`filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`; `functionName` and `input` on a `script`; `flow` on a `subflow`) is therefore accepted at that door, as at every other one, and an undeclared key that no conversion rewrites is refused there, located. This widens the fallback path back for the `script` and `subflow` aliases the previous key arm had begun refusing there. `duplicatePackage` re-saves through the same gate. + + **What stays as it was.** + + - `try_catch`: its undeclared keys stay registration's, judged by the walk. Its contract's `retry` is the shared `RetryPolicySchema`, which strips an unknown key, while its descriptor closes `retry` to `maxRetries`, `backoffMs`, `backoffMultiplier`, `maxRetryDelayMs` and `jitter`. Moving the judge would have widened registration. + - Every plugin node type (the spec does not declare its contract): judged by the walk against its descriptor's `configSchema`, with the walk's own prescriptions. + - A key at or under a region slot (a `loop` body or a `parallel` branch, and their nodes and edges): judged at registration by the region check, as before. + - A key inside a free-form map (`filter`, `fields`, `headers`, `defaults`, `input`, `payload`, `templateData`): author data, never judged. + - `assignment`, whose top-level keys are the author's variable names, and `decision`, which parses no contract: unjudged. + - A spelling an ADR-0087 D2 conversion still rewrites at load (`object` and `filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`, the save door). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | a typo of a declared key (`titl`, `outputVariabel`) | the declared key the refusal's did-you-mean names | + | `fieldValues` on `create_record` / `update_record` | `fields` | + | `bulk`, `all`, `multiple` or `options: { multi: true }` on `update_record` / `delete_record` | `multi: true` at the top level of `config` | + | `visibleIf` on a screen field | `visibleWhen` | + | `itemVariable` on a `loop` | `iteratorVariable` | + | `outputVariable` on an `http` node | delete it: the http executor binds no output variable | + | a key nothing reads | delete it | + + **The one-line fix: rename or delete the key the refusal names.** Registration already refused such a flow, so the fix changes nothing a running flow does. + + **Who is affected, measured.** At `fbcbcf124`, every builtin flow node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (95 nodes) carries only keys its contract declares. The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys whenever the engine publishes the node's descriptor. Its fallback form (while the descriptor list is loading, or when it cannot be read) offers `outputVariable` on an `http` node and the `url` alias on a `notify` node. Registration already refused the first and the conversion rewrites the second; the designer fix is tracked in objectui. hotcrm, deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, over every builtin in `getBuiltinNodeConfigContracts()` but `try_catch`, named by the new export `builtinNodeConfigKeysJudged`; no new code joins `FLOW_SLOT_REFUSAL_CODES`. + - **The ledger.** The D3 semantic entry `flow-builtin-node-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. +- 1fb274e: `ObjectStackProtocolImplementation.declinesStoredRow(type, name)` is now public, so a door that serves a stored row out of the layered read can ask the same decision the reads make + + Clause-②: yes (widening) + + - The method answers `true` for exactly the names whose stored `sys_metadata` row the active reads (`getMetaItem`, the list, and the `effective` layer of `getMetaItemLayered`) do not adopt: a flow name a managed package ships (the answer `isShippedFlowName` gives), and a datasource name the host registers from code (one an installed package declares in `*.datasource.ts`, or the host's `default`). Every other type and name answers `false`. + - The `GET /meta/:type/:name/published` doors in `@objectstack/rest` and `@objectstack/runtime` now ask this method in place of `isShippedFlowName`. A door asks it, and does not restate either half or the host's code-datasource set. + - `isShippedFlowName` stays public and unchanged. + - The only change to the public surface is this one added method. No signature, schema or accept set changes, and no behaviour of this package changes. +- f85a83b: fix(lint)!: the object save door refuses a formula field whose expression `os build` refuses (#22019) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. At the object save door it did not. A formula field calling an unregistered function, such as `sqrt(record.amount)`, was refused by `os build` as an unknown function, but `PUT /api/v1/meta/object/:name` answered 200, stored it, and the field read `null` on every row. + + The runtime publish gate now runs the build's own formula check on an object write. The registry entry for the build's expression rule (`validateStackExpressions`) declared the flow, action and hook writes and never the object write, so the gate never dispatched it there. It now declares `object` as well, for one of its passes: a formula field's `expression`. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' expression`), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for a formula field whose expression the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that field's `expression`. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), and the other errors in the build's formula check. Its warnings now ride the save response as advisories, as they already did for a flow write. + + **Remedy.** Fix the expression: the message names the unknown function or field and the position, as `os build` already requires. Use one of the functions `introspectScope` lists, qualify field reads as `record.FIELD`, or compute the value in a stored field and reference it. Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps reading, with the formula still `null`, until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. + - This entry's crossing covers formula fields alone. The other expressions an object carries (validation-rule predicates, the field-rule slots `requiredWhen`, `readonlyWhen` and `visibleWhen`, option `visibleWhen`, and the object's own action predicates) are judged at this door through their own #22032 entries, each its own crossing, measured over the stored corpus first. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every formula field this repository ships has 0 refusals and 0 advisories at the door. That is 29 fields on 28 objects: examples 7 on 6, and the platform `display_title` formulas 22 on 22. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature. The registry entry reaches the passes through an internal function that is not on the package's entry. The built entry declarations differ only in one doc comment, on `AuthoringRuleContext.runtimeWriteType`. + + +- 73a0a6b: fix(lint)!: the object save door refuses an object action's `visible` or `disabled` predicate that `os build` refuses (#22032) + + Clause-②: no (narrowing: the object save door refuses an action visible or disabled predicate os build already refuses) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For an object's own actions it did not, at the object save door. An action whose `visible` read a bare field, such as `amount > 1`, or called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The action runtime evaluates such a predicate fail-closed, so the action was hidden on every record. + + The runtime publish gate now runs the build's action check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields, validation-rule predicates, the field-rule slots and option `visibleWhen`. On an object write it now also judges each `actions[].visible`, and each `actions[].disabled` that is not a boolean literal, as a predicate over `record`, the way the build does. Every expression an object carries is now judged at this door. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · action 'ACTION' visible`, or `disabled`), message and hint. One location differs, never the verdict: an action that is also declared in the stack's top-level `actions` is located by `os build` at `stack · action 'ACTION' visible`, and by the door, which judges the object alone, at the object. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for an action whose `visible` or `disabled` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that action. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), and a syntax error. Its warnings now ride the save response as advisories. + - The platform's own `sys_approval_request` (shipped by `@objectstack/plugin-approvals`) carries 8 action `visible` predicates that read `record.viewer`, a block the approvals service attaches on read and the object does not declare. The shared validator refuses all 8 (``unknown field `viewer` on `sys_approval_request` ``). Until #22211 lands, a save of that packaged object answers as follows. The first two rows were measured on the showcase's `objectstack dev` boot; the third is a source reading. + + | kernel and setting | publish save, before → after | draft save | + |:--|:--|:--| + | host-config kernel, no `OS_METADATA_WRITABLE` | 403 `NOT_OVERRIDABLE` → 422 `INVALID_METADATA` (8 issues) | 403 `NOT_OVERRIDABLE` | + | `OS_METADATA_WRITABLE=object` | 200 → 422 `INVALID_METADATA` (8 issues) | 200 | + | environment-scoped kernel | 403 `NOT_OVERRIDABLE`, unchanged | 403 `NOT_OVERRIDABLE` | + + On a host-config kernel the authoring gate answers before the package door, so there the 422 is returned where the more basic 403 belongs; #22220 carries that order. The object itself is unchanged: it registers, is served, and its actions evaluate as before. + + **Remedy.** Fix the predicate: the message names the unknown function or field, the bare reference, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and write a boolean literal (`true` / `false`) for a constant. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. For `sys_approval_request` there is nothing to repair on the consumer side: its predicates are the platform's, and #22211 carries the fix. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - The stack's top-level `actions`, its flows, its sharing rules and its hooks are not judged on an object write: each is judged at its own type's door, as before. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every object this repository ships (118 objects from its `*.object.ts` files) carries 58 action predicates on 16 objects, and the example stacks as `defineStack` composes them (33 objects, standalone actions merged in) carry 59 on 5. The shared validator refuses the 8 on `sys_approval_request` above and nothing else, and the door refuses the same 8 after this change and none before it. There are 0 other refusals and 0 advisories, at both doors. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. The built entry declarations differ only in one doc comment, on `AuthoringRuleContext.runtimeWriteType`. + + +- f2a45db: fix(lint)!: the object save door refuses a field-rule slot whose predicate `os build` refuses (#22032) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field's rule slots it did not, at the object save door. A field whose `requiredWhen` read a bare field, such as `amount > 1`, or whose `visibleWhen` called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. + + The runtime publish gate now runs the build's field-rule-slot check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields and validation-rule predicates. On an object write it now also judges each field's `requiredWhen`, `readonlyWhen` and `visibleWhen` the way the build does, with the build's three gates on them: the `parent` gate, the null-guard check over `requiredWhen`, and the refusal of a `requiredWhen` or `readonlyWhen` that reads through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' SLOT`), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for a field whose `requiredWhen`, `readonlyWhen` or `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that slot. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, a root a field-level rule never binds (such as `current_user`), a `parent` read on an object that does not declare exactly one `master_detail` relationship, an ordering or arithmetic operator in `requiredWhen` applied to a nullable field with no `!= null` guard, and a `requiredWhen` or `readonlyWhen` that reads through a reference field (`record.account.tier`, or `parent.REF.FIELD`). Its warnings now ride the save response as advisories. + - A detail object's `requiredWhen` or `readonlyWhen` that reads through one of its master's reference fields (`parent.REF.FIELD`) is judged whenever the master is in the write's context, and that includes a save of the master itself. So a master save can answer 422 with an issue located at a stored detail's field. Fix the detail's predicate, then save the master again. + + **Remedy.** Fix the predicate: the message names the unknown function or field, the unbound root, the unguarded operand or the reference read, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, guard a nullable operand in `requiredWhen` with `record.FIELD != null && …`, and move a check that must read through `record.REF` into a `validations[]` `script` rule, whose `condition` is read one hop through a reference; a read through `parent.REF` has no such surface, so read a column the master declares instead (denormalise the value onto it). Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. + - `conditionalRequired` is still refused at the save door's schema step, before this gate, as a key retired in protocol 17; `os build` judges it as a field-rule slot as before. + - Option `visibleWhen` and the object's own action predicates are judged at this door through their own #22032 entries, each its own crossing, not through this one. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every field-rule slot this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 9 slots on 8 fields of 3 objects (examples: 8 on `showcase_invoice` and `showcase_invoice_line`, three of them `parent`-scoped; the platform: 1 on `sys_permission_set`), over the 118 objects this repository ships. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- aa9447c: fix(lint)!: the object save door refuses a field option's `visibleWhen` that `os build` refuses (#22032) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a field option's `visibleWhen` it did not, at the object save door. An option whose `visibleWhen` read a bare field, such as `amount > 1`, or called an unregistered function, such as `sqrt(record.amount) > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The server's option check cannot evaluate such a predicate and lets the value through, so the gate it declares is never enforced. + + The runtime publish gate now runs the build's option check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields, validation-rule predicates and the field-rule slots. On an object write it now also judges each `fields[].options[].visibleWhen` the way the build does: as a predicate over `record` and `previous`, and with the build's refusal of a read through a reference field. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for an option whose `visibleWhen` the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, and a read through a reference field (`record.account.tier`, `previous.account.tier`). Its warnings now ride the save response as advisories. + + **Remedy.** Fix the predicate: the message names the unknown function or field, the bare reference or the reference read, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and compare a reference field as a value (`record.account != null`) rather than read through it. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - `current_user` is still accepted in an option's `visibleWhen`, as the build accepts it: the option evaluator binds the acting user (ADR-0068 D1). A role gate such as `'org_admin' in current_user.positions`, or a grant check such as `current_user.can('OBJECT', 'edit')`, still saves. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen` it is still refused, as before. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - The object's own action predicates (`actions[].visible`, `actions[].disabled`) are judged at this door through their own #22032 entry, its own crossing, not through this one. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: this repository ships 5 option predicates, all on `showcase_cascade` (four `record.country` cascades and one `current_user.positions` role gate), among the 118 objects it ships. They have 0 refusals and 0 advisories, at the build and at the door. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- 3d91885: fix(lint)!: the object save door refuses a validation rule whose predicate `os build` refuses (#22032) + + Clause-②: no (narrowing) + + `formulas.mdx` says the same `validateExpression` validator backs `os build` and metadata registration. For a validation rule's predicates it did not, at the object save door. A rule whose `condition` called an unregistered function, such as `sqrt(record.amount) > 1`, or read a bare field, such as `amount > 1`, was refused by `os build` at error, but `PUT /api/v1/meta/object/:name` answered 200 and stored it. The rule then faulted on every write it judged. + + The runtime publish gate now runs the build's validation-rule check on an object write. The build's expression rule (`validateStackExpressions`) was already on the object door for formula fields alone. On an object write it now also runs its validation-rule pass: each `validations[]` rule's `condition` and a `conditional` rule's `when`, plus the null-guard check over every predicate the rule carries, its nested `then` and `otherwise` rules included. The door's verdict is the build's finding: the same rule id (`expression-invalid`), location (`object 'NAME' · validation 'RULE'`, or `… validation rule 'RULE' then → 'CHILD'` for a nested predicate), message and hint. + + **BREAKING — what moves for consumers.** + + - An object write in publish mode answered 200 for a validation rule whose predicate the shared validator refuses. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + - The verdict is the one `os build`, `os validate` and `os lint` already gave: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, an ordering or arithmetic operator applied to a nullable field with no `!= null` guard (`has()` is no guard here), and the other errors in the build's validation-rule check. Its warnings now ride the save response as advisories. + + **Remedy.** Fix the predicate: the message names the unknown function or field, or the unguarded operand, and the position, as `os build` already requires. Qualify field reads as `record.FIELD`, use one of the functions `introspectScope` lists, and guard a nullable operand with `record.FIELD != null && …`. Saving it as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved. At that save the gate judges it, because the differential compares the write against the stored universe without its own stored row. + - This entry's crossing covers validation-rule predicates. The field-rule slots (`requiredWhen`, `readonlyWhen`, `visibleWhen`), option `visibleWhen` and the object's own action predicates are judged at this door through their own #22032 entries, each its own crossing. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every validation rule this repository ships has 0 refusals and 0 advisories, at the build and at the door. That is 21 rules carrying 13 predicates on 10 objects: examples 11 predicates on 7 objects, and the platform objects 2 on 3 (one rule on `sys_user` carries no predicate). + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- 8fc50b7: fix(lint)!: a `conditional` validation rule's nested `then` / `otherwise` predicate meets the same expression verdict as the rule's own (#22042) + + Clause-②: no (narrowing) + + A `conditional` validation rule applies its `then` rule when its `when` holds and its `otherwise` rule when it does not, and the rule validator evaluates either branch as a rule of its own. `os build` judged a rule's own `condition` and `when` with the shared `validateExpression` validator, but reached the predicates inside `then` / `otherwise` with the null-guard check alone. So a nested `condition` that called an unregistered function, such as `sqrt(record.amount) > 1`, or read a bare field, such as `amont > 1`, passed `os build`. The object save door gives the build's verdict, so `PUT /api/v1/meta/object/:name` stored it, and the rule then refused every write it judged, because a validation rule that cannot be evaluated fails closed. The same predicate one level up was refused at both doors. + + The build's expression rule (`validateStackExpressions`) now runs the same check on every predicate nested in a `conditional` rule, at every depth: each nested `condition`, and the `when` of a `conditional` nested inside a branch. A nested `condition` also gets the relationship-traversal checks, because ObjectQL hydrates a one-hop read there, as it does at the top level. A nested `when` does not, because the evaluator never hydrates a `when`, as at the top level. A nested finding is located at the nested rule, the location the null-guard check already gave it: `object 'OBJECT' · validation rule 'OUTER' then → 'INNER'`, with `when-predicate` appended for a nested `when`. A rule's own `condition` and `when` keep their findings and their location (`object 'OBJECT' · validation 'NAME'`) and are judged once. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` now refuse, at `error`, a stack whose `conditional` validation rule carries a nested predicate the shared validator refuses. The validator's warnings on a nested predicate are now reported too, and at the save door they ride the response as advisories. + - An object write in publish mode that carries such a rule answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at the nested rule. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode) and the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`). + - The verdict is the one a rule's own `condition` already got: an unknown function, a field the object does not declare, a bare field reference (`amount` instead of `record.amount`), a syntax error, and, for a nested `condition`, a reference field read both through the relationship and as a value, or a read deeper than one hop. + + **Remedy.** Fix the nested predicate the way the same predicate is fixed at the top level: the message names the unknown function or field and the position. Qualify field reads as `record.FIELD`, and use one of the functions `introspectScope` lists. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - A rule's own `condition` and `when` are judged exactly as before, at the same location; the null-guard check over every predicate is unchanged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: this repository ships one `conditional` validation rule with nested predicates (`showcase_account.churn_reason_consistency`, two nested `condition`s), among 21 validation rules on the 118 objects it ships. Both have 0 refusals and 0 advisories, at the build and at the door. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. + + +- a543e24: `ObjectStackProtocolImplementation.revertStoredPackage({ packageId, organizationId?, actor? })` reverts a package's stored members to their published version + + Clause-②: yes (widening) + + - A Studio-authored package's members are `sys_metadata` rows bound by `package_id`. The method reads them through the same predicate `listDrafts` uses, which now has one statement shared by both reads. + - No stored row bound to the package: it answers `{ stored: false, discarded: [] }` and touches nothing. The package's members, if it has any, belong to the metadata service. An unprovisioned `sys_metadata` answers the same way. + - Stored rows, none of them published: it refuses with `RESOURCE_CONFLICT` / 409, "Package '…' has never been published, so there is no published version to revert to." Nothing is touched. + - Otherwise it removes every draft of the package, each in the scope it lives in, through the same per-draft step `discardPackageDrafts` takes. Each item then serves its published (active) row again. An item created after the last publish has no published row and is removed. A refused draft fails the call with that refusal's own code and status. + - The only change to the public surface is this one added method. `discardPackageDrafts` and `listDrafts` answer as before. +- 8f2e808: feat(meta): the `/meta` item read serves the ADR-0008 version token, `If-None-Match: *` pins a save that expects no row, and the 409 `METADATA_CONFLICT` body carries the current version as data + + Clause-②: yes (narrowing) + + **BREAKING** for two request shapes on `PUT /api/v1/meta/:type/:name` that were answered `200` before this release and are now refused `400 VALIDATION_ERROR`, with nothing written: an `If-None-Match` header whose value is anything but `*` (an entity-tag, a list, a weak `W/"*"`, an empty value), and an `If-None-Match` header sent beside `If-Match`. Before, the door did not read `If-None-Match` at all and wrote the body, unguarded or under the `If-Match` alone. The remedy: send `If-None-Match: *` alone to save only where no row exists, or `If-Match` with the version you read alone to save only over that version. No first-party client sends `If-None-Match` on a `PUT`: the SDK sends it only on its cached `GET`, and objectui's ETag hook has no caller. + + ADR-0008's optimistic lock on `PUT /api/v1/meta/:type/:name` takes the version a client read as `If-Match`, but a client can only send a token it was served, and only a save receipt served one. So the first save after a load could not be pinned: two editors who each loaded an item and saved once overwrote each other. Three additions close that. + + - **The read serves the token.** `GET /meta/:type/:name` now carries `version` (declared on `GetMetaItemResponseSchema`): the keyed token of the stored row a save to this item would compare against, at the read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` for the draft row, the plain read for the active row). It is the same producer and the same row as the save receipt's `version`, so a read after a save serves the receipt's token byte for byte. Send it back as `If-Match`. `null` means no stored row is there (an item served from code or a package artifact, or from a row in a scope the save does not write), so the next save is a create. The cached published-value branch (the default plain read) and `?preview=draft` publish no `version`, as they publish no `lock`; their `ETag` stays the cache validator and is not the token. + - **"Expect no row" can be said.** `If-None-Match: *` on `PUT /meta/:type/:name` (with `?mode=draft` for a draft) saves only where no row of that lifecycle exists, and is refused `409 METADATA_CONFLICT` once one does. This is the `parentVersion: null` pin `SaveMetaItemRequestSchema` already declared, now reachable over HTTP. The header takes `*` alone: any other value, or `If-None-Match` beside `If-Match`, is refused `400 VALIDATION_ERROR` and nothing is written. No first-party client sends `If-None-Match` on a `PUT`. A save with neither header is last-writer-wins, as before. + - **The conflict body names the current version as data.** The 409 of the `/meta` item write doors (save, publish, rollback, reset) now carries `currentVersion` beside the unchanged `error` sentence: the token the sentence names, or `null` when no row of the target lifecycle exists (declared as `MetadataConflictErrorSchema`). On the save door it equals the token the next read at the same address serves, so a client offering "reload" or "overwrite" re-pins without parsing prose. The SDK already exposes it as `err.details.currentVersion`. + - **The receipts' `version` describes are corrected.** `SaveMetaItemResponseSchema.version`, `PublishMetaItemResponseSchema.version` and the package publish door's `published[].version` said "Content hash … currently emitted as `sha256:`" and "409 `metadata_conflict`". The token has been the keyed `hmac-sha256:` digest of the stored content hash since the doors stopped serving the raw hash, and the conflict code on the wire is `METADATA_CONFLICT`. Only the description text moves. + + +- 3b49318: fix(meta): a second unpinned draft save of a package-owned item is no longer refused 409, the item read serves that draft's `version`, and a package-less draft save keeps a package-owned datasource's stored credential + + Clause-②: yes (widening) + + - **The second draft save is accepted.** Save an item into a package (`PUT /api/v1/meta/:type/:name?package=`), then save a draft of it without `?package=` twice. The repository stores that draft in the item's package (the draft inheritance that keeps a pending change in the package it edits). The save door took its expected parent from the package-unbound draft row instead. It found none, so the second save, which sent no `If-Match`, was refused `409 METADATA_CONFLICT` ("Expected parent null but current is hmac-sha256:…"). ADR-0008 makes an unpinned save last-writer-wins, and it is accepted again. + - **One head row per write address.** `SysMetadataRepository.headAt(ref, { state, packageId })` answers `{ head, packageId }`: the row a `put` at that address upserts (`null` for a create), and the package binding that put writes under. Both come from the same resolution `put`'s optimistic lock runs. That resolution includes the draft inheritance and the adoption of a draft stored without a package. The save door's expected parent and the item read's `version` both come from it, so the token `GET /meta/:type/:name?state=draft` serves is the token a draft save at the same address accepts. Before this, that read served `version: null` while the inherited draft existed. + - **`?package=all` on the item read.** `GET /meta/:type/:name?package=all` now reads `all` as the save and publish doors read it: as naming no package. Before, the read forwarded the literal `all`. Its `version` was resolved at a package no save writes, so it served `null` beside a row the save then judged. Its `?state=draft` read answered `404 NO_DRAFT` for a draft stored in the item's package. The read now serves the item as a read with no `?package=` serves it, and `?package=all` still takes the uncached arm, so it keeps serving `version`. + - **The credential carry-forward reads the same row.** A save of a type whose read withholds a credential (`datasource`; `flow` with the automation plugin) carries the stored credential back into a body that returns as it was served. It compared that body against the row at the key the caller named. A package-less draft save of a package-owned item therefore found neither the inherited draft nor the package's active row, and compared against the code layer. The stored credential was persisted away on the first such save, measured on the real route with a datasource whose `config.url` holds one. Both reads now go through `headAt`: the draft the save overwrites, or with no draft yet the active row in the package the draft is stamped into. An item with no package binding is unchanged. + - **Descriptions only, in `@objectstack/spec`.** `SaveMetaItemRequestSchema.packageId` now states the draft inheritance, where it said "absent = env-local overlay". The `POST /meta/:type/:name/publish` route description spells the conflict code `METADATA_CONFLICT`, as the wire sends it. + - A stale `If-Match` is still refused `409`, and `If-None-Match: *` is still refused while a row exists. An item with no package binding is unchanged. No spec key, request field or response field is added or removed. + - The only change to the public surface is one added method on the exported `SysMetadataRepository`: `headAt`. No existing signature, schema or accept set changes. +- bb4f5cc: fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, or any other root the server's option check does not bind (#22157) + + Clause-②: no (narrowing: `os build` and the object save door refuse a select option's `visibleWhen` that reads `parent`, which the runtime's option check cannot bind) + + A select option's `visibleWhen` is a gate the server enforces on write: the rule validator evaluates the predicate for the value a caller picks, and refuses the value when the predicate is false. That option check binds `record`, `previous` and the acting user (`current_user`, and its ADR-0068 aliases `user`, `ctx` and `os`), and nothing else. An option predicate that read `parent`, such as `parent.status == 'closed'`, still passed `os build` and the object save door. `parent` is a root the platform declares, and a field's own `readonlyWhen` and `requiredWhen` bind it on an object with exactly one `master_detail`. The option check does not bind it. So the predicate faulted on every write that picked the option, the server logged "the option's gate was NOT enforced on this write", and the value was admitted. + + The build's expression rule (`validateStackExpressions`) now gives a select option's `visibleWhen` a root verdict over the roots the option check binds. A predicate that reads any other root the platform declares, `parent` above all, is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the option, the field and the root. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a root other than `record`, `previous`, `current_user`, `user`, `ctx` or `os`. Besides `parent`, that covers the roots the platform declares for other evaluation sites, such as `input`, `vars`, `trigger`, `data` and `features`. Each of them faulted at the option check in the same way. + - An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + + **Remedy.** Rewrite the predicate against what the option check binds: `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`. To gate a choice on a master-detail header's state, read a column the detail object declares, and denormalise the header value onto the detail; `parent` is bound only for a field's own `readonlyWhen` and `requiredWhen`. Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind `parent` for an option, this refusal is lifted in that same change. + - The acting user is still accepted in an option's `visibleWhen` under all four ADR-0068 spellings, and so is a grant check such as `current_user.can('OBJECT', 'edit')`. On a field's own `requiredWhen`, `readonlyWhen` or `visibleWhen`, the acting user is still refused and `parent` is still accepted, as before. + - A root the platform does not declare at all, such as `app`, was already refused in an option's `visibleWhen` by the bare-reference check, and it still is, with the same message. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: every object this repository ships carries 5 option predicates, all on `showcase_cascade`: four `record.country` cascades and one `current_user.positions` role gate. That is over the 119 objects from its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them (33 objects). They have 0 refusals and 0 advisories, at the build and at the door, before this change and after it. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes: the expression rule already declared `object`. + + +- c512c25: feat(spec,metadata-protocol): each `GET /meta/_drafts` row carries the draft body's own `label`, or `null` + + Clause-②: yes (widening) + + - **What a client can now read.** Every row of the pending-drafts list (`GET /api/v1/meta/_drafts`, the runtime's `GET /metadata/_drafts`, and the SDK's `client.meta.listDrafts()`) carries `label`: the draft body's own top-level `label`. This list is the only place a client finds an item that exists only as a draft, so before this such an item could be shown by its machine name alone (a draft permission set read `technician`, not "Technician"). + - **Its shape.** `I18nLabel | null`, required on the wire. It is carried as authored: a plain string, or an inline locale map (`{ en: …, 'zh-CN': … }`) on the types whose `label` is an `I18nLabel`. The route takes no locale, so the reader resolves a map the way it resolves every other `I18nLabel` (`resolveI18nLabel` in `@objectstack/spec/ui`). + - **When it is `null`.** The body declares no `label`; it declares one `I18nLabelSchema` refuses (a row stored before its type's schema was enforced on save, or a row of a type with no registered schema); or its stored bytes do not parse, in which case the draft stays listed and every read of its body still fails. It is never the item name standing in for a missing label: a reader that wants a fallback chooses it, knowing the label is absent. + - **Where it comes from.** `SysMetadataRepository.listDrafts` reads it off the `sys_metadata` row it already fetches, so there is no second query, and `ObjectStackProtocolImplementation.listDrafts` passes it through. Of the stored body, only this one member leaves; field definitions and every other body key stay off the header. + - **Unchanged.** The other six members, the filters (`?packageId=`, `?type=`), the org scope, and the authoring gate on both routes. +- b1f7a7a: fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads a member of `ctx` or `os` the server's option check never binds, such as `os.org.id`, `os.env` or `ctx.locale` (#22274) + + Clause-②: no (narrowing: a select option's `visibleWhen` that reads an unbound member of a bound root is refused at build and at the object save door) + + A select option's `visibleWhen` is a gate the server enforces on write. The option check binds `record`, `previous` and the acting user, as `current_user` and its ADR-0068 aliases `user`, `ctx.user` and `os.user`. Under `ctx` and `os` it binds the `user` member and nothing else: it passes no organization and no environment. The build already refused a root the option check does not bind, such as `parent`, but it judged `ctx` and `os` as whole roots. So an option predicate that read `os.org.id != ''`, `os.env == 'prod'` or `ctx.locale == 'en'` passed `os build` and the object save door with no finding. On every write that picked the option the predicate then faulted (`No such key: org`, `env` or `locale`), the server logged "the option's gate was NOT enforced on this write", and the value was admitted. + + The build's expression rule (`validateStackExpressions`) now judges the members of `ctx` and `os` in an option's `visibleWhen`, in the same verdict that judges its roots. A member other than `user` is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the member, says that the option check binds only the `user` member under that root, and gives the remedy. Every spelling of the read is judged the same: `os.org`, `os.?org`, `os['org']` and `has(os.org)`. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a member of `ctx` or `os` other than `user`, such as `os.org.id`, `os.org.tier`, `os.env` or `ctx.locale`. + - An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + + **Remedy.** + + - For the caller's organization, compare `current_user.organizationId`, which the option check does bind: it holds the acting user's organization id, or `null` when the caller acts outside an organization. `os.org.id != ''` becomes `current_user.organizationId != null`. No other organization fact, such as its tier, is available to an option predicate; read a column the object declares instead. + - `os.env`, `ctx.locale` and any other member: rewrite the predicate against `record.FIELD`, `previous.FIELD`, or the acting user as `current_user`. + - Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind a member such as `os.org` for an option, this refusal is lifted for that member in the same change. + - The acting user is still accepted under all four ADR-0068 spellings (`current_user`, `user`, `ctx.user`, `os.user`), and so are its fields (`current_user.positions`, `ctx.user.id`) and a grant check such as `current_user.can('OBJECT', 'edit')`. + - The same members are still accepted where they are bound, such as `os.org.id` in a `formula` field's `expression`. The refusal is the option slot's alone. + - A computed key such as `os[name]` names no member, so it is not judged. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: the objects this repository ships carry 5 option predicates, all on `showcase_cascade`, which read `record` (four) and `current_user` (one). None reads `ctx` or `os`. That holds over every object in its `*.object.ts` files and the two `app-multi-package` sub-stacks, and over the example stacks as `defineStack` composes them. They have 0 refusals at the build and at the door, before this change and after it. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. + + +- 46692c1: fix(lint)!: `os build` and the object save door refuse a select option's `visibleWhen` that reads a member of the acting user the server's option check never binds, such as `current_user.roles` (renamed `positions`), `ctx.user.roles` or `current_user.email` (#22394) + + Clause-②: no (narrowing: a select option's `visibleWhen` that reads an `EvalUser` member the schema does not declare is refused at build and at the object save door) + + A select option's `visibleWhen` is a gate the server enforces on write. The option check binds the acting user under four spellings, `current_user` and its ADR-0068 aliases `user`, `ctx.user` and `os.user`, all one `EvalUser` object. That object carries the caller's `id`, `positions`, `isPlatformAdmin` and `organizationId`, and nothing else. The build already refused a root the option check does not bind (such as `parent`) and a member of `ctx` or `os` other than `user`, but it stopped there. So an option predicate that read `'admin' in current_user.roles`, `ctx.user.roles == ['a']` or `current_user.email == 'a@b.c'` passed `os build` and the object save door with no finding. `roles` has not been a member of the acting user since ADR-0090 D3 renamed it `positions`; `name` and `email` are declared by `EvalUserSchema` but the server never sets them for this check. On every write that picked the option the predicate then faulted (`No such key: roles`, `email`), the server logged "the option's gate was NOT enforced on this write", and the value was admitted. + + The build's expression rule (`validateStackExpressions`) now judges the members of the acting user in an option's `visibleWhen`, under all four spellings, in the same verdict that judges its roots and the members of `ctx` and `os`. A member is accepted only when `EvalUserSchema` (`@objectstack/spec`) declares it AND the option check binds it; the allowlist is read off the schema and off `@objectstack/formula`'s `buildScope`, never written out. Any other member is refused at `error` and located at the option (`object 'NAME' · field 'FIELD' option 'VALUE' visibleWhen`). The message names the member, names the members the acting user does carry there, and gives the remedy. Every spelling of the read is judged the same: `current_user.roles`, `current_user.?roles`, `current_user['roles']`, `has(current_user.roles)`, and the same below `ctx.user` and `os.user`. The object save door runs the same pass, so its verdict is the build's finding: the same rule id (`expression-invalid`), location, message and hint. + + **BREAKING — what moves for consumers.** + + - `os build`, `os validate` and `os lint` refuse an option `visibleWhen` that reads a member of `current_user`, `user`, `ctx.user` or `os.user` other than `id`, `positions`, `isPlatformAdmin` and `organizationId`, such as `current_user.roles`, `current_user.role`, `ctx.user.roles`, `current_user.email` or `user.name`. + - An object write in publish mode that carries such an option answered 200. It now answers `422 INVALID_METADATA`, with an `expression-invalid` issue located at that option. This covers `PUT /api/v1/meta/object/:name` (and `saveMetaItem` in publish mode), the promotion of a draft (`POST /api/v1/meta/object/:name/publish`, `publishMetaItem`), and a package draft publish (`publishPackageDrafts`). + + **Remedy.** + + - `roles` / `role` → `positions`: `'admin' in current_user.roles` becomes `'admin' in current_user.positions`, in whichever spelling the predicate used (`ctx.user.roles` becomes `ctx.user.positions`). + - `name`, `email` and any other member: rewrite the predicate against a member the option check binds (`current_user.id`, `current_user.positions`, `current_user.isPlatformAdmin`, `current_user.organizationId`), or against a column the object declares (`record.FIELD`, `previous.FIELD`). + - Saving the object as a draft (`mode: 'draft'`) is still allowed, because drafts are never gated; publishing that draft is judged. + + **Unchanged.** + + - The server's option check is unchanged. It binds what it bound before, and an option predicate that faults is still logged and admitted. If the runtime comes to bind a member such as `email` for an option, this refusal lifts for that member in the same change, because the allowlist is derived from what the option check binds. + - The same members are still accepted where they are bound, such as `current_user.email` in a row-level security policy or an object action's `visible` predicate. The refusal is the option slot's alone. + - A grant check such as `current_user.can('OBJECT', 'edit')` is a call, not a member read, and is still accepted. + - A computed key such as `current_user[name]` names no member, so it is not judged. + - Stored rows are not migrated, and they are not refused on read. An object stored before this change keeps loading until it is next saved, and that save is judged. + - `OS_ALLOW_UNLINTED_METADATA_WRITES=1` still turns a refusal into a logged write. + - Measured before crossing: the objects this repository ships carry 5 option predicates, all on `showcase_cascade`, which read `record` (four) and `current_user` (one, `current_user.positions`). None reads a member the option check does not bind. That holds over every object in its `*.object.ts` files and the two `app-multi-package` sub-stacks. They have 0 refusals at the build, before this change and after it. + - No public export or signature moves. `validateStackExpressions(stack)` keeps its signature, and no registry entry changes. + + +- 3ca71b6: fix(metadata-protocol)!: one reader of `OS_METADATA_WRITABLE` — the legacy `OBJECTSTACK_METADATA_WRITABLE`, removed in 11.0, no longer opens the hatch at the type listing either + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + This finishes 11.0's removal at the reader that kept it. The 11.0 release removed ObjectStack's own legacy environment-variable names, `OBJECTSTACK_METADATA_WRITABLE` among them. That change edited one of the two readers of `OS_METADATA_WRITABLE` (the metadata repository's write gate) and missed the other (the protocol's, which feeds `GET /api/v1/meta/types` and the protocol's write gates). So with only the legacy spelling set, the type listing reported a named type as writable (`allowOrgOverride: true`, `overrideSource: 'env'`), while creating a new item of that type answered `403 NOT_CREATABLE`. One deployment gave two answers to one question. + + **What changes.** The protocol now reads the setting through the repository's reader, so there is one reader and it reads `OS_METADATA_WRITABLE` only. With only `OBJECTSTACK_METADATA_WRITABLE` set, the hatch is shut everywhere: the listing reports no env override, and every write the hatch would open is refused as it is with nothing set. No deprecation warning is printed for the legacy name any more, because nothing reads it. + + **What does not change.** `OS_METADATA_WRITABLE` behaves exactly as before. + + **Operator action.** A deployment that still sets `OBJECTSTACK_METADATA_WRITABLE` sets `OS_METADATA_WRITABLE` in its place, with the same comma-separated type list. This is the rename 11.0 already published; nothing else changes. +- ad14957: fix(metadata-protocol): a create under a name a managed package or a built-in holds is told to choose another name, not to edit the source artifact (#22591) + + Clause-②: yes + + The public input surface widens: `packagedBaseRefusal` accepts `operation: 'create'`, and `tenantAuthoredWriteRefusal` takes an optional operation; no verdict, code or status moves. + + A write onto an item a managed package ships, on a type with no environment overlay, is refused with `403 NOT_OVERRIDABLE`. The refusal's remedy used to assume the write was an edit of that item: "Edit the source artifact and redeploy", or, for a flow, an action or a permission set, its clone or on/off switch. That remedy is wrong for a create. An administrator who creates a position of their own under a name a package or a built-in already holds (`manager`, `everyone`, `guest`, …), or renames one into such a name, cannot edit a source artifact they did not write, and did not ask to customize the package's item. + + The remedy now follows what the write did: + + - **A create, or a rename into the name**, is told: the name is taken; to author your own item, choose a name no package or built-in holds, and `GET /api/v1/meta/:type` lists the names in use. This is one sentence for every metadata type. + - **An edit** keeps the sentence it had, byte for byte. + + Which writes are refused does not change, and neither do the code and the status. + + Where the remedy is a create's: + + - **Setup** (`@objectstack/plugin-security`): a create of a `sys_position` row, or a rename into a name a package or a built-in holds, under the `single` posture. The data door relays the metadata door's refusal for a create. + - **The metadata door** (`PUT /api/v1/meta/:type/:name`): a save sent with `If-None-Match: *`, the first-write precondition (`parentVersion: null` in `saveMetaItem`). Any other save is an edit, as before. + - **`ObjectStackProtocolImplementation.packagedBaseRefusal`** takes `operation: 'create'` beside `'save'` and `'delete'`, for a door that knows its write takes a name. The verdict is the `'save'` verdict. `tenantAuthoredWriteRefusal` takes an optional `operation: 'create'` the same way. +- c63028e: fix(metadata-protocol)!: the cross-object search asks the spec's exposure decision for `search` on each object, so an `apiMethods` whitelist that withholds `list` keeps the object out of the sweep (#22640) + + Clause-②: no + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The REST data routes, the runtime dispatcher, the MCP data tools and the analytics door judge an object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`). In the derivation table, `search` derives from `list`. The cross-object search (`GET /search`, `searchAll`) spelled its own skip instead, and that skip read only `apiEnabled: false` and `searchable: false`. + + **FROM.** The search swept an object whose `apiMethods` whitelist does not grant `list` (for example `['get']`, or the deny-all `[]`), unless the object also declared `searchable: false` or `apiEnabled: false`. It served that object's rows while every data route refused them. + + **TO.** Before an object is queried, the search asks `canServeApiOperation(enable, 'search')`, and an object it refuses is skipped. A skipped object is never queried, named or counted. An explicit `objects=` naming it answers like one naming no object at all. + + - **Every caller.** The decision takes no user, so administrators and context-less in-process calls skip the same objects. + - **`searchable: false` keeps its own skip.** The decision answers an object with no whitelist without reading that flag. + - **Unchanged.** An object with no whitelist, or with one that grants `list`, is swept exactly as before. + + **Measured producers.** Read on `origin/main` `cb3bb933`: every object in this repository whose whitelist withholds `list` also declares `searchable: false` or `apiEnabled: false`, and no example app declares `apiMethods`. So no shipped object's search results change. Deployed and cloud-held object definitions were NOT MEASURED. +- 615cba8: fix(objectql,metadata-protocol)!: the data door honours a field's `internal: true` in its filter (related-object conditions included), sort, group-by, aggregate, per-aggregation-filter, `$search` and `$expand` positions at every depth, not only the row (#22646) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A field declared `internal: true` is withheld from every generic exit (#21197): the engine omits it from every row, select-named included. The ROW position honoured that; the generic data door's EVALUATE positions did not, so a value the platform withholds from rows could still be learned through a position that answers by its stored value. + + **FROM.** On `origin/main`, for a member holding read and for an administrator alike, on the list and query data routes (`GET /api/v1/data/:object`, `POST /api/v1/data/:object/query`): + + - A **filter** naming such a field was admitted — the predicate reached the driver, so the row came back only when the guess matched: a confirmation oracle over a withheld value. + - A **sort** by such a field was admitted — the order leaks the comparative stored value. + - A **group-by** of, or an **aggregate operand** over, such a field was refused by the engine, but as an undeclared `500 INTERNAL_ERROR` (a bare `Error` with no `code` or `status`), not a declared refusal. + - A **per-aggregation filter** (`aggregations[].filter`) naming it, and a **filter beside an aggregation**, were admitted — the same oracle at a second filter position. + - A **`$search`** whose resolved set included such a field (a non-hidden `internal` text column enters the auto-default) scanned it — a cross-field substring match that confirms a guessed prefix. + - A **nested-relation condition** (a condition on a lookup's related object) naming the related object's `internal` field was evaluated on that object — the parent row came back only when the related value matched, the same oracle one hop away. + - An **`$expand`** entry's own `where` / `orderBy` on its TARGET object's `internal` field was admitted into the expansion sub-read, at the first level and at every deeper level — the expanded record's presence is an oracle on the related row. + + **TO.** Each evaluate position is refused `400 INVALID_FIELD`, a declared ADR-0112 envelope naming the object and the field, before the engine is asked: + + - Filter, sort, group-by, aggregate operand and per-aggregation filter are refused at the generic data door (`@objectstack/metadata-protocol`), after the field-existence gates, in the shape and envelope of the stored-metadata body/hash family one axis over. A nested-relation condition inside any filter is judged against the RELATED object's own `internal` fields, and every level of an `$expand` tree has its own filter / sort judged against that level's target object; the refusal names the object that declares the field. + - The engine's `aggregate()` refusal (`rejectCredentialAggregation`) now carries the `INVALID_FIELD` / 400 envelope instead of the bare `Error`, so the group-by and aggregate-operand positions answer a declared `400` rather than a `500` — and the `secret` / `password` case that shared that bare `Error` is upgraded with it. + - A `$search` never scans an `internal: true` column: the field is dropped from the set the engine's search expansion resolves over (a withhold, like the auto-default's `hidden` / credential-type exclusions), and an explicit `$searchFields` naming one is refused `400 INVALID_FIELD` at the door rather than silently widened back to the default set. + + **Bindings.** + + - **Every caller.** The refusal is at the generic data door and takes no user, so an administrator is refused exactly as a member is (#21197). There is no system carve-out at the door. + - **The engine's privileged consumers are untouched.** The API-key verifier's `where: { key: }` lookup, the share-link / SCIM / approval-token lookups — all call the engine directly under a system context and never pass the generic data door, so authentication keeps working, exactly as #7823 kept the engine's write results whole while the generic-data-path ingress stripped them. + - **One flag reader.** Every position judges `collectInternalReadFields` (engine) or its byte-identical `@objectstack/core` twin `collectInternalWriteResponseFields` (the door, which sits below objectql), never a second list. + - **Unchanged.** An ordinary field in every position is served as before; an object that merely has an `internal` field is served for every other field. + + **Scope.** Target-OBJECT exposure through `$expand` is a separate axis (#22661); the cross-object search sweep (`searchAll`) is #22640's region and inherits the engine's search-set withholding through `engine.find` without a change to its own code. +- 3b5475a: fix(metadata-protocol,service-analytics)!: a read that follows a lookup asks the TARGET object its declared exposure — the data door's `$expand` at every level, and the dataset door's dimension-label passes (#22661) + + Clause-②: no + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The data routes judge the ADDRESSED object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`): `enable.apiEnabled: false` answers `404 OBJECT_API_DISABLED`, and an `enable.apiMethods` whitelist that does not grant the verb answers `405 OBJECT_API_METHOD_NOT_ALLOWED`. A read that FOLLOWS a lookup reaches a second object nobody addressed, and two such reads never asked it. + + **FROM.** For an administrator and a member alike: + + - **`$expand`** — `GET /api/v1/data/:object?$expand=`, `GET /api/v1/data/:object/:id?expand=`, the relation map on `POST /api/v1/data/:object/query` (every level of the tree), and the export door (which expands every lookup to name it) served the row fields of a lookup's target whose declaration refuses it on every data route. + - **Dataset dimension labels** — `POST /api/v1/analytics/dataset/query` rendered a reference-class dimension's grouped id as the target record's display name, and sorted an `order` on that dimension by those names, for the same targets. + + **TO.** Each read asks the spec's one decision of the TARGET object, for `get` — the read both perform is "turn an id the caller holds into the record it names", the read `GET /api/v1/data/:target/:id` performs: + + - An `$expand` entry whose target the decision does not serve is withheld at the generic data door (`@objectstack/metadata-protocol`), at every level: the field answers as an UNEXPANDED lookup, its stored id — the answer the door already gives for a related record the caller may not read. Nothing below a withheld entry is read. + - A dimension label is not read for such a target (`@objectstack/service-analytics`): the stored id renders, and an `order` on the dimension sorts by it — the answer a target the reader's row scope hides already gets. A declaration that cannot be read withholds the labels too (fail-closed, at `warn`); the query itself still answers. + + Concretely, a target declaring `apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `get` (for example `['list']` or `['create']`) is withheld from both; a target with no `enable` block, or a whitelist that grants `get`, is served exactly as before. + + **Bindings.** + + - **Every caller.** The decision takes no user, so an administrator, a member and a system context get the same tree and the same labels. + - **The engine's privileged callers are untouched.** A flow's `config.expand` and a hook's own reads call the engine directly and never pass the generic data door, the same split the write results and the `internal: true` evaluate positions keep. + - **No new error code.** Both reads withhold rather than refuse, so no response status changes. + + **Measured producers.** Read on `origin/main` `eae3368a` over every `.ts` source under `packages/` and `examples/`: ten objects refuse `get` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false`. No example declares `enable.apiEnabled` or `enable.apiMethods`. So no shipped object's served answer changes. Deployed and cloud-held object definitions were NOT MEASURED. +- 490cb6d: A write answer now carries the advisory validation-rule hits of that write as `warnings` + + Clause-②: yes (widening) + + A `severity: 'warning'` or `'info'` validation rule never blocks a write. Until now its hit was only logged on the server, so no client could show the person who saved what the rule says. A create, an update or a clone now answers the hits of that one write, and the validate-only preview reports the same entries. + + - **Write answers.** `CreateDataResponseSchema`, `UpdateDataResponseSchema` and `CloneDataResponseSchema` declare an optional `warnings`: one entry per advisory rule hit, carrying the rule's `name` as `rule`, its `severity`, the `field` it is about, the finding `code` and the author-written `message`. The key is absent when there is no hit, so an existing client's reading of the answer does not move. REST relays it in the response body unchanged. The write's status and record are unchanged, and an `error` rule still refuses the write. + - **One element, shared with the preview.** `ValidateDataIssueSchema` gains optional `rule` and `severity`, set only on an advisory rule hit. It is the element of the new `warnings`. `validateData` appends the advisory hits of the same evaluation to each accepted row's existing `results[].warnings`, after the value-shape findings the deployment admits. An import's dry run copies that array to the row verbatim, as before. + - **Engine.** `WriteObservabilityOptions` gains `onValidationAdvisory`, an in-process listener beside `onFieldsDropped`, with its event schema `ValidationAdvisoryEventSchema`. The engine calls it once per advisory hit on the write it was passed to: per row on `insert`, and on `update` by id and per matched row of a `multi` update. A nested write a hook or a flow makes inside that write carries its own options, so its hits never reach the outer write's listener or answer. `evaluateValidationRules` returns its advisory hits (an empty array when there are none) instead of `void`. + - **Unchanged.** Rule semantics, the per-write `warn` log line and the seed and boot load's one summary line per rule are all as before. No authorable key is added: `ValidationRuleSchema` is untouched. A rule whose condition could not be evaluated is not a hit. Its fault text is for the operator, so it stays in the server log only. + - **Client.** `CreateDataResult`, `UpdateDataResult` and `CloneDataResult` declare `warnings?: ValidateDataIssue[]`. + + **For authors.** Nothing to change in metadata. Write each advisory rule's `message` for the person saving the record, because a client can now show it to them. +- ec7c7e0: fix(metadata-protocol)!: a nested-relation filter condition asks the related object its declared exposure, and is refused when that object does not serve `list` (#22737) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A nested-relation filter condition, `{ : { : … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one. + + **FROM.** On `GET /api/v1/data/:object?filter=…` and `POST /api/v1/data/:object/query`, for an administrator and a member alike, such a condition was evaluated whatever the related object's `enable` block declared. A source row came back only when the related value matched, even for a related object every data route refuses. + + **TO.** The generic data door asks the spec's one exposure decision (`apiExposureDenialReason`) of the related object, for `list`, the operation `GET /api/v1/data/:target?filter=…` is judged as. A condition is a query of the related object, not a read of a record whose id the caller holds, so a `get`-only whitelist does not serve it. A related object the decision does not serve is refused, with the data door's own exposure answer: + + - `enable.apiEnabled: false` → `404 OBJECT_API_DISABLED`. + - an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `405 OBJECT_API_METHOD_NOT_ALLOWED`, with the effective operation set in `allowed`. + + The refusal names the related object and the condition's position. It applies wherever the door walks a filter: `where`, each aggregation's `filter`, and an `expand` entry's own `where` at every level. + + **Migration.** Filter on the lookup's stored id (`{ : '' }` or `{ : { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`. + + **Bindings.** + + - **Every caller.** The decision takes no user, so an administrator, a member and a system context get the same answer. + - **Refused, not withheld.** Dropping a condition would widen the result. A related object the caller may not read already answers this position with a refusal (`403`). + - **The engine's privileged callers are untouched.** A flow's or a hook's own query calls the engine directly and never passes the generic data door. + - **No new error code.** `@objectstack/metadata-protocol` now stamps the two existing exposure codes, and the error-code ledger lists them under that package too. +- 038d4c9: feat(metadata-protocol): one language of an email template is read, reset, published, viewed in history and reverted by name — the by-name `/meta` doors accept a bundle member selector (#22877) + + Clause-②: yes + + **What an administrator can now do.** An email template kept in several languages is one item per language in the store. Each language can now be addressed by name on every by-name door, with `?member=` naming the language: + + - `GET /api/v1/meta/email_template/:name?member=zh-CN` reads the `zh-CN` wording: the stored customization when there is one, else the package's `zh-CN` template. A language that nothing stores or ships answers as an absent item (404). + - `DELETE /api/v1/meta/email_template/:name?member=zh-CN` resets the `zh-CN` customization alone; the other languages keep theirs. + - `POST /api/v1/meta/email_template/:name/publish?member=zh-CN` publishes the `zh-CN` draft. + - `GET /api/v1/meta/email_template/:name/history?member=zh-CN` lists the `zh-CN` saves. All languages of one template share one version counter, so the version numbers of one language are not consecutive. + - `POST /api/v1/meta/email_template/:name/rollback?member=zh-CN` reverts the `zh-CN` customization to one of its own versions. A version saved for another language answers `404 VERSION_NOT_FOUND`. + + With no `?member=`, every door addresses the canonical member (`en-US`), exactly as before; naming `en-US` is the same request. This closes the window the previous release's changeset stated: a non-`en-US` language could not yet be reset, published, listed or reverted by name. + + **The selector.** It is named `member`, after what the store keys a row by, and not `locale`: on `/meta`, `?locale=` already chooses the language the response is translated into. The protocol requests gain one optional field, `member`, on `GetMetaItemRequest`, `DeleteMetaItemRequest`, `PublishMetaItemRequest` and `HistoryMetaItemRequest`. A wrong value is refused with `400 VALIDATION_ERROR`, naming the parameter: the selector on a type whose items have no languages (every type except `email_template` today), a blank value, or a value longer than a stored member can hold (100 characters). + + **Each of the five routes now declares its query parameters.** `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` refuse a query parameter they do not read with `400 VALIDATION_ERROR`, naming the parameter and listing the supported ones, instead of ignoring it. A misspelled `?memeber=zh-CN` is therefore refused, not answered with the `en-US` wording. The supported names are the ones each route has always read, plus `member`: + + - `GET /meta/:type/:name`: `layers`, `state`, `preview`, `package`, `locale`, `member`. + - `DELETE /meta/:type/:name`: `state`, `dropStorage`, `member`. + - `GET /meta/:type/:name/history`: `sinceSeq`, `limit`, `member`. + - `POST /meta/:type/:name/publish`: `package`, `member`. + - `POST /meta/:type/:name/rollback`: `toVersion`, `member`. + + A client that sent any other parameter to these routes was having it ignored; it now gets a 400 and can drop it. The deprecated `?layers=` view reads no language, so `?layers=` together with `?member=` is refused rather than answered with the `en-US` layers. A read naming a member skips the shared read cache. + + **What else now follows the language** (`@objectstack/objectql`, `@objectstack/metadata-protocol`): + + - A by-name read that names no language answers the `en-US` template even when only another language is customized. Before, the registry's read of the name could answer the other language's customization. + - Resetting one language keeps the other languages' customizations served at once, not only after the next restart. + - Publishing, discarding and reverting a package's changes (`POST /packages/:id/publish-drafts`, discard, and commit revert) address each language's draft or change as its own item. Before, a package whose only pending draft was a second language failed to publish (`NO_DRAFT`), and a discard reported that draft discarded while it stayed. The draft list and the commit timeline keep the fields they declare. + - In a cluster, a save, reset or revert of one language converges the other servers on that language. + + **For code that uses these packages directly.** Every addition below is optional; code that does not pass it behaves as before, except the bare-name read noted in the first item. + + - `SchemaRegistry` (`@objectstack/objectql`) takes an optional last argument, `member`, on `getItem`, `getArtifactItem`, `removeRuntimeShadow` and `removeOverlayEntry`. It is the discriminator value of one bundle member, such as `'zh-CN'`. + - `getItem(type, name, packageId, member)` answers that member, in the usual order (customization, then the requested package, then any package), or `undefined`. With no `member`, `getItem` now answers the canonical (`en-US`) member of a bundle. A bundle with no `en-US` member still answers its first member. + - `getArtifactItem(type, name, packageId, member)` answers that member's packaged definition. + - `removeRuntimeShadow(type, name, member)` and `removeOverlayEntry(type, name, member)` remove that member's customization entry only. With no `member`, they remove the whole bundle's customization entries, as before. + - A type whose items have no languages ignores the argument. + - `MetadataMutationEvent` (`@objectstack/metadata-protocol`) has an optional `member`: the language the mutated row belongs to. It is present only for a non-`en-US` language. Listeners registered with `onMetadataMutation` receive it. It also rides `ClusterMetadataMutationPayload`, and a server that receives it re-reads that language's row. + - The entries that `SysMetadataRepository.listDrafts` returns have an optional `member`, present for a draft of a non-`en-US` language. The protocol's draft list (`GET /meta/_drafts`) and a package's commit timeline serve it too, on every entry of a template, with the canonical `en-US` spelled out. +- 5f37d36: feat(spec)!: at most one `isDefault: true` record page per object per deployment — a second is refused at `defineStack` / `os validate`, at package registration and at metadata save, naming both pages (#23015) + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + + `PageSchema.isDefault` now states the rule (maintainer ruling A on #23015): at most one `isDefault: true` record page may exist for an object in a deployment. The record view opens that page with no tie-break, so with two defaults for one object the page that opened used to be whichever plugin initialised first — an accident no author wrote. A record page is `type: 'record'` (the default when `type` is left out) bound to an `object`; `isDefault` on any other page is outside the rule. + + **What is refused, and where.** Every door that can add a second default refuses it, naming both pages: + + - **`defineStack`** (and so `objectstack validate`, `objectstack build` and a source boot) — two default record pages for one object inside one stack, `STACK_CROSS_REFERENCE_INVALID` (422), one finding per object naming the object, every page and its position (`stack.pages[0]`). **`composeStacks`** refuses the pair across the stacks it composes, in the same envelope; under `manifest: 'preserve'` one page name in two stacks is two packages' pages. + - **Package registration** (`@objectstack/objectql`'s `SchemaRegistry`, at `installPackage` ahead of every mutation and at the `registerItem` seam) — a package whose default record page meets another package's default for the same object, an environment's stored default, or a second default in its own manifest, `DEFAULT_RECORD_PAGE_CONFLICT` (422, exported as `DEFAULT_RECORD_PAGE_CONFLICT_CODE`). Two packages' pages under one name are two pages and are refused too. `OS_METADATA_COLLISION=warn` does not soften it. + - **Metadata save** (`@objectstack/metadata-protocol`'s `saveMetaItem`, so `PUT /api/v1/meta/page/:name`) — a page saved as the default record page of an object a stored page or a packaged page already holds, `DEFAULT_RECORD_PAGE_CONFLICT` (422), in draft and publish mode, before anything is written. + + **Unchanged.** A save under the existing default's own name is that page (an ADR-0005 overlay by name), not a second default. One default per object, a second record page that is not a default, and two objects with one default each are accepted. The same package registering again — a reload, or an upgrade that moves its default to another of its pages — is one holder. A save under the name of a page a package ships is still answered by the packaged-base refusal (`NOT_OVERRIDABLE`, 403), as before. A stored page reaching the registry from `sys_metadata` (hydration, write-through) is not refused again. + + ## FROM → TO + + | door | FROM | TO | + |:--|:--|:--| + | `defineStack` / `objectstack validate` | `pages: [{ name: 'account_a', type: 'record', object: 'crm_account', isDefault: true }, { name: 'account_b', type: 'record', object: 'crm_account', isDefault: true }]` accepted; the boot kept both and the record view opened whichever registered first | refused, `STACK_CROSS_REFERENCE_INVALID` 422 — set `isDefault: false` on all but one | + | `composeStacks` | two stacks each shipping a default record page for one object composed clean | refused in the cross-reference envelope, naming both stacks' pages | + | package registration | a second package (or a plugin's `manifest.register`) shipping a default record page for an object another package holds registered; init order decided the page | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, naming both pages; keep one default, or compose the deployment without the package that ships the other | + | metadata save | `PUT /api/v1/meta/page/my_page` with `isDefault: true` for an object that already has a default record page stored or shipped saved | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, naming both pages; save it with `isDefault: false`, or first demote the stored default | + + **The one-line fix: keep `isDefault: true` on the one record page per object that should open the record view, and set `isDefault: false` on every other.** To replace a page a package ships, compose the deployment without that package's page; shipping a second default is not an override path. + + No D2 conversion rewrites it: which page stays the default is the author's call. Its D3 record is the semantic entry `page-default-record-page-one-per-object-refused` (protocol 18). + + **`@objectstack/spec`.** `defaultRecordPageObject(page)` (exported from `@objectstack/spec/ui`) is the one reading of "which object is this page the default record page of" that the three doors share. `PageSchema.isDefault` gains its `.describe()`. `DEFAULT_RECORD_PAGE_CONFLICT` joins the error-code ledger under `@objectstack/objectql` and `@objectstack/metadata-protocol`. + + **`@objectstack/objectql`.** The registration refusal, `SchemaRegistry.defaultRecordPageHoldersOf(object)` (every page holding the object's default — the reading the save door meets with the stored pages) and the `DEFAULT_RECORD_PAGE_CONFLICT_CODE` constant. + + **`@objectstack/metadata-protocol`.** The save-door refusal. + + **`@objectstack/platform-objects`.** The page form's `isDefault` help text, which said "Set as default page for this page type" — a rule nothing enforced — now states the one-default-per-object rule, in all four locales. + + **Who is affected, measured.** In this repository every object with a default record page has exactly one: `sys_organization` and `sys_user` (`@objectstack/plugin-auth`), `sys_position` (`@objectstack/plugin-security`), `sys_approval_request` (`@objectstack/plugin-approvals`), and `showcase_project`, `showcase_task` and `showcase_preference` (`examples/app-showcase`). A deployment that also composes a package shipping its own default record page for one of those objects is refused at boot until one of the two pages stops being a default. +- 252a582: feat(spec, metadata-protocol): the pending-drafts list and a package's commit timeline tell two languages of one email template apart — each entry of a template carries `member` (#23073) + + Clause-②: yes (widening: a new declared response key) + + **What changed for a reader of the two lists.** An email template kept in several languages is one item per language: two languages of `acme.welcome` are two drafts, and two items of one commit. Until now `GET /api/v1/meta/_drafts` and `GET /api/v1/packages/:id/commits` answered them as two entries with the same `type` and `name`, so a reader could not tell which entry was which language, and could not address the second one. + + - Each entry of `GET /meta/_drafts` (`ListDraftsResponseSchema`) and each item of a commit in `GET /packages/:id/commits` (`ListPackageCommitsResponseSchema`) now carries an optional `member` for a type whose identity is a pair (today only `email_template`, identified by `(name, locale)`). It is the language the entry is: `zh-CN`, and `en-US` for the canonical language, spelled out rather than left blank. Two languages of one template are therefore two entries with different `member` values. + - `member` is present on every entry of such a type and absent on every other type, so the lists are unchanged for every type whose identity is its name alone. + - The value is the one the by-name doors take back as their `member` selector: `GET /api/v1/meta/email_template/acme.welcome?state=draft&member=zh-CN` reads the `zh-CN` draft a list entry names, and `?member=` on publish, history, revert and reset addresses it the same way. For a commit item, `member` is the language the stored revert plan records, the one a revert of that commit acts on. + + **For code that uses these packages directly** (`@objectstack/metadata-protocol`): `ObjectStackProtocolImplementation.listDrafts` entries and `listCommits` items have the optional `member` described above. The types `ListDraftsResponse` and `ListPackageCommitsResponse` (`@objectstack/spec/api`, which `@objectstack/client`'s `meta.listDrafts` and `packages.listCommits` return) gain the same optional field. Code that does not read it behaves as before. +- e5f9f01: fix(metadata-protocol)!: the metadata save door refuses a `protection` block with a located `400 VALIDATION_ERROR` instead of storing a lock that binds nothing (ADR-0010 §3.5, Phase 4.3) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `protection` is the package author's lock declaration. ADR-0010 states one door for it: a code package's loader translates it into the item's private lock envelope when the package loads, and strips it (Phase 4.3). An item saved through the metadata API is authored in the environment, and ADR-0010 §3.5 states that such an item takes no package lock. + + **This entry supersedes one sentence of the entry `15213-unmanaged-lock-strip`, in this same release.** That entry says a malformed `protection` block is not stripped and "the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back". What holds now: on a type whose schema declares `protection`, an item the unmanaged install hands on with its block still in it (a malformed block, or a block beside a `_lock*` value its declaration refuses) meets this entry's door refusal, `400 VALIDATION_ERROR`, and the install is still rolled back. An item carrying a refused `_lock*` value and no block, and a block on a type that does not declare one (`action`, `hook`, `page`), still answer `422 INVALID_METADATA`. Where the two entries disagree, this one is current. + + **FROM.** `PUT /api/v1/meta/{type}/{name}` with a `protection` block answered `200` and stored the block verbatim. The item was then served with `protection.lock: 'full'` while it read `lock: 'none'`, `editable: true` and `deletable: true`, and a second save and a delete both answered `200`. Measured on the showcase for an `app` and a `dashboard`. An author, or an AI, reading that item was told it was protected when anyone holding the authoring capability could change or delete it. + + **TO.** The same save answers `400 VALIDATION_ERROR` and writes nothing. The message names the block and the door that does honour it, and the envelope's `issues[]` carries `protection` as the path, so the Studio designer can highlight it. + + - **Every type that declares the block**, in draft and in publish mode, for a new item and for an overlay of a packaged one. A malformed block is refused the same way, because the block is not accepted at this door at all. On a type whose schema does not declare `protection`, the schema's own `422 INVALID_METADATA` answers, as before. + - **Every authoring write**: the REST and dispatcher saves and the domain doors that save through them. The unmanaged install already drops a block it can read before it saves a copy, and a malformed one is refused here. + - **Server-side rewrites strip it.** A row stored before this change may carry the block. When the server itself rewrites such a row (`os migrate meta --stored --apply`, the stored flow credential move, package duplication), it drops the block instead of failing the rewrite: the block never bound, so nothing changes but the stored bytes. + - **Unchanged.** A body without `protection` saves as before. A packaged item's lock still binds: its loader translated the block at load time, and a save against it still answers `403 ITEM_LOCKED`, or `403 NOT_OVERRIDABLE` on a type with no environment overlay, before this refusal is asked. A row stored before this change keeps its bytes. + - **Package export.** `GET /packages/:id/export` copies a writable base's rows into a manifest, so a block saved into a base row rode into that manifest, where the package loader translates it on install. A block can no longer be saved into a base row. Declare it in the manifest you install instead. + - **Remedy.** Delete the `protection` block from the body you send to the metadata API. To lock an item, declare `protection` on it in a code package's source. + + `@objectstack/spec`: the `protection` TSDoc on every type that mounts it (app, view, dashboard, report, dataset, object, flow, webhook, agent, tool, skill, position, permission set, email template) now says that only the package loader translates the block, and that the metadata API's save door refuses it. +- aa5bfdf: fix(metadata-protocol)!: the metadata save door never sets, changes or removes an item's lock: a body's `_lock*` family that would do so is refused with a located `400 VALIDATION_ERROR`, an echo of the served lock is accepted and ignored, and the row keeps the lock it stores (ADR-0010 §3.5, §4.1) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The `_lock` family (`_lock`, `_lockReason`, `_lockDocsUrl`, `_lockSource`) is the runtime's private lock envelope. A code package's loader stamps it from the package's `protection` block, and an install or a migration's re-save carries a stored one. ADR-0010 strips it on write and reattaches it on load (§4.1), and an item saved through the metadata API is authored in the environment, which takes no package lock (§3.5). + + **This entry supersedes one sentence of the entry `23096-save-door-protection`, in this same release.** That entry says, under its unchanged behaviour, "A body without `protection` saves as before". What holds now: a body without `protection` saves as before only when it also sends no `_lock*` key, or sends back exactly the `_lock*` family a read of that item serves; a body whose `_lock*` family would set, change or remove the item's lock answers `400 VALIDATION_ERROR` and writes nothing, as this entry states. Where the two entries disagree, this one is current. + + **FROM.** `PUT /api/v1/meta/{type}/{name}` stored a body's `_lock*` family verbatim. A hand-written `{ "_lock": "full" }`, or one an author or an AI copied from another item's served body, answered `200`, and the item then read `lock: 'full'`, `editable: false`, `deletable: false`: its next save and its delete both answered `403 ITEM_LOCKED`, and no door unlocks it. Any holder of the authoring capability could make an environment item permanently uneditable and undeletable. The other way round, an author save that sent no `_lock*` key dropped a lock the platform had stamped on the row, so a `no-delete` stopped refusing the delete. + + **TO.** An author's save keeps the lock family the row it writes already stores. + + - **No `_lock*` key in the body.** The save goes through, and the row keeps its stored lock. A body that says nothing about the lock does not remove it. + - **An echo is accepted and ignored.** Studio and the AI authoring surfaces send the served body back on every edit, so a family equal to one a read of the item serves passes: the family the item read, the layered read's `effective` and the list serve (the item's resolved lock), or the family stored on the row the save writes (what the draft read and the layered read's `overlay` layer serve). The row still keeps its own stored family, so an echo of a packaged item's lock on its first overlay stores none in the environment row; the package's lock still binds. + - **Anything else is refused**, `400 VALIDATION_ERROR`, and nothing is written: a family that would set a lock (on a new item, or on one that has none), change one (its state or its prose) or remove one (`_lock: 'none'`, or a partial echo that drops part of it), and a family copied from another item. The message names each `_lock*` key sent and the door that does lock an item, and `issues[]` carries each key as a path, so the Studio designer can highlight it. + - **Every authoring write**: the REST and dispatcher saves, draft and publish, the external-table import, the unmanaged install, and the domain doors that save through them (the flow and permission-set clones, the permission-set and position write-throughs, the package publish's app flip). A type with no schema (`external_catalog`) is judged too. + - **Unchanged.** A `_lock*` value the type's declaration refuses (`_lock: 'locked'`, a non-string reason) is still the schema's `422 INVALID_METADATA`, so the unmanaged install's refusal of such an item does not move. The server-side rewrites (`os migrate meta --stored --apply`, the stored flow credential move, package duplication) keep the family their body carries. A packaged item's lock still binds, and a save against a locked item still answers `403 ITEM_LOCKED` first. No lock stored today is released. + - **Remedy.** Delete the `_lock*` keys from the body you send to the metadata API, or send them back exactly as a read of that item served them. To lock an item, declare `protection` on it in a code package's source, where the package loader translates it into the item's lock. +- 543b33d: fix(metadata-protocol)!: a metadata save or delete that names no package addresses the row of the one package the item belongs to, and a name two or more packages hold is refused `409 RESOURCE_CONFLICT`; the delete door can name its 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 one item can hold one row per package. A save that names a package (`?package=` on the REST door, `packageId` in process) writes that package's row. A save that named none always wrote the package-unbound row, even for an item a package owns, and a delete named no package at all: it removed whichever row of the item the store returned first. The draft save already resolved this: a draft that names no package is stored in the package of the item's active row. + + **FROM.** Save an item into a package (`PUT /api/v1/meta/app/my_app?package=com.acme.app`), then save it again without `?package=`: the second save answered `200` "Saved app 'my_app' (env-wide, state=active)" and wrote a second, package-less row beside the package's row. The item read kept serving the package's row, so nothing the save carried took effect, and a console "Disable" reported success while the app stayed enabled. The same held for the API, the CLI and agents, and for an overlay row bound to a code package. A `DELETE /api/v1/meta/app/my_app` of an item two packages hold removed one of the two rows, the other was then served, and the receipt said "it no longer exists". Uninstalling a package, or discarding its drafts, deleted each of its items by name the same way, so it could remove another package's row of the same name and leave its own. + + **TO.** A direct save, or a delete, that names no package resolves its address from the item's rows (the active rows for a save and a delete, the draft rows for a draft discard): + + - **Bound to one package.** The save writes that package's row, exactly as a save naming the package does, and the item read serves it next; the delete removes that row. The receipts name the package: "Saved app 'my_app' (env-wide, package=com.acme.app, state=active)" and "Deleted app 'my_app' (package=com.acme.app) — it no longer exists." Every check on the row the save writes reads that package: its stored lock, a stored credential carried forward, the optimistic lock, and the writable-base rule. The item read that names no package serves that row's `version`, so a read followed by a save or a delete with the served `If-Match` is accepted. + - **Bound to two or more packages.** The save or delete is refused `409 RESOURCE_CONFLICT` and changes nothing. The message names the packages and the remedy: name one with `?package=`. + - **Bound to none.** The save writes, and the delete removes, the package-unbound row, as before, and the receipts read as before. + + The delete door gains the address a save has: `DELETE /api/v1/meta/:type/:name?package=` (`packageId` in process, `DeleteMetaItemRequestSchema.packageId`) removes that package's row and no other; `all` and an empty value name no package, as on the save door. A repeated `?package=` is refused `400`. A delete receipt now says when another row of the item is still stored, naming it, instead of claiming the item is gone or back at its artifact default. Uninstalling a package and discarding its drafts name the package, so each removes only its own rows. + + **Also refused now**, each as the same save naming the package is refused: a package-less save of an item held by a read-only package (a code package's runtime-only item, or an installed `system`/`cloud` package's) answers `422 WRITABLE_PACKAGE_REQUIRED`; and a package-less save asserting a create (`If-None-Match: *`) of an item a package holds answers `409 METADATA_CONFLICT`. + + `packageId: null` in process names no package too, as the request contracts state. The server-side rewrites keep the row they name: `os migrate meta --stored --apply` and the stored flow credential move pass `null` for a package-unbound row they rewrite, and that row is still the one written; package duplication names its target package. + + **Unchanged.** A save or a delete that names a package exactly. A draft save, which keeps its own inheritance. An item a code package ships with no stored row: the package-less save still writes the package-unbound overlay, and the read serves it. Package-less rows already stored beside a package's row stay as they are; none is converted. A package-less delete of such an item removes the package's row and its receipt says the package-less row remains. + + **Remedy.** For an item two packages hold, send the save or the delete with `?package=` naming the package whose row it edits or removes. A receipt now names the package when the write addressed a package's row; a client that compares receipt text should expect `, package=` after `env-wide` on a save and ` (package=)` after the item on a delete. + + `@objectstack/spec`: the `SaveMetaItemRequestSchema.packageId` description now states what an `active` save that names no package writes, where it said the env-local overlay; `DeleteMetaItemRequestSchema` declares the optional `packageId`. `@objectstack/rest`: `package` joins the reset door's closed query set (`META_ITEM_RESET_PARAMS`). +- 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')`. +- c857a93: feat(spec)!: publish refuses an anonymous flow endpoint whose target flow runs as `system`, in both directions, with the prescription (ADR-0138 D2b) + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0138 D2b decides that no anonymous door triggers an elevated flow directly. An `authRequired: false` endpoint of `type: 'flow'` whose `target` flow declares `runAs: 'system'` ran every anonymous request's data steps as the system principal, past row-level security, with the endpoint's rate limit as the only brake. The runtime's own elevated-start refusal covers only self-triggered flow types (`autolaunched`, `record_change`, `schedule`), so a `system` flow of type `api` or `screen` behind such an endpoint executed elevated. Publish now refuses the pair, in both directions, because either item can be the last one edited: the endpoint's publish reads its target's `runAs`, and a flow's publish that sets `runAs: 'system'` reads the anonymous endpoints that target it. Each refusal names the endpoint and the flow and carries the prescription. + + **What is refused, and where.** Every publish door that holds the pair calls one judge, `anonymousElevatedFlowPairs` in `@objectstack/spec/api`: + + - **The stack parse** (`defineStack`, `objectstack validate`, `objectstack build`, the artifact load): a stack whose anonymous flow endpoint names one of its own flows declaring `runAs: 'system'`. One zod issue per pair, `code: 'custom'` at `apis[i].authRequired`; `defineStack` throws its schema refusal, `STACK_SCHEMA_INVALID` (422), carrying the issue. + - **The package publish** (`MetadataManager.publishPackage`, `POST /api/v1/packages/:id/publish`): an `api` item of the package naming a `system` flow anywhere in the manager's index, reported at the endpoint; and a `flow` item of the package declaring `runAs: 'system'` that an anonymous endpoint outside the package names, reported at the flow. The package publishes nothing (`success: false`, the pair in `validationErrors`), and `validate: false` does not turn it off. + - **A flow published through the metadata API** (`saveMetaItem` in publish mode, so `PUT /api/v1/meta/flow/:name`; a draft's publish, `POST /api/v1/meta/flow/:name/publish`; and `POST /api/v1/packages/:id/publish-drafts`): a flow going `active` with `runAs: 'system'` while a declared anonymous flow endpoint names it. `INVALID_METADATA` (422), `issues[0].path: 'runAs'`, nothing written; a publish-drafts batch aborts on it. A draft save is not judged (a draft does not execute; its publish is), and neither is a `migrate-stored` re-save of a row that already exists. + + **Unchanged.** Each item alone publishes as before: an authenticated endpoint on a `system` flow, an anonymous endpoint on a `user` flow (or one that leaves `runAs` at its default), and an anonymous `object_operation` endpoint are all accepted. A stored flow row that already pairs this way keeps its bytes and keeps executing as published; its next publish is refused. A guest-written row that fires a record-change flow is outside the refusal: the record leaves that indirect path undecided, because its trigger is the write, not the door. + + ## FROM → TO + + | door | FROM | TO | + |:--|:--|:--| + | `defineStack` / `objectstack validate` | `apis: [{ name: 'acme_intake_api', type: 'flow', target: 'acme_intake', authRequired: false, rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, … }]` beside `flows: [{ name: 'acme_intake', runAs: 'system', … }]` accepted; every anonymous request ran `acme_intake` as the system principal | refused, `STACK_SCHEMA_INVALID` 422, the issue at `apis[0].authRequired` naming both items and the prescription | + | package publish | a package carrying either half of the pair, with the other half anywhere in the manager's index, published | refused, `success: false`, the pair reported at the package's own item | + | metadata API flow publish | `PUT /api/v1/meta/flow/acme_intake` with `runAs: 'system'` while an anonymous endpoint names it saved and armed it | refused, `INVALID_METADATA` 422, the issue at `runAs`, nothing written | + + **The one-line fix: serve the caller another way — an authenticated endpoint (`authRequired: true`) for a partner that can hold a credential, the flow's signed inbound hook (ADR-0041: a start node with `triggerType: 'api'`, a `hookId` and a `secret`, which may keep `runAs: 'system'`) for a partner webhook, or a public form (a form view whose `sharing` sets `allowAnonymous: true`) for anonymous intake.** `runAs: 'automation'` joins that list when ADR-0073 M2 adds the value. + + No D2 conversion rewrites it: which channel replaces the anonymous endpoint depends on who the caller is, and whether the flow needs elevation is a judgment about its data steps, so no conversion can choose a posture for the author. Its D3 record is the semantic entry `api-endpoint-anonymous-elevated-flow-refused` (protocol 18). + + **`@objectstack/spec`.** The judge and its two refusal sentences join `@objectstack/spec/api`: `anonymousElevatedFlowPairs`, `anonymousFlowDoorTarget`, `anonymousEndpointElevatedFlowMessage` (the endpoint direction), `elevatedFlowAnonymousEndpointsMessage` (the flow direction) and `validateAnonymousElevatedFlows` (the stack's issues), with the types `AnonymousFlowDoorCandidate`, `ElevatedFlowCandidate` and `AnonymousElevatedFlowPair`. `ObjectStackDefinitionSchema` runs the judge beside the per-endpoint gates. The `runAs` description on the flow schema, which prescribed `system` for every user-less trigger, `api` included, now names the exception and what serves that caller. + + **`@objectstack/metadata`.** `MetadataManager.publishPackage` runs the judge over the package's `api` and `flow` items against the index the endpoint matcher serves from. + + **`@objectstack/metadata-protocol`.** The flow publish doors run the judge against the declared endpoints: the engine registry's `api` items and the `metadata` service's. + + **Who is affected, measured.** No in-repository stack pairs an anonymous flow endpoint with a `system` flow: `examples/` declares no `authRequired: false` endpoint, and every anonymous flow endpoint in the test fixtures names a `runAs: 'user'` flow. The partner-webhook example, `showcase_inbound_task_webhook`, takes the signed inbound hook with `runAs: 'system'` and is untouched. +- 14a13bc: fix(metadata-protocol)!: the metadata save, restore and publish doors refuse a permission set whose body `packageId` disagrees with the package it is saved into, and the audience-binding suggestion reader takes a set's owner from its binding only (ADR-0086 D3) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0086 D3 declares `PermissionSetSchema.packageId` as the owning package of a package-shipped set, absent for an environment-authored one. The owner every reader uses is the package the set is saved into: the stored row's `package_id`, served as `_packageId`. A body `packageId` that named another package was accepted and then silently overridden, so an author, an AI build above all, never learned that the owner it wrote was not the owner it got. + + **FROM.** A permission set saved and published into package `app.crm` with `packageId: 'app.other'` in its body answered `200` and `published`, and was owned by `app.crm` everywhere it was read. A set saved into no package with `packageId: 'app.other'` was stored as an environment-authored set, while the audience-binding suggestion reader offered it, accepted it and derived it into `everyone` as `app.other`'s. + + **TO.** + + - **`@objectstack/metadata-protocol`.** The save door (`PUT /api/v1/meta/permission/:name`, draft and publish mode, and every in-process caller) the restore doors (`POST /api/v1/meta/permission/:name/rollback` and a commit revert's restore limb) and the draft promotion (`POST /api/v1/meta/permission/:name/publish` and the package publish) refuse a permission set whose body `packageId` is not the package the row is written into, with `400 VALIDATION_ERROR`, the code and status the doors already answer for a body `name` that disagrees with its row. `issues[]` carries the key at `packageId`. A body `packageId` on a set saved into no package is refused too, the empty string included: a present key declares a package-shipped set. The message names both values and both remedies. The package judged is the one the write lands in: a draft saved without naming a package lands in the package of the item's active row, and is judged against it. A package publish that meets such a draft (one stored before this change) aborts the whole batch, `outcome: 'refused'`, with the draft in `failed[]` under `VALIDATION_ERROR`. A rollback to a stored version that carries such a body (one written before this change) is refused, and a commit revert reports it in `failed[]` under the same code; the remedy there is to save the set again with `packageId` dropped or set to its package, instead of restoring that version. Nothing is written, published or restored. Package duplication copies a set whose body names the source package as naming the target package. `os migrate meta --stored --apply` records a stored set it rewrites, and the door refuses, as that row's failure. + - **`@objectstack/plugin-security`.** The audience-binding suggestion reader takes a set's owner from its binding only, never from its body's `packageId`. A set bound to no package has no owner there, so no suggestion is created for it. An acceptance recorded under the package its body named is withheld from `everyone` at the next derivation. The withholding is reported at `warn`, naming the set and its reason. Accepting a pending suggestion of that kind is refused. + - **`@objectstack/spec`.** The `PermissionSetSchema.packageId` description states the refusal. The step-18 semantic migration entry `permission-set-package-id-binding-mismatch-refused` carries the upgrade sweep. + + **Unchanged.** A body `packageId` equal to the package the set is saved into is accepted, and an absent one is accepted everywhere. Stored rows are not rewritten. A stored set whose body disagrees with its binding keeps loading and keeps reading as its binding, and is refused on its next save or publish, as is a restore of a stored version that carries one. An installed package's own sets are owned by the package that ships them, as before. + + **Remedy, in one line.** Drop `packageId` from the permission set's body, or save the set into the package it names (`?package=` on the REST door, `packageId` in process). +- 572ecfb: fix: `os migrate meta --stored` lists every stored row that protocol 18 refuses and no conversion rewrites. Before, the preview counted each such row as already on protocol. + + Clause-②: yes (widening: `IAutomationService.canonicalizeStoredFlow(...).todos?` in `@objectstack/spec`, `AutomationEngine.canonicalizeStoredFlow(...).todos` in `@objectstack/service-automation` and `StoredFlowCanonicalization.todos?` in `@objectstack/metadata-protocol`, each a new output member; nothing narrows, and no door accepts or refuses anything it did not before) + + Three step-18 retirements refuse a stored shape that has no lossless rewrite. Upgrading a v17 database left those rows refused at load or at boot, and the stored preview read them as `canonical`. Each shape now has exactly one fate: **refused and listed**. The preview and `--apply` print one `TODO` line per site under its row. The line names the step-18 entry that refuses the shape, the site, and the choice the owner has to make. Nothing is rewritten, and the run's verdict does not change, because no run of the pass could clear these rows. + + **#23269, a flow value slot carrying a `{…}` token, including a `get_record` / `update_record` / `delete_record` filter operand: refuse and list.** A whole-path token maps mechanically to `{ dialect: 'cel', source: '' }`, but the rewrite is not lossless. A token the template resolved to nothing (an erased condition, so the node was refused) is a CEL fault on an absent root, and a flow variable named like a query placeholder used to shadow it. ADR-0087 D2 conversions run on the load path and must be lossless, so this one is not a D2. The chain also has no operator-applied rewrite of stored rows separate from the load path: `--stored` writes back the read path's own pass, and `--from 17` rewrites authored sources, not rows. So the flow is listed by flow, node and path (`flow-value-slot-template-dialect-refused`), and `registerFlow` goes on refusing it until the value is rewritten as its CEL envelope. A flow already in the CEL form, or using a query placeholder such as `{current_user_id}`, is not listed. + + **#23270, a `controlled_by_parent` object without exactly one required `master_detail`: refuse and list, by object (`cbp-master-relation-tiers-retired`).** No shape here converts losslessly. The upgrade never picks between two masters, either both required or neither. An explicit `required: false` is a contradiction the builder refuses. Turning a required `lookup` into a `master_detail` changes what deleting the master does. An object with no master has nothing to convert. Leaving `controlled_by_parent` changes who can read the rows, so that is the owner's choice and never a silent rewrite. The listing reads the same predicate `ObjectSchema` refuses with, so the preview and the parse refuse one set. The one lossless shape, a single `master_detail` with `required` omitted, is still converted by `cbp-master-detail-required-defaulted` and is not listed. + + **#23271, a view whose `data` binds the retired view-level API source: refuse and list, by view (`view-data-api-provider-retired`).** There is no mechanical move. The replacement is a page component's `dataSource`, so a page must exist to carry it. Deleting the view's `data` would make the view query its object instead. Views in every stored body spelling are covered, and so are an object's own `listViews`. The line shows the request URL and method and withholds every other request key, so a credential stored in a view's headers never reaches the report. A view on the `object`, `value` or `schema` provider is not listed. + + How it is wired: `applyConversions` reports these shapes through its existing `onTodo` sink, after the conversions run. Each TODO's `conversionId` is the id of the D3 entry that refuses the shape, so the published `ConversionTodoNotice.conversionId` now names either a conversion or a D3 entry. The published flow-canonicalization result gains one member, a widening in three packages: + + - `@objectstack/spec`: the contract `IAutomationService.canonicalizeStoredFlow(...)` declares an optional `todos?: ConversionTodoNotice[]` beside `notices` and `conflicts`. An implementation that reports none still conforms. + - `@objectstack/service-automation`: `AutomationEngine.canonicalizeStoredFlow(...)` returns `todos`. `registerFlow` ignores them, because its own refusal names the same sites. + - `@objectstack/metadata-protocol`: `StoredFlowCanonicalization` gains the matching optional `todos?`, and the stored pass lists a flow row's TODOs the way it lists any other row's. + + The authoring funnel and the read seams pass no sink, so nothing changes for them. +- 2de9c03: fix(metadata-protocol)!: a delete naming one package keeps the item's other rows registered, a draft save that names no package of an item two or more packages hold is refused `409 RESOURCE_CONFLICT`, and the delete's `_lock` refusal carries the named package's reason (ADR-0048) + + 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 one row per package. Since the save and the delete name their package (`?package=` on the REST door, `packageId` in process), three paths still did not take the package they were handed. + + **The registry after a delete naming one package.** FROM: `DELETE /api/v1/meta/object/my_object?package=com.acme.a` of an object two packages hold removed `com.acme.a`'s row and the receipt said the other package's row remains, and the item read served that row, but the registry entry was retired as if the deleted row were the whole item. Data CRUD then answered `404 OBJECT_NOT_FOUND` for the object until the next boot or save, and on a kernel whose registry holds the stored rows, registry lookups missed an item of any other type the same way. TO: when a row of the item is still stored after a delete, that row is registered again, as a boot registers it, and nothing is retired. Deleting the last row still retires the item. + + **A draft save that names no package.** FROM: `PUT /api/v1/meta/view/my_view?mode=draft` with no `?package=`, of an item whose active rows two packages hold, answered `200` "(env-wide, state=draft)" and stored the draft in whichever package the database returned first. With one package and a package-less row beside it, the draft went into the package or stayed package-less by the same order. TO: the draft inherits the package of the item's active rows when they are bound to one package, whatever the storage order (a package-less row beside it is not a second package). When they are bound to two or more, the save is refused `409 RESOURCE_CONFLICT`, writes nothing, and names the packages with the `?package=` remedy, exactly as the direct save and the delete answer. A draft read that names no package serves the package-unbound draft's version for such an item. + + **The delete's `_lock` refusal.** The delete's lock gate now takes the package the delete names, as the save's does. The verdict is unchanged: the lock is the strictest of every package that ships or stores the item, so a delete naming a package is refused whenever the save naming it is. What changes is the prose: when several packages declare the strictest lock, the `ITEM_LOCKED` refusal of a delete naming a package carries that package's reason, where it carried the reason of the package whose id sorts first. + + **Remedy.** For an item two or more packages hold, send the draft save with `?package=` naming the package whose draft it is (`packageId` in process). Nothing else asks anything of a caller. +- d89f491: A stored flow the automation engine refuses is no longer served as running: the flow meta read serves the engine's verdict beside the item as `registration`, an envelope sibling of `lock`, and the flow list serves it as `registrations` keyed by flow name, so a refused flow reads `{ state: 'refused', reason: { code?, message } }` beside its stored `status`, which stays as authored. + + Clause-②: yes (widening) + + - **The shape** (`@objectstack/spec`). `FlowRegistrationSchema` is a closed set: `registered` (the engine holds the flow), `refused` (it refused the stored flow and holds nothing under the name; `reason` carries the engine's own `message` and, when the thrown error set one, its `code`, verbatim), and `unregistered` (it holds nothing and refused nothing: a read during boot, between a save and the engine following it, or after a deliberate unregister). `GetMetaItemResponseSchema` gains `registration` and `GetMetaItemsResponseSchema` gains `registrations`, both optional. New exports: `FlowRegistrationSchema`, `FlowRegistrationRefusalSchema` and the types `FlowRegistration` and `FlowRegistrationRefusal`. `IAutomationService` gains the optional member `getFlowRegistration(name)`. + - **Where the verdict comes from** (`@objectstack/service-automation`). The `kernel:ready` cold-boot bind records each flow it refuses, with what the engine threw, after it withdraws the flow; the `metadata:reloaded` re-sync and the metadata-change sync record a refusal that leaves the name unregistered. A later successful registration of the name, or its withdrawal, ends the record. `AutomationEngine` answers `getFlowRegistration(name)` and takes `recordFlowRefusal(name, error)`. The refusal's warn line, its level and the withdrawal are unchanged. + - **Where it is served** (`@objectstack/metadata-protocol`). The protocol reads the engine through the kernel's service table by name, as it already reads the engine's stored-flow canonicalizer; it does not import the automation service. The field is absent where no automation service declares the member, on every type other than `flow`, on a draft read and on the execution face the engine binds from: absence means no engine judged the flow, never `registered`. The verdict is the engine of the process that answered the read. + - **The default door** (`@objectstack/rest`, `@objectstack/metadata-protocol`, `@objectstack/spec`). `GET /api/v1/meta/flow/:name` on a default deployment (`metadata.enableCache: true`) is answered by the cached item branch, which now carries `registration` too. `getMetaItemCached` returns it beside `data`, read live from its delegate read on every call, and `GetMetaItemCachedResponseSchema` declares it. It is never part of the ETag, so a cached read that carries it is never answered `304 Not Modified`: a judged flow is always served in full. The cached branch's other envelope keys are unchanged. +- 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. +- 14aff67: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + fix(metadata-protocol)!: a draft save naming no package of an object its package-less definition owns is refused when its draft address is a package's draft, with a way out to the package-less draft (ADR-0029 D9.9) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The ADR-0029 D9.9 check of the object save door is now asked of the package the write lands in. A draft save naming no package (`PUT /api/v1/meta/object/NAME?mode=draft`, Studio's designer save) leaves the binding to the draft address, which is the item's one package `Q` when an active row is bound to `Q`, or `Q`'s draft when no active row is bound to a package and `Q`'s draft is the only one stored (ADR-0048). The check used to ask with the request's empty package, so an object whose owner is its package-less definition read as its own re-save, and the save wrote `Q`'s draft. It answered `200`, and the promote door then refused every publish of that draft. + + 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, carrying `packageId: Q` and `ownerPackageId: null`. Only rows written before the save door refused a package's row beside the package-less one, or drafts bound to a package stored before that row existed, reach these states. + + - Over a row bound to `Q` stored beside the package-less one, with no draft, with a draft bound to no package, or with both that draft and `Q`'s: FROM `PUT /api/v1/meta/object/NAME?mode=draft` answering `200` with `Q`'s draft written (a new one, or the draft bound to no package adopted into `Q`), TO the refusal "A draft save naming no package of object 'NAME' writes a draft bound to 'Q', which no publish can serve: the object is served by its package-less definition. First DELETE /api/v1/meta/object/NAME?package=Q, then send this save again: it writes the package-less draft. Nothing was written." + - The same row with `Q`'s draft and no draft bound to no package: FROM `200` into `Q`'s draft, TO the refusal naming `Q`'s draft discard after the row's delete: "First DELETE /api/v1/meta/object/NAME?package=Q, then ?state=draft&package=Q (the draft discard), then send this save again: …". Deleting the row alone leaves `Q`'s draft, which the address then still names. Each package's draft is named the same way when several are stored. + - No row bound to a package, and `Q`'s draft the only draft stored: FROM `200` into `Q`'s draft, TO the refusal "First DELETE /api/v1/meta/object/NAME?state=draft&package=Q (the draft discard), then send this save again: it writes the package-less draft. …". + + **Remedy, in one line.** Send the requests the refusal names, in its order, then the same draft save: it writes the package-less draft, and the table, its records and the served definition stay as they are. A discard removes only that package's draft, which no publish could serve; the save sent again carries the edit. To move the object into `Q` instead, send the draft save naming `Q`: its refusal names that move. + + **Unchanged.** + + - A draft save naming no package where no package's row and no package's draft is stored: it writes the draft bound to no package. + - A draft save naming no package where the draft bound to no package is the address (no package's row, that draft stored): it is written, as before. + - A draft save naming no package over rows bound to two or more packages, or over two packages' drafts with no package row and no draft bound to no package: `409 RESOURCE_CONFLICT`, as before. + - Every save naming a package, every publish-mode save, and the promote doors: same answers and texts. + - An object a code package ships, an object owned by a runtime row bound to a real package, and every type except `object`. + - Stored rows are not rewritten on upgrade. +- 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. +- cf07168: feat(metadata-protocol)!: the metadata save door refuses a retired flow spelling on a NEW flow, and a stored flow keeps re-saving + + Clause-②: yes (narrowing) + + **BREAKING (API save door):** a flow saved through `saveMetaItem` (`PUT /api/v1/meta/flow/:name`, the runtime dispatcher, and every caller of the protocol) with no stored row of that name at the save's address is now judged as authored input. It is canonicalized without the ADR-0087 D2 conversions retired from the authoring funnel, so a spelling the loader stopped lifting at 18 is no longer rewritten and stored: it meets the flow parse's refusal, `422 INVALID_METADATA`, with an issue located at the key and the same prescription `os validate` and `os build` print. Draft and publish mode alike. Before, the door lifted the old spelling, stored the new one and said nothing, so the client that wrote it (an AI writing flows through MCP or REST above all) was never told and wrote it again. + + What counts as a stored row: the row the save overwrites, at its state and package, and for a draft save with no draft yet the active row the draft overlays, so Studio's first draft edit of a stored flow is a re-save. + + Not changed: + + - A re-save of a stored flow keeps replaying the full chain, retired entries included, so a legacy row stays editable and is healed by the save, exactly as before. + - The server's own writes of bodies at rest keep the full chain: the stored-metadata migration (`os migrate meta --stored`), a package duplicate and an unmanaged package import. + - The automation engine's flow rehydration (`registerFlow`) and the read path, which still serves a stored flow verbatim. + + Migration, for a client that creates flows through the API: + + - `wait` node loose `config.duration` / `config.timerDuration` → `waitEventConfig.timerDuration`; `config.signal` / `config.signalName` → `waitEventConfig.signalName`; `config.eventType` → `waitEventConfig.eventType`. On the block itself, `timeoutMs` → `timerDuration` and `onTimeout` is removed. + - `connector_action` node loose `config.connectorId` / `config.actionId` / `config.input` → its `connectorConfig` block. + - CRUD node `config.filters` → `config.filter`; `config.object` → `config.objectName`. + - `map` and `subflow` node `config.flow` → `config.flowName`. + - `notify` node `config.to` → `recipients`, `subject` → `title`, `body` → `message`, `url` → `actionUrl`, `source: { object, id }` → `sourceObject` / `sourceId`. + - `script` node `config.functionName` → `function`, `input` → `inputs`; `actionType` / `template` / `recipients` / `variables` / `script` are removed. + - `errorHandling.retryDelayMs` and a `try_catch` node's `retry.retryDelayMs` → `backoffMs`; the flow keys `active` / `template`, a node's `outputSchema` and `errorHandling.fallbackNodeId` are removed. + + Each refusal names the key to write. `os migrate meta --from 16` lists the same edits for source files. Rows already stored need nothing. + + `@objectstack/service-automation`: `AutomationEngine.canonicalizeStoredFlow(name, definition, options?)` takes `{ includeRetired?: boolean }`. Omitted, it is `true` (the full chain, as every caller holding a body at rest wants). `false` is the authoring posture the save door asks for. `registerFlow` does not take the option. + + +- 6e18016: feat(metadata-protocol)!: a flow re-save that newly introduces a retired spelling is refused like a new flow's + + Clause-②: yes (narrowing) + + **BREAKING (API save door), the re-save case:** a flow saved through `saveMetaItem` (`PUT /api/v1/meta/flow/:name`, the runtime dispatcher, and every caller of the protocol) over a stored row is now judged as authored input when its body introduces a spelling retired from the authoring funnel that the stored row does not carry. Adding a node with an old spelling to a legacy flow, or writing one on a node the stored row holds in the canonical spelling, is no longer rewritten and stored: the save meets the flow parse's refusal, `422 INVALID_METADATA`, with an issue located at the key and the same prescription `os validate` and `os build` print, exactly as a new flow's does. Draft and publish mode alike. The whole body is judged that way, so the refusal also lists the retired spellings the stored row already carries; fixing the added one alone is enough to save. Before, a re-save healed every retired spelling in the body, the new one included, and said nothing. + + What counts as introduced: a retired conversion that fires on the request at a site where it does not fire on the stored row. A site is the key's path, with each node (and edge) named by its `id` rather than its position, so moving, adding or removing nodes does not change it. The stored rows compared are the row the save overwrites, and for a draft save the active row the draft overlays as well, so Studio's repeated draft saves of a legacy flow keep saving. + + Not changed: + + - A re-save whose retired spellings the stored row all carries, such as the served legacy body saved back unchanged, keeps replaying the full chain and is healed as before. + - A new flow with a retired spelling is refused, as it was. + - The server's own writes of bodies at rest keep the full chain: the stored-metadata migration (`os migrate meta --stored`), a package duplicate and an unmanaged package import. + - The automation engine's flow rehydration and the read path, which still serves a stored flow verbatim. + + Migration, for a client that edits flows through the API: write the spellings below on any node or key you add or change. The legacy spellings already on the stored row may stay until the row is healed. + + - `wait` node loose `config.duration` / `config.timerDuration` → `waitEventConfig.timerDuration`; `config.signal` / `config.signalName` → `waitEventConfig.signalName`; `config.eventType` → `waitEventConfig.eventType`. On the block itself, `timeoutMs` → `timerDuration` and `onTimeout` is removed. + - `connector_action` node loose `config.connectorId` / `config.actionId` / `config.input` → its `connectorConfig` block. + - CRUD node `config.filters` → `config.filter`; `config.object` → `config.objectName`. + - `map` and `subflow` node `config.flow` → `config.flowName`. + - `notify` node `config.to` → `recipients`, `subject` → `title`, `body` → `message`, `url` → `actionUrl`, `source: { object, id }` → `sourceObject` / `sourceId`. + - `script` node `config.functionName` → `function`, `input` → `inputs`; `actionType` / `template` / `recipients` / `variables` / `script` are removed. + - `errorHandling.retryDelayMs` and a `try_catch` node's `retry.retryDelayMs` → `backoffMs`; the flow keys `active` / `template`, a node's `outputSchema` and `errorHandling.fallbackNodeId` are removed. + + Each refusal names the key to write. + + +- a65a59b: feat(spec)!: the ADR-0078 conversion-conflict channel is retired — `CONVERSION_CONFLICT_CODE`, `ConversionConflictNotice`, `ConversionConflictDetail`, the `onConflict` / `reservedNodeTypes` conversion options, the `conflicts` arrays and the `FLOW_CONVERSION_CONFLICT` 409 + + Clause-②: yes (narrowing) + + **BREAKING (published API):** the ADR-0087 conversion API carried a channel for a rename REFUSED because its old token was a live name in the environment: a conversion reported it through `ConversionContext.reportConflict`, `applyConversions` handed it to an `onConflict` sink as a `ConversionConflictNotice` (code `OS_METADATA_CONVERSION_CONFLICT`), the automation engine collected it in `canonicalizeStoredFlow`'s `conflicts` array, and the metadata protocol answered it with `409 FLOW_CONVERSION_CONFLICT`. Its only producer was the live-executor guard inside the flow-node HTTP callout rename. Protocol 18 reserved those three names (`FLOW_RESERVED_NODE_TYPES`), so the engine refuses an executor under one, no live owner can exist, and the guard went. Nothing can raise a conversion conflict any more, so the channel leaves the published surface rather than staying as a live-looking failure mode nothing triggers. + + ## FROM → TO + + | you wrote | write instead | + | --- | --- | + | `import { CONVERSION_CONFLICT_CODE, type ConversionConflictNotice, type ConversionConflictDetail } from '@objectstack/spec'` | delete the import — no replacement | + | `applyConversions(stack, { onConflict, reservedNodeTypes, … })` (likewise `applyConversionsToFlow`, `applyConversionsToStoredItem`, `collectConversionNotices`) | delete both options; keep `onNotice` / `onTodo` | + | a conversion's `apply` reading `context.reservedNodeTypes` or calling `context.reportConflict` | a rename over an open namespace reserves its old names instead (the `FLOW_RESERVED_NODE_TYPES` pattern), so the refusal happens at registration; a site with no lossless rewrite goes to `context.reportTodo` | + | an `IAutomationService.canonicalizeStoredFlow` implementation returning `conflicts: […]` (or a `StoredFlowCanonicalization` from `@objectstack/metadata-protocol` carrying one) | drop the member; return `parsed`, `storable`, `notices` and optionally `todos` | + | a client branch on `error.code === 'FLOW_CONVERSION_CONFLICT'` / HTTP 409 from a flow save, a package duplication or an unmanaged package import | delete the branch — no request can produce it | + + **The one-line fix:** delete every reference; the branch it guarded was already dead, so deleting it changes no behaviour. + + **What an upgrader sees.** Importing a removed name is `TS2305`; passing a removed option, or returning a `conflicts` member from a typed canonicalizer, is an excess-property `tsc` error. `ErrorCode` no longer parses `FLOW_CONVERSION_CONFLICT`. + + **Runtime behaviour is unchanged.** The removed branches could not fire: a stored flow carrying a reserved callout name still loads as `http` (the rewrite has been unconditional since the names were reserved), and `saveMetaItem`, `duplicatePackage`, `importUnmanagedPackage` and `os migrate meta --stored` answer exactly as before. No metadata document needs editing. + + ## The retirement kit + + - **`@objectstack/spec`:** the three exports leave the root; `ApplyConversionsOptions` loses `onConflict` and `reservedNodeTypes`, `ConversionContext` loses `reservedNodeTypes` and `reportConflict`, the `IAutomationService.canonicalizeStoredFlow` result loses `conflicts`; the error-code ledger drops `FLOW_CONVERSION_CONFLICT` by its own "Retiring a code" rule (a row comes out with its last emitter). + - **`@objectstack/service-automation`:** `AutomationEngine.canonicalizeStoredFlow` no longer returns `conflicts`, and the engine no longer builds a live-name set for the conversion pass. + - **`@objectstack/metadata-protocol`:** `StoredFlowCanonicalization` loses `conflicts`; the flow save door's 409, the stored-migration pass's `failed` row for a refused rename, `duplicatePackage`'s failed item and `importUnmanagedPackage`'s 409 are gone with it. + - **The D3 entry `conversion-conflict-channel-removed`.** No D2 conversion and no tombstone: no metadata document ever carried any of it. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** The three packages are published, so this is breaking for consumers no telemetry was consulted for. + + +- d6bd98a: fix(metadata-protocol): `duplicatePackage` is refused, with nothing copied, when the install of the copy's package record is refused + + Clause-②: no (narrowing) + + + + - **Refused now.** `duplicatePackage`, and `POST /api/v1/packages/:id/duplicate` which calls it, installs the copy's package record before it copies any item. When that install is refused, the duplicate is now refused with that same refusal, and nothing is minted or copied. A copy whose namespace another installed package owns answers `422 NAMESPACE_CONFLICT` with the registry's own sentence (ADR-0048); that covers an explicit `targetNamespace` and one derived from the last segment of `targetPackageId`. A failed `sys_packages` write answers the install's own `500`. Before, both refusals were dropped and the items were copied anyway: bound to an id with no package record (absent from `GET /packages`), after a namespace conflict inside the namespace the other package owns, and the answer read `success: true`. + - **Unchanged.** A duplicate whose package record installs copies exactly as before, with the same answer shape, and per-item failures still ride `failed[]`. A source with no package record still copies without installing one. + - **Remedy.** Choose a target namespace no installed package owns: pass `targetNamespace` explicitly, or pick a `targetPackageId` whose last segment derives a free one. For a deliberate migration, `OS_METADATA_COLLISION=warn` still downgrades the namespace gate to a warning, and the duplicate then proceeds. + - **Logged.** The refusal is logged once at `warn` by `[protocol.duplicatePackage]`, naming the target and source ids and the refusal's code and message. For a store fault the error itself is attached, so its cause reaches the log. +- f3174ef: fix(metadata-protocol)!: `installPackage` judges the whole manifest by the install door's declaration, so the primitive refuses a manifest `POST /api/v1/packages` refuses + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `ObjectStackProtocolImplementation.installPackage` parsed only `manifest.id`, so an in-process caller stored manifests the install door answers `400 VALIDATION_ERROR` for. The primitive now parses the manifest through `PackageInstallManifestSchema` (`@objectstack/spec/api`, `ManifestSchema` with `scope` narrowed to `project`), the declaration the door parses, after its version default and before any write. + + - **FROM:** `protocol.installPackage({ manifest })` with `type: 'application'`, `scope: 'environment'`, `scope: 'cloud'` / `'system'`, no `type`, or a key the manifest does not declare (such as a metadata collection, `pages`) registered the package and wrote its `sys_packages` row. + - **TO:** the call throws with `statusCode: 400` (an HTTP boundary answers `VALIDATION_ERROR`), the message names each refused key path (`manifest.type`, `manifest.scope`, …) with the declaration's own sentence, and neither the registry nor `sys_packages` is written. A malformed `manifest.id` still answers first, with its own sentence and repair. + - **Unchanged:** a manifest with no `version` still installs with `version: '0.1.0'`, a missing `namespace` is still derived from the id, and the manifest is stored as sent (the parse's `scope` / `defaultDatasource` defaults are not copied onto it). + - **Rows already stored** with `type: 'application'` / `scope: 'environment'` keep hydrating at boot, as stored. A new write of the same manifest, including a `duplicatePackage` of such a base, is refused. + - **The fix for a caller:** write a declared `type` (`app` for an application package) and leave `scope` out, or write `scope: 'project'`. +- a06f0d4: fix(metadata-protocol)!: the by-name `/meta` read refuses an object of a disabled package with `404 OBJECT_PACKAGE_DISABLED`, as the object list and the data plane already do + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Disabling a package already took its objects out of `GET /meta/objects` and refused their data with `404 OBJECT_PACKAGE_DISABLED`. The by-name read still served them. It now gives the same answer. + + - **Refused now:** `getMetaItem({ type: 'object', name })` (`GET /api/v1/meta/object/:name`, the plural spelling included) for an object whose owning package is disabled. The refusal applies to every state the read serves: the active read, `state: 'draft'` and `previewDrafts`. `getMetaItemLayered` (`?layers=true`) refuses it too. The answer is `404` with `code: 'OBJECT_PACKAGE_DISABLED'` and `object`, built by the same constructor as the data plane's refusal, so the two cannot drift. + - **Unchanged:** objects of enabled packages, other metadata types, and the registry's resolution reads (`registry.getObject`, `registry.listItems('object')`), which keep serving a disabled package's objects for migrations and cross-package references. + - **Still checked:** saving an object of a disabled package still runs the destructive-change check. A save that drops a field still answers `409 DESTRUCTIVE_CHANGE` until it is confirmed with `force`. The check reads the object's prior schema through the protocol's internal read, not the refusing door. + + What changes for you: a client that read a disabled package's object by name now gets `404 OBJECT_PACKAGE_DISABLED`. Re-enable the package to read it again. +- 2bcd406: fix(metadata-protocol)!: the by-name `/meta` read answers a disabled package's item of every type but `object` and `package` as absent, as the list already omits it + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Disabling a package already took its apps, views, pages, dashboards, flows and every other item out of `GET /meta/:type`. The by-name read `GET /meta/:type/:name` still served them. It now answers such an item the way it answers a name with nothing behind it. + + - **Absent now:** `getMetaItem({ type, name })` for an item whose `_packageId` names a disabled package, for every type except `object` and `package`. The active read and `previewDrafts` serve no `item`, which both transports answer `404 RESOURCE_NOT_FOUND`, byte for byte the answer for a missing name. The strict draft read (`state: 'draft'`) throws the missing draft's `404 NO_DRAFT`. The list read and the by-name read ask one predicate, so they hide the same items. + - **Kept:** the protection envelope (`lock`, `editable`, `deletable`, `version` and the rest) still reports the write doors' verdicts about the address, because a disable does not change how the lock gate judges a save there. Only `item` and a flow's `registration` are withheld. + - **Unchanged:** objects, which keep their `404 OBJECT_PACKAGE_DISABLED`; packages, which are never hidden, so a disabled package can still be read and re-enabled; items of enabled packages; and `getMetaItemLayered` (`?layers=true`, `/layers`) and `/published`, which still serve a disabled package's non-object item. + + What changes for you: a client that read a disabled package's app, view, page or other item by name now gets `404 RESOURCE_NOT_FOUND`, as for a missing name. Re-enable the package to read it again. +- e74ec56: fix(metadata-protocol)!: `listDrafts` omits a disabled package's drafts, as the list read omits its items + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `GET /meta/_drafts` (both transports) listed a disabled package's pending drafts, while the by-name draft read answers them `404 NO_DRAFT` and the list read omits the package's items. + + - **Omitted now:** every draft whose `packageId` names a disabled package, with or without `?packageId=`. The predicate is the one the list and by-name reads ask. Drafts of the `package` type are never hidden. + - **Unchanged:** the package batch doors (`POST /packages/:id/publish-drafts`, `/discard-drafts`) still publish and discard a disabled package's drafts; they read the store directly. Other packages' drafts are listed as before. + + What changes for you: re-enable the package to see its pending drafts in the list again. +- 1e001c7: feat(metadata-protocol): `publishPackageDrafts` takes an opt-in `backfill` that writes the literal `defaultValue` of fields the publish adds into the object's existing rows, as the caller + + Clause-②: yes (widening) + + - **New request keys.** `backfill?: Array<{ object, field }>` names fields this publish adds; `context` is the caller's execution context the writes run as. Absent or `[]`, no row is touched. + - **Judged before anything is published.** Every entry must name an object drafted in the package, a field its draft declares, a field the published object does not have yet, and a literal `defaultValue` (a runtime token such as `NOW()` or `current_user`, or an expression, is resolved per new record and is refused). A malformed entry, a duplicate, or a backfill without a caller context is refused too. All problems come back in one `400 VALIDATION_ERROR`, one `issues[]` entry per problem with its `backfill.N` path, and nothing is promoted or written. + - **Written after the publish, as the caller.** Once the drafts are live and the new column exists, rows whose value is absent are read and written 200 at a time through `updateManyData` with the caller's context, so permissions, RLS/FLS, validation and record hooks run as on that caller's own edit. A value already stored is never overwritten. + - **New response key `backfillApplied`:** `{ success, fields: [{ object, field, updated, error?, code? }] }`, one element per requested field. `updated` counts the rows this publish wrote. A field stops at its first failing row and reports the rows written so far with the error; the publish is not undone. On the SQL driver the new column's DEFAULT already fills existing rows, so `updated` is 0 there. +- 41c7405: fix(metadata-protocol)!: an organization-scoped seed load refuses a row whose natural key a row with no organization already holds, by name in the seed result, instead of inserting a twin of it + + Clause-②: no (narrowing) + + + + **BREAKING** (a seed load writes fewer rows), shipped as `minor` under the launch-window convention for breaking changes. + + An organization-scoped seed load (`SeedLoaderService` with `config.organizationId`: the per-organization replay, the marketplace walled install and the sample-data reseed) looked up existing rows by natural key among that organization's rows only. A row holding the same natural key with `organization_id` NULL, as a database whose rows predate organization stamping holds them, was invisible to that lookup, so the upsert inserted a twin beside it that carried the organization. Nothing was reported: 0 errors, nothing logged. On a HotCRM 4.0.1 → 5.0.0 upgrade this doubled every seeded event, task, attendee and line item. + + - **FROM:** in `upsert` or `ignore` mode, with `config.organizationId` set, a seed record whose natural key no row of that organization holds, but a row with no organization does, was inserted as a new row for the organization. + - **TO:** that record is refused. The load reports it in `errors` with the existing `ReferenceResolutionError` shape: `field: 'organization_id'`, `targetObject` the seeded object itself, `targetField` the natural key, `attemptedValue` its value, and a message that names the row holding the key and the remedy. It counts as errored, the load answers `success: false`, and the refusal is logged at `error`, once per dataset. The row with no organization is left exactly as it is: the loader never adopts it. Every other record still loads. + - **Unchanged:** a key the organization's own row holds is matched as before, even when an unowned row also holds it. Another organization's rows stay invisible to the lookup. A fully stamped database loads with 0 inserts. `update` mode (which never inserts) and the `insert` / `replace` modes are unchanged, and so is a load with no `config.organizationId` (the single-posture boot), which still matches the unowned row. + + What to do. Attribute the rows that have no organization with `os migrate organization-ownership` (plan, then `--apply`), then run the seed again: each row is then the organization's own, and the upsert matches it. +- 2ec6827: feat(metadata-protocol): the draft save records `draftChanges` — which surface, actor and instant last changed each key path of a pending draft — and the strict draft read serves it + + Clause-②: yes (widening) + + - **Recorded by the draft save.** `saveMetaItem({ mode: 'draft', surface })` compares the body it writes with the body it replaces, inside the write's own transaction: the draft row, else (the draft's first save) the active row its publish would replace, else nothing, which records `[]`, the whole body. A path is the object keys from the body root to the deepest changed key; an array is one leaf, with no index. Each changed path is stamped `{ by: surface ?? 'api', actor, at }`, where `actor` is the value the save's history event carries (or `null`) and `at` is the write's instant as `toISOString()`. + - **Kept until publish or discard.** No two entries share a path: a save replaces every entry at or under each path it changes, so the longest prefix answers for a key. A save that changes no key path leaves the entries as they were. Publish and discard delete the draft row, and the entries with it, so the next draft starts clean. A `migrate-stored` rewrite and an active save record nothing. + - **Served on the strict draft read only.** `getMetaItem({ state: 'draft' })` answering a stored draft row carries `draftChanges` beside `item`. It is absent on the active read, the cached branch, the `?preview=draft` read, and a draft row whose saves recorded none (one saved before this release) — never `[]`. + - **Stored on the row.** `sys_metadata` gains a nullable `draft_changes` column (`@objectstack/metadata-core`'s `SysMetadataObject`), never inside the stored body. Schema sync adds it to an existing table on boot with no manual step (measured on SQLite and the memory driver). + - **Translated labels.** `@objectstack/platform-objects`' translation bundles carry the new column's label and help text in every shipped locale. + - **An unknown `surface` is refused.** In-process, a value outside `copilot | studio | api` is refused `400 INVALID_REQUEST` before anything is written. +- 33a45cc: fix(metadata-protocol): removing a field that stores nothing is not a destructive change + + Clause-②: yes (widening) (the save door newly accepts, without `force`, a removal it refused; owes the seat's `TIER_DEFAULT` review of record before the queue) + + - **What changed.** The object save's safety diff (`PUT /api/v1/meta/object/:name`, and every other door that reaches `saveMetaItem`'s destructive gate) now judges each finding by whether the field is stored, using the spec's storage predicate `isVirtualSearchField` (`SEARCH_VIRTUAL_TYPES`) on the field's own declaration — the predicate the sortability projection already reads. A `formula` field is computed on read and has no column (ADR-0079 addendum, item 9), so: + - removing a `formula` field saves `200` without `?force=true` (it answered `409 DESTRUCTIVE_CHANGE` with "existing data in this column will become inaccessible"); + - retyping a `formula` field into a stored kind saves without `force`: the new column starts empty, and no stored value is lost. + - **Not changed.** Removing a stored field, including an engine-computed but stored one (`autonumber`, `summary`), still answers `409 DESTRUCTIVE_CHANGE` with the same `field_removed` finding and message. Retyping a stored field into a `formula` still answers `409` with `field_type_change`. The `force` mechanism, the messages and the error codes are unchanged. + - **Who notices.** An author or an agent that removes a formula field no longer needs `force=true` for it, so `force=true` stays reserved for a real loss of stored data. + +### Patch Changes + +- 5a871e4: perf(metadata-protocol): the served by-name read takes its stored rows from the overlay cache, and only a `sys_metadata` write retires that cache + + Clause-②: no (no accept set, served body or authz answer moves: fewer queries and rounds for the same answer; owes the seat's review of record before the queue, at `CONTRACT_REVIEW_TIER` by the seat's choice, since it changes when authz and localization caches are trusted) + + - **`getMetaItem` reads the item's active `sys_metadata` rows through the overlay cache.** The list read already used that cache. The REST API-exposure gate reads the object by name on every `/data` request, and that read was its one uncached `sys_metadata` round trip. The cached value is the raw row set. Parsing, merging and registry hydration still run on every call, hit or miss. Only the served face reads through the cache. The protocol's own authoring checks, such as the destructive-change check before an object save, still read the store on every call. + - **Only a write to `sys_metadata` retires the cache.** The write is observed by an engine middleware after it settles. Every non-`write` epoch reason also retires it, including the metadata cluster bridge's `remote` bump, and `OS_METADATA_OVERLAY_CACHE_TTL_MS` still bounds it. Before, any engine write to any object retired it. A row set is cached only on an engine that has both the write epoch and `registerMiddleware`. With the TTL set to `0`, nothing is registered on the engine. +- 3ae0b77: fix(metadata-protocol): a seed reference written blank (`''` or whitespace-only) is no reference, by the engine's own `isMissing` + + Clause-②: no (no published accept set or surface moves: the row was already written, and only the false "unresolved" report and its `success: false` go away; owes the seat's `TIER_DEFAULT` review of record before the queue) + + - **What changed.** The seed loader skipped a reference only when its value was `undefined` or `null`, so a lookup written `''` was resolved as a natural key, missed, deferred, and reported after pass 2 as `Deferred reference unresolved … = '' → … not found`: `errored` counted it and the load answered `success: false` for a row that had landed. A replay reported the same error again. Every site that decides whether a reference has a value now asks `isMissing` from `@objectstack/core`, the rule the engine's insert door and required check already use: a single reference, each element of a `multiple: true` reference in pass 1 and in pass 2, and the id half of a `referenceVia` pointer pair (which already skipped `''` and now also skips whitespace-only). + - **A blank single reference** is not resolved and not reported. Its value is left as written, so the engine decides it: an optional lookup's row lands with the field empty and a replay skips it clean; a required lookup is refused by the engine's own required check (`book is required`), and no longer also reported as an unresolved reference. + - **A blank element of a `multiple: true` reference** is dropped like a `null` hole, and the other elements resolve. Before, the blank element made the whole field defer and fail, so the row landed with none of its associations. + - **What stays the same.** A real natural key resolves as before, and one that names no row is still reported. No export, error code, configuration key or result shape changed. Nothing to do for a consumer. +- 4ff18d3: fix(metadata-protocol): a batch row answers a sandboxed hook's refusal in the hook's own words + + Clause-②: no (no accept set or route moves, and a row keeps its shape: a sandboxed hook's refusal now carries the sentence and code the other data doors already answer) + + - **A sandboxed hook's refusal reads as its sentence on a batch row.** When a sandboxed hook body refused a row with `throw new Error('…')`, `POST /api/v1/data/:object/batch` answered that row with `code: 'INTERNAL_ERROR'` and `The create of this record failed. The reason is in the server log.` `POST /data/:object`, `/createMany` and `/import` answer the hook's sentence. The row now carries that sentence and `code: 'VALIDATION_ERROR'`, the code those doors give a refusal that declared no code or status. Rows of `updateMany` and `deleteMany` answer the same way. The row still has no `httpStatus`, because the hook declared none. + - **A code or status the hook declared still rides.** For a refusal that declared a 4xx status, the row used to quote the `hook 'NAME' threw: Error: …` debug wrapper. It now quotes the hook's sentence. + - **Unchanged:** a hook body that crashed, a refusal that declared a 5xx, the sandbox's own faults (a time or CPU budget, a denied capability) and driver errors keep the withheld sentence and the server-log warning. A crashed body that declared a 4xx is now withheld as well. Before, the row quoted its wrapper with the native error text. +- 3566c08: fix(metadata-protocol): a seed replay no longer rewrites an unchanged row whose `time`, `datetime` or `date` value the seed spells differently from the stored form (#24562) + + Clause-②: no + + The replay's no-op check (`isNoOpReplay`) compared a seed value with the stored one as text, so `'09:15'` against the stored `'09:15:00'`, `'2026-06-17T14:30:00Z'` against `'2026-06-17T14:30:00.000Z'`, and a `date` written as an instant against its stored `YYYY-MM-DD` all read as changed. Every boot rewrote those rows: `updated_at` moved, lifecycle validation ran again, history tracking recorded an edit nobody made, and a parent's summary recompute moved the parent's `updated_at` too. + + A field the object declares `time`, `datetime` or `date` is now compared in its stored form, by the rule the drivers apply on write (`temporalStorageForm`, `@objectstack/core`). Two spellings of the same wall clock, instant or calendar day skip the row. A different value is still rewritten, and a value that rule cannot read still counts as changed. What a seed may declare, and what any write stores, is unchanged. +- 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`. +- 66949b7: feat(plugin-security)!: the four security-catalog row objects are retired — `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` (ADR-0131 D3) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Positions, permission sets and capabilities live in the security catalog — the environment registry, fed by package declarations and by environment metadata saved through `/api/v1/meta` (ADR-0131 D3). The four row objects that mirrored them are no longer registered, so their data and REST routes and their Setup object pages are gone. No table is dropped and no row is deleted. + + FROM → TO: + + - `GET|POST|PATCH|DELETE /api/v1/data/sys_permission_set` → `GET|PUT|DELETE /api/v1/meta/permission/:name`; to copy a set, `POST /api/v1/security/permission-sets/:name/clone` with `{ name, label }`. + - `… /api/v1/data/sys_position` → `/api/v1/meta/position/:name`. A position's permission sets are its definition's `permissionSets`. + - `… /api/v1/data/sys_position_permission_set` → the position definition's `permissionSets` (`PUT /api/v1/meta/position/:name`). + - `… /api/v1/data/sys_capability` → read-only `GET /api/v1/meta/capability`; a package declares a capability with `defineCapability` (`stack.capabilities`). + - Setup → Access Control → Positions / Capabilities / Permission Sets now open Setup's catalog lists (`/apps/setup/metadata/?scope=environment`) instead of the object pages; the `sys_position` record page (`SysPositionDetailPage`) is removed. + - An object permission on one of the four names in a permission set grants nothing and can be deleted. + + What else changes: + + - `sys_user_permission_set.permission_set_id` is a plain text column now, not a lookup: same column, same stored ids, same indexes. It is not written and grants nothing; a grant names its set by `permission_set`. + - A permission set saved through the metadata door is written to no row: the `permission` mutation projector, the `sys_permission_set` and `sys_position` data-door write-throughs and the row-only position backfill are no longer registered. + - On a fresh `single`-posture database the first human is promoted to platform administrator without reading any catalog row. An unscoped grant written before `sys_user_permission_set.permission_set` existed (it names no set) still withholds the promotion until the organization-ownership migration names it. + - Uninstalling a package still revokes every grant naming a permission set the package declared, by the names the uninstalling door captures before the withdrawal (`declared`, `captureUninstallDeclared`), and now by those names alone: the cleanup reads and removes no `sys_permission_set` or `sys_position_permission_set` row, so the `sets` and `positionBindings` counts on its outcome are always 0. A grant written before `sys_user_permission_set.permission_set` existed names no set and is not reached; the organization-ownership migration names it. + - On a `group` or `isolated` deployment the data-door half of the walled catalog refusal goes with the objects: a data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability` answers `404 OBJECT_NOT_FOUND` for every caller instead of `403 PERMISSION_DENIED` below the platform operator. The refusal at the `/meta` write doors, the permission-set clone door and the `/packages` write doors is unchanged. + - `capability-reference-unknown` no longer counts a `sys_capability` seed row as a declared capability — no runtime reader consults one. Declare the capability with `defineCapability`. + - On an existing database `os migrate plan`'s unmanaged-table sweep now lists the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` tables: they keep their rows and no object declares them any more. The sweep only reports; nothing is dropped, and the tables stay for the organization-ownership migration. + + Removed exports, no replacement: + + - `@objectstack/plugin-security`: the object definitions `SysPosition`, `SysPermissionSet`, `SysPositionPermissionSet`, `SysCapability` (from `./objects`); `securityObjects` lists the remaining three. + - `@objectstack/platform-objects/pages`: `SysPositionDetailPage`. + - `@objectstack/spec`: `SystemObjectName.POSITION`, `SystemObjectName.PERMISSION_SET`; the four names leave `PLATFORM_OBJECTS_BY_PACKAGE['plugin-security']` and `PLATFORM_PROVIDED_OBJECT_NAMES`. + - `@objectstack/core`: `isRowActive` and `ActivatableRow` — a catalog row's `active` column decides nothing; deactivation is the activation ledger's (`sys_metadata_activation`). + - `@objectstack/objectql`: the published `PLATFORM_OBJECT_TENANCY` record loses its `sys_permission_set` key (the other three names were never in it). A removed key of an exported constant is a narrowing, so `@objectstack/objectql` takes `minor` under the launch-window convention for breaking changes. +- 8c105e3: The legacy-organization boot report reports each of its reads on its own, and a stored row under a code-defined datasource name is named once at boot + + Clause-②: no + + - `[metadata_org_scoped_unserved]` reads `sys_metadata` and counts the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit` as four separate terms. A read that fails for any reason but a missing table now costs only its own term: the line names it NOT MEASURED, with the reason, and still reports the other terms. Before, one failing read silenced the whole line. + - `[metadata_sealed_overlay_unserved]` no longer lists an environment row stored under the name of a datasource the host defines in code. The datasource restore's boot warning already names that row, with its own remedy, so the row was named twice. No read serves the row either way, and nothing is deleted or rewritten. +- b88c356: The remaining platform producers in these four packages now pass the explicit system opt-in (`{ isSystem: true }`) on their data-engine calls. Until now they reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. + + Clause-②: no + + - **service-messaging, the inbox read state.** `listInbox` (and its unread total), the receipt read behind it, and mark-read / mark-all-read take the opt-in inside the service. Their scope is unchanged: every read of a user's rows is keyed on the user id the door derived from the session, the receipt a mark-read inserts is stamped with it, and the receipt it updates is one a user-keyed read returned. + - **service-messaging, `owner_of:` audiences.** The record read takes the opt-in, the same posture as the email lookup beside it. It reads only `id` and the owner fields, and only the owner id leaves the resolver. An `owner_of:` audience on an object whose sharing model is `private` now resolves its owner; before, it resolved to nobody. + - **service-messaging, the rest of the fan-out and the outboxes.** The `role:` and `team:` membership reads, the email and SMS recipient reads, the notification template read, the dedup lookup in `emit()`, and both outboxes' enqueue, ack and list. + - **service-storage.** `StorageMetadataStore.createFile` and `createSession` insert under the opt-in. The organization still reaches the driver beside it, so the stored organization is unchanged, and the file's `owner_id` is still the uploading user. + - **service-settings.** The `sys_secret` store the plugin builds (insert, get, update), and the read that verifies a rotation before the old secret is reaped. A store `update` now writes the `ciphertext` it is given; without a context the engine's read-only strip dropped it. No caller in this repository uses `update`. + - **metadata-protocol.** `SysMetadataRepository.getByHash`, `list`, `history` and the history replay of `watch()`. + - None of the gates the middleware runs before its hand-off applies to these calls. ⛔ No new export on any package entry, and no new elevation API. +- a3bcbcf: feat(plugin-security)!: a data-engine context that carries no principal and is not a system context is refused (ADR-0096 D5 strict mode) + + Clause-②: no (narrowing) + + + + **BREAKING** for in-process code: an accept-set narrowing of the data engine's security middleware, shipped as `minor` under the launch-window convention for breaking changes. + + **What is refused now.** A data-engine operation whose execution context carries no principal (no user id, no position, no permission set) and is not a system context. That covers a call that passes no context at all, an empty one, one with only a tenant id, and one with only provenance fields. Every layer answers it the same way: + + - the engine middleware throws `PermissionDeniedError`, `403 PERMISSION_DENIED`, for every verb (find, findOne, count, aggregate, insert, update, delete), before the operation runs; + - `canReadObject`, `canExport` and `canWriteObject` answer `false`; + - `getReadFilter` answers the deny filter (zero rows). + + It used to be handed straight through, with no CRUD gate, no row-level security, no field mask and no tenant wall. That contradicted the published contract for an empty tool-execution context, "unauthenticated (RLS-on, sees-nothing)", which this change now keeps. The field projections (`getReadableFields` and its siblings) answer such a context as they answer any caller that resolves no permission set. + + **What is unchanged.** + + - A system context (`isSystem: true`) is admitted everywhere, as before. + - A context that carries a principal without a user id is decided by what it carries: a named permission set, the guest principal, or the public-form grant. + - An unauthenticated HTTP request is answered as before: a door that requires a session answers `401 UNAUTHENTICATED` before the engine is asked anything, and an endpoint an application declared open runs it as the guest principal. + + **What to do.** Code that calls the data engine in-process must state who it acts for. FROM: an engine call with no context, or with a context that names nobody. TO: either + + - pass the caller's execution context, so the caller's own permissions decide; or + - for platform plumbing whose own door already authorized the caller (a store read or write the platform owns), pass the explicit system opt-in, `context: { isSystem: true }`. + + The one-line fix: give every in-process engine call a context, the caller's or `{ isSystem: true }`. The refusal message names both. + + `@objectstack/metadata-protocol`: the protocol's own platform-store reads and writes behind the publish, history, audit, diff, commit, migration and code-only delete doors now pass the explicit system opt-in, like its other store calls. Nothing those doors answer changes. +- 1abfc58: The metadata door serves a code-defined datasource's code definition while a stored row under its name still exists + + Clause-②: no + + - `GET /api/v1/meta/datasource/:name`, the `GET /api/v1/meta/datasource` list and the `effective` layer of `GET /api/v1/meta/datasource/:name/layers` now skip a stored `sys_metadata` row under a datasource name the host registers from code: one an installed package declares in `*.datasource.ts`, or the host's `default`. They serve the in-memory code definition instead. The datasource admin door and the boot restore already serve that ("code wins on collision"). Before this change the stored row was served first, so the two doors answered with two different bodies for one name. + - The decision is made by name, through the same predicate the reads already ask for a shipped flow name. It never reads a row's `origin`. Every other type keeps ADR-0005's read order, in which the stored overlay wins. + - Unchanged: the row stays at rest and is still reported in the layered read's `overlay`. The read envelope stays `deletable: true` while the row exists, and `DELETE /api/v1/meta/datasource/:name` still removes it as the repair. A draft read (`state: 'draft'`, or the draft preview) is still answered from the draft row. A runtime datasource's stored row is served as before. + - The `/meta` and admin doors already refuse to write such a row. This change affects only how a row left from before that refusal is read. + - Not moved: `GET /api/v1/meta/datasource/:name/published` still serves the stored row, which is the active overlay row that route describes. + - ⛔ No public export, signature, schema or accept-set change. The built entry declarations gain two `private` member names on `ObjectStackProtocolImplementation`. +- db87a02: fix(metadata-protocol): another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of a view container are saved + + Clause-②: no + + - **What was wrong.** Where packages ship the same view container, the view list (`getMetaItems` for `view`) served one item for each name a saved environment-wide copy of that container expands: the copy's own expansion. Every other package's item of that name, shipped or saved, was left out. The anonymous form endpoints judge a withdrawal against the environment-wide view list, so they could miss another package's withdrawal of such a form. + - **What it does now.** The view list serves each package its own item of such a name: + - a package's saved copy of the container serves that package's item of each name it expands; + - a package-less saved copy stands in for every package that has no copy of its own (ADR-0048); + - any other package keeps its own item. + + So another package's withdrawal of a form holds at the anonymous form endpoints, whatever packages' copies of the container are saved. The organization-scoped save check reads the same list, so it judges each package's item too. + - **A stored view row of exactly such a name** keeps its own package's slot only. A package-less row still serves every package's slot. Before, any package's row of the name kept every package's copy expansion of it out of the list. + - **The by-name read agrees.** `getMetaItem` naming a package serves the item that package's slot in the list serves. Where no copy belongs to that package, a package-less copy now stands in for it. A list scoped to a package (`GET /api/v1/meta/view?package=`) serves the same item in each slot the package lists. A package-less copy adds no item to that list. + - **What does not change.** Within one package, a later expansion of a name still replaces an earlier one, and the save door's view container collision check is unchanged. A by-name read that names no package answers as before. No key, export, status or error code changes. +- 6befe19: A metadata row stored with no `checksum` can be edited and removed through the metadata door (#21978) + + Clause-②: no + + - `SysMetadataRepository` serves a `sys_metadata` row that carries no `checksum` as the hash of its stored body, but its `put` and `delete` compared the caller's parent with the raw column (`null`). So `PUT` and `DELETE /api/v1/meta/:type/:name` answered `409 METADATA_CONFLICT` ("Expected parent … but current is null") for every such row, with `If-Match` set to the version the door served and with no `If-Match` (last-write-wins) alike. A publish over such a row was refused the same way, as were the rollback and commit-revert doors, which take their parent from the same read. The datasource admin door stored such rows before it stamped them. + - `put` and `delete` now accept the version such a row is served as. A `null` parent still matches it, and a row with a `checksum` is judged exactly as before. A stale version is still refused with `409 METADATA_CONFLICT`, and the refusal now names the row's served version as the current one instead of `null`. + - The next write stamps the row's `checksum`, as every write does. Stored rows are not rewritten. + - Publishing a draft row stored with no `checksum` now also removes that draft row. Before, the post-promotion cleanup was refused by the same lock and the draft stayed pending, with nothing reported. +- 2015c54: A by-name metadata read that names no package now wears the package of the body it serves + + Clause-②: no + + - **What was wrong.** `getMetaItem` with no `packageId` (behind `GET /api/v1/meta/TYPE/NAME` when no package is named) merges the registry artifact's protection envelope, `_packageId`, `_packageVersion` and `_provenance`, over the body it serves. With no package named, that envelope was the first-registered package's. When two installed packages ship one name and the body served was the other package's, the answer carried that body under the wrong package. Two cases were measured. In the first, both packages ship a view container and one of them stores a copy of it: the read served the copy's view and named the first-registered package. In the second, a stored row of the name is bound to one package. The answer's top-level `packageId` / `provenance` / `packageVersion` fields, which are read off the served item, said the same wrong thing. + - **What it does now.** With no package named, the envelope is looked up at the package the served item is bound to. That is the stored row's package, the package of the container copy that expands the name, or the `_packageId` of the MetadataService or registry item. This is the rule the `GET /api/v1/meta/TYPE` list already applies to each item it serves, so the list and the by-name read now give one envelope for one served body. + - **Unchanged.** Which body the read serves. A read naming a package. The lock family and the `lock` / `editable` / `deletable` envelope, which still come from the item-lock resolution over the read's own address. A served item bound to no package (a package-less stored row or copy, or a registry entry with no package) keeps the package-less lookup it had, so for a name only one package ships, a tenant's package-less overlay still wears that package's envelope. The layered read (`/layers`) is not changed. + - ⛔ No public export, signature, schema or accept-set change. Nothing is accepted or refused differently. +- ae97841: A by-name view read naming a package now serves that package's view when another package's stored row has the same name + + Clause-②: no + + - **What was wrong.** Two installed packages can ship a view of one name, and a stored `sys_metadata` view row can be bound to one of them. The registry hydration registered that row under the view's bare name. The registry answers the bare name ahead of every package's own entry, whichever package the read names. So `getMetaItem` naming the OTHER package found no row of its own and served this row's body at its registry step, under the other package's envelope. That is the read behind `GET /api/v1/meta/view/NAME?package=…`. Meanwhile the list's slot for that package served its own view. This was measured on an unscoped kernel after a save, and on either kernel after a cold boot, because `loadMetaFromDb` hydrates through the same door on every kernel. + - **What it does now.** A view row bound to one package is not registered under a name another package ships. This is the shape the view-container expansion registration already takes. The reads answer the row from the row itself: the read naming its own package, the read naming no package, and the list's slot for that package. The read naming the other package serves that package's own view and envelope. The delete's registry heal also stops re-registering a metadata-service view baseline bound to one package under such a name. + - **Scope: `view` only.** Every other type registers as before: a row bound to one package, of a name two packages ship, keeps the bare entry with its own body and its own package's envelope. A package-less view row, and a view row of a name that only its own package ships or that no package ships, also register as before. The environment-scoped kernel's answer after a save is unchanged, because it registers nothing on a save. + - ⛔ No public export, signature, schema or accept-set change. Nothing is accepted or refused differently. The built entry declarations gain one `private` member name on `ObjectStackProtocolImplementation`. +- 35afb15: fix(metadata-protocol): a save of a packaged item whose type allows no overlay answers `403 NOT_OVERRIDABLE` before any check that judges its body, on every kernel topology + + Clause-②: no + + - **What was wrong.** `PUT /api/v1/meta/:type/:name` refuses an in-place write onto an item a code package ships when the type has no per-organization overlay channel (`allowOrgOverride: false`, for example `object`, `permission`, `position`). An environment-scoped kernel answered that refusal before it judged the body. A host-config kernel (the CLI's assembler, the showcase's boot shape, `OS_MODE=off`) answered it only at the repository write, after every other check. So on that kernel a publish of a packaged object whose body the authoring gate refuses answered `422 INVALID_METADATA` with findings the author could not land through this door, and a body the spec parse refuses answered `422` in draft and publish mode. After fixing the findings, the author got the `403`. + - **What changes.** The package check now runs on every topology, at the position it already had on an environment kernel: after the code-only and organization-scope refusals, before the item lock and every check that reads the body or the store. The same request now gets the same refusal on both kernels: `403 NOT_OVERRIDABLE`, or `403 ITEM_LOCKED` when the save names the read-only package, with the same sentence. On a host-config kernel that sentence replaces the repository's "is not allowOrgOverride in the registry" text for these saves. + - **What does not change.** Every request refused before is still refused, and every request admitted before is still admitted. The repository refused the same writes on every topology, and it still does, for the doors that reach it without this check. An environment-local item, an item of a type that allows overlays, and a save with `OS_METADATA_WRITABLE` open for the type are judged by the same checks as before, and drafts are still not judged by the authoring gate. +- 3f80f17: A cel-dated seed row reaches every hook in the stored form of its temporal field + + Clause-②: no + + `` cel`daysFromNow(n)` ``, `daysAgo(n)`, `today()` and `now()` in a seed record resolve to a JS `Date`. The seed loader handed that `Date` to the engine, so what a hook saw depended on how the hook ran: + + - An in-process `handler` (a source config, which `@objectstack/verify`'s `bootStack` boots) saw the `Date` object. + - A sandboxed `body` (the compiled artifact `objectstack dev` boots) saw a full ISO instant, even on a `date` field. + + A hook that reads a date field as a string therefore accepted the row under `objectstack dev` and refused it under `bootStack`, and the row was not stored. + + The loader now puts each `Date` on a `date`, `datetime` or `time` field into the stored form the drivers write (`YYYY-MM-DD`, the UTC ISO instant, `HH:MM:SS`) before any hook runs, so every hook sees the value the column stores. A body hook on a `date` field now sees `2026-10-09` where it saw `2026-10-09T00:00:00.000Z`. Stored values do not change on any driver, because each driver already normalises a declared temporal field on write (`@objectstack/driver-memory` through the same `temporalStorageForm`). A `Date` on a field that is not temporal, or on a field the object does not declare, is handed over unchanged. +- cb3bb93: fix: a hook's refusal reached through an action or another hook answers in the hook's own words, and `$top=0` reports the real `total` (#22588) + + Clause-②: no + + - **A nested sandboxed refusal keeps its sentence (`@objectstack/runtime`).** A script action whose body writes through `ctx.api` and is refused by a sandboxed hook used to answer with the hook's debug wrapper in front of the sentence: `POST /api/v1/actions/:object/:action/:id` → `400 VALIDATION_ERROR` "`hook 'guard' threw: Error: A finished task cannot be reopened.`". It now answers the hook's sentence alone, as `PATCH /api/v1/data/:object/:id` and a declarative `operation: 'update'` action already did. The same holds for a hook that writes another object through `ctx.api` and is refused there: `/data` answers the inner hook's sentence. Status and `code` do not change. The cause was the sandbox's VM hop: a host error crosses into a body's VM as `name` and `message` only, so the refused write's sentence was left behind and the wrapper became the outer error's caller-facing text. The sentence now travels beside the wrapper and is used when the body lets that error escape unchanged. A body that catches the error and throws its own (or rewrites its message first) is still answered in its own words, a nested crash is still a `500`, and the server log still names every frame. + - **The `/actions` door reads its sentence through `sandboxBusinessMessage`** (`@objectstack/types`), the same read `/data`, the bulk doors and `/analytics/dataset/query` make, instead of its own copy. The wire answer is unchanged. + - **`$top=0` reports the filtered `total` (`@objectstack/metadata-protocol`).** `limit: 0` asks for no rows, and `findData` treated it as "no limit", so the zero-length page was reported as the total: `GET /api/v1/data/:object?$top=0` answered `total: 0` while `$top=1` answered the real count. The Console reads its list footer's record count from exactly that `$top: 0` request. A zero-row request is now a paged request: it returns no rows and the same `total` as any other page size, filters included. `$count=false` still skips the count and now omits `total` for `$top=0` too, instead of reporting `0`. A `search` query still reports its page-local estimate, as it does at every page size. +- c4e7fa5: feat(core): export `relationConditionSites`, the one walk that finds a filter's nested-relation conditions and each one's target object (#22777) + + `relationConditionSites(fields, filter)` returns every `{ : { : … } }` condition a filter holds, at the top level and under `$and` / `$or` / `$not`, with the reference field, the target object (resolved by the spec's `referenceTargetOf`) and the condition. An id comparand, an operator bag and a non-reference field are not conditions. + + It moved unchanged from `@objectstack/metadata-protocol`, where it was module-private, so that the MCP stdio data bridge (`@objectstack/mcp`) and the generic data door ask each condition's target its exposure over the same sites. `@objectstack/metadata-protocol` now imports it; its behaviour does not change. +- 55382dc: A paged list under a search now reports the real number of matching records as `total`, and `hasMore` agrees with it on every page. + + Clause-②: yes (widening) — `EngineCountOptionsSchema` gains two OPTIONAL keys, `search` and `searchFields`, so the accept set of the count options grows. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write them. + + Until now `GET /api/v1/data/:object?$top=25&$search=Line` over 75 matching records answered `total: 26` (offset + page + 1, a page-local estimate), and the last page claimed `hasMore: true`; `POST /api/v1/data/:object/query` with a `search` did the same. Nothing on the response marked the number as an estimate, so the Console rendered it as the record count and offered the wrong number of pages. + + - **`@objectstack/spec`** — `EngineCountOptionsSchema` declares `search` (the bare string, or the structured `FullTextSearchSchema` form) and `searchFields`, identically to `EngineQueryOptionsSchema`. FROM `count(object, { where })` only → TO `count(object, { where, search, searchFields })`. The structured `search` form carries flag defaults, so the parsed state is named `EngineCountOptionsParsed` (ADR-0122). `HookCountQuery` (`ctx.api.object(name).count()`) derives from it and gains the same two keys. + - **`@objectstack/objectql`** — `engine.count()` (and `ctx.api.object(name).count()`) accepts the two keys it used to refuse as unknown options, and expands them through the same ADR-0061 expansion `find()` and `aggregate()` use: the same server-resolved searchable fields, the same `internal: true` exclusion, the same `searchFields` narrowing, AND-ed with `where` before the security middlewares run. There is one expander, not two, so a count under a search is the number of rows `find()` returns for the same bag. + - **`@objectstack/metadata-protocol`** — `findData`'s paged branch passes `search` / `searchFields` to `engine.count()`, and the page-local estimate branch is gone. `$count=false` still skips the count and omits `total`. + + Nothing to migrate. A client that worked around the estimate, for example by labelling the count as page-local under a search, can read `total` as the match count again. +- 5677add: fix(metadata-protocol): two locales of one email template saved through the metadata door both survive a restart — an overlay row is keyed by its bundle member (#22877) + + Clause-②: no + + **What an administrator sees.** Saving the same email template in two languages through the metadata door (`PUT /api/v1/meta/email_template/:name`, the Studio editor's save) keeps both. Before, the store kept one overlay row per template name, so the second language's save replaced the first language's row: both read back until the next restart, and then the first language mailed the package wording again. Each language now keeps its own wording across restarts, and each send in that language uses it. + + **What changed in the store.** + + - `sys_metadata` gains one nullable column, `bundle_member`. It holds the member of a metadata type whose identity is a pair — today `email_template`, identified by `(name, locale)`. NULL is the canonical member (`en-US`, the locale a send falls back to). Every other row stays NULL, so rows of every other type, and an `en-US` template row, are byte-identical to before. + - The value is taken from the saved body's `locale`; it is never authored. A body that declares no `locale` is the `en-US` member. + - The overlay uniqueness index becomes `(type, name, organization_id, COALESCE(package_id, ''), COALESCE(bundle_member, ''))`, for active rows and for draft rows, and the declared index in `metadata-core` carries the column too. A second row for the same template, language, package and state is refused by the index. + + **What a read or a door addresses.** A save addresses the language its body names. A read, delete, publish, history or revert by name that names no language addresses the `en-US` member: `GET /meta/email_template/:name` serves the `en-US` wording (the stored one when it was customized, else the package's), where before it served whichever row the name had. The layered list and the send path serve every language as before. A non-`en-US` language's own customization is reached through the list, and is replaced by a save that names its `locale`; a by-name reset (`DELETE`) removes the `en-US` customization only. The lineage counter stays one per template name, so a version number names one saved snapshot, and a revert by name restores only a version of the `en-US` member. Resetting, publishing or reading a non-`en-US` language by name needs the by-name member selector, which arrives with stage 2 of #22877; until then those doors address the `en-US` member. + + **What happens on the first boot after upgrading.** Nothing to configure. + + 1. The schema sync adds the `bundle_member` column. + 2. Boot hydration gives every `email_template` row stored before the column existed the member its body declares, so a row an earlier save left holding a second language's wording becomes that language's row, not the `en-US` one. Rows of every other type are not read or written. + 3. The first metadata write rebuilds the overlay index with the new key, through the existing probe-then-replace order. Adding a key part only relaxes uniqueness, so no rows a store already holds can block the rebuild; a store that was already blocked is reported as before, with the duplicate-listing query and `os migrate duplicates`. + + **On a database without partial indexes (MySQL).** The rebuild above is refused there, as before, and reported in the server log at the first metadata write. A store that booted an earlier version keeps the four-column declared index under `idx_sys_metadata_overlay_active`, because the schema sync skips an index whose name already exists. That index has never constrained an environment-wide overlay row: those rows store no organization, and a UNIQUE key does not collide NULLs. So two languages of one template coexist there too, and no save is refused. `os migrate plan` lists the declaration change on that index as a destructive `recreate_index`. To bring the index to the five-column declaration, run `os migrate apply --allow-destructive`. A fresh store gets the five-column declaration. + + `os migrate duplicates` boots read-only and adds no column. Run against a store this version has not booted yet, it lists the two overlay indexes with every row read as the empty member — the value the column will hold — so a duplicate that will block the rebuild is still reported, and the entry's `duplicateSql` is the member-less statement that ran there. +- 6a6a4d3: fix: a refused on-demand schema sync (`syncObjectSchema`) is logged at `error`, naming what was not created and how to fix it (#22985) + + Three places create an object's storage after the boot's own schema sync has run. When the driver refused that DDL, each of them logged a `warn` and carried on, so the deployment looked healthy while a table did not exist. Each now logs one `error` line that says what was not created, that nothing else will look wrong, and what fixes it. The driver's refusal is kept out of the line itself. Nothing else changes: the boot, the connect and the publish still complete as before. + + - **`@objectstack/service-messaging`**: boot-time provisioning of the eight notification tables. A refused table logs `[messaging] could not provision
storage — its storage was NOT created. …` at `error`, once per table per boot, with the refusal in the record's meta. The fix it names: resolve the refusal, then restart or run `os migrate apply`. This used to be a `warn`. Under `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` the provisioning still issues no DDL, so this line cannot appear. + - **`@objectstack/service-datasource`**: connecting a declared datasource re-runs the schema sync of every object bound to it. A refused object logs `datasource '': object '' has NO working storage here — …` at `error`, once per object per connect, with the refusal in meta. On a logger that has no `error`, the same line is logged at `warn`. The fix it names: resolve the refusal, then restart. This used to be a `warn` reading `syncObjectSchema('') failed`. + - **`@objectstack/metadata-protocol`**: publishing an object whose storage sync is refused logs `[Protocol] object '' was published but its storage was NOT created or altered — …` with `console.error`. The driver's refusal is the second argument. The fix it names: resolve the refusal, then publish the object again or restart. The publish still answers success. This replaces the `console.warn` line `[Protocol] table sync failed for object '': …`, so a log search for that text now finds nothing. +- 0a29bec: fix(metadata-protocol): a metadata row records who created it and who last changed it + + Clause-②: no + + - **What was wrong.** A metadata write that named its author stored no author on the `sys_metadata` row. `created_by` and `updated_by` read null on every row, though the repository's own schema mapping declared `actor → updated_by` and its read side reads that column. Only the history row's `recorded_by` named the writer, so "who changed this object?" could be answered from history alone. + - **What it does now.** Every metadata write stamps the row from the write's actor: `created_by` when the row is first written, and `updated_by` on every write. Studio saves, `PUT /api/v1/meta/:type/:name`, publishing a draft, restoring a version and an in-process `saveMetaItem({ actor })` all write through this one path. A write with no actor (a boot sync, a migration with no user, an unauthenticated internal call) stores null in both columns, never a placeholder user. A save that changes nothing writes nothing, so it leaves both columns as they were. + - **What an operator or a consumer sees.** A row written from now on names its creator and its last writer, so a consumer (such as cloud's AI build agent, whose staged drafts name their author) can read the author from the row itself. The pending-changes list (`listDrafts`' `updatedBy`) names the draft's last writer. Rows written before this release keep their null columns until their next write. History's `recorded_by` is unchanged. +- 8c10776: fix(metadata-protocol): a stored-metadata migration records the user who ran it, or nobody, never a label + + Clause-②: no + + - **What was wrong.** The stored-metadata migration pass (`os migrate meta --stored`, `POST /api/v1/meta/_migrate-stored`, the runtime dispatcher's `POST /metadata/_migrate-stored`, and `migrateStoredMetadata()` called in process) recorded a label as the writer of every row it rewrote: `'os migrate meta --stored'`, `' (POST /api/v1/meta/_migrate-stored)'`, `'POST /api/v1/meta/_migrate-stored'`, or `'migrate-stored'`. The writer lands in `sys_metadata_history.recorded_by` and in the row's `updated_by`. Both columns are references to `sys_user`, so the label pointed at no user. Nothing refused it, because metadata writes run as the system. + - **What it does now.** The pass records the id of the user who ran it, exactly as every other metadata write does. A REST or runtime call by a signed-in user with `manage_metadata` records that user's id. A run with no user records `null`, which means "system-initiated". That covers the CLI, which opens the database directly with no session, and a system caller. The pass still names itself on each history row's `source` (`migrate-stored`), as it did before. + - **What an operator sees.** History rows and rewritten rows written by a migration from this release on name the person who ran it, or nobody. They never name a command or a route. Rows a migration rewrote before this release keep the label they were given. + - **Contract text.** `MetadataItem.authoredBy`'s description now gives the same rule as `PutOptions.actor`: the acting user's id, or `null` for a system-initiated write, never a label such as `"system"` or `"cli"`. The schema accepts the same values as before. +- abf8655: A language the environment added to an email template, which the package does not ship, is no longer treated as packaged code + + Clause-②: no + + An `email_template` is identified by its name and its `locale`, so the languages of one template form a bundle. When an administrator customizes a template in a language its package does not ship (for example a `zh-CN` version of a template the package ships in `en-US` only), that version is the environment's own item. Until now it was registered and served wearing the package's protection envelope, taken from the shipped `en-US` version: + + - the registry's artifact lookup answered the environment's `zh-CN` version as the package's own `zh-CN` artifact; + - `GET /meta/email_template/:name?member=zh-CN` and the `/meta/email_template` list served it with `provenance: 'package'`, the package's id and its version; + - the registry heal that runs after `DELETE ?member=zh-CN` refused to retire it as packaged code. + + - `@objectstack/metadata-protocol`: the artifact envelope (`_packageId`, `_packageVersion`, `_provenance`, and an artifact lock) is copied only onto a version of the same language as the shipped artifact. Each place that copies it looks up the artifact of the body's own language first. A language the package does not ship is registered and served as the environment's item. A language the package does ship keeps its envelope, its lock and its reset exactly as before. Types without languages are unchanged. + - `@objectstack/objectql`: `SchemaRegistry.removeRuntimeShadow(type, name, member)` lifts a named language's shadow only when the package ships that language. For any other language it declines, and the heal's last step (`removeOverlayEntry`) retires the entry. Naming no language still sweeps the whole bundle, as before. + + A lock still binds the template name, at the read and at the write gate alike: a template the package locks stays locked in every language. +- c6139f2: Resetting a published overlay of a view container now stops serving the views it expanded to, without a restart + + Clause-②: no + + - A view container (a `defineView` document, such as a package's `showcase_task` views) expands to one view per entry, registered under `.` (`showcase_task.default`, `showcase_task.in_progress`, …). A published overlay of the container registers its own expanded views under those names in the process registry. + - `DELETE /api/v1/meta/view/NAME` on such an overlay answered `reset: true`, but its registry heal restored only the container's own name. `GET /api/v1/meta/view` and `GET /api/v1/meta/view?object=OBJECT` kept listing the overlay's expanded views next to the packaged ones until the process restarted, and the end-user list kept rendering the overlay's columns and label. The by-name read of an expanded view served the overlay's copy too wherever no MetadataService answered first. + - The heal now also heals every view name the container registered when it was published, through the same steps as the container's own name. A packaged view of that name is served again, a view only the overlay added is removed, and a later publish of the container is served as before. + - Unchanged: an organization-scoped delete still touches no registry entry, re-registration still happens on a control-plane kernel only, and a view name another package ships, which the publish never registered, is never touched by the reset. Dashboards and every other type reset as before. + - No public export, signature, schema or accept-set change. The built entry declarations gain three `private static` member names on `ObjectStackProtocolImplementation`. +- 78c13bd: Republishing a view container without one of its views now stops serving that view, without a restart + + Clause-②: no + + - A view container (a `defineView` document, such as a package's `showcase_task` views) expands to one view per entry, registered in the process registry under `.`. A publish of the container registered the views it expanded to and removed none it no longer expanded to. So after `POST /api/v1/meta/view/NAME/publish` of a container whose `listViews` lost an entry, `GET /api/v1/meta/view`, `GET /api/v1/meta/view?object=OBJECT` and the by-name read of that view kept serving it until the process restarted. + - Every write that changes a view container's stored row now leaves the registry holding the views that row expands to: a publish, a save that publishes, a rollback, a revert, a replica's convergence, a reset and a delete. So does the list read's and the boot's hydration of a stored row. The write answers after this, so a read that follows it serves the published set. + - A view the container no longer expands to is healed the way a reset heals it: where a package ships a view of that name, the package's view is served again, and a name nothing else serves is removed. As for a reset, a name whose MetadataService read fails stays registered until a restart. + - When one container name has two stored rows (one bound to no package, one bound to a package), each row is compared with what that row registered before. A republish of one row removes only the views that row dropped, and the list read leaves both rows' views registered however often it runs. A view both rows expand to, which one row drops, is served from the other row's stored row and registered again by the next list read. A reset or delete of the name heals every row's views, as before. + - Unchanged: an organization-scoped write touches no registry entry, a baseline is re-registered on a control-plane kernel only, and a view name another package ships is never registered or healed for the container. Every other type registers and resets as before. + - No public export, signature, schema or accept-set change. In the built entry declarations, `ObjectStackProtocolImplementation` gains one untyped `private` member name and loses two `private static` ones. +- 9543e9c: Adopting loose items into a base no longer moves an overlay of a packaged item, and the views it does move are served from the base at once + + Clause-②: no + + - `POST /api/v1/packages/ID/adopt-orphans` rebound every metadata row bound to no package into the base. That included a row that overlays an item a package ships, such as a list view edited and saved from the record list, or an edited copy of a package's view container. Once bound to the base, such a row stopped customising the packaged item. After a restart, an adopted overlay of a package's view container added views under its own name, and the package's own view was served again. An adopted overlay of one packaged view was listed twice under its name. + - ADR-0070 keeps an overlay of a packaged item bound to the item it customises, so the adoption now leaves such a row as it is stored. A row is skipped exactly when a save of it would be an overlay (`override-artifact`). An environment whose only rows bound to no package are such overlays now answers `reassignedCount: 0` and `success: false`. + - Each view row the adoption does rebind is now written through to the process registry with the binding it left. So `GET /api/v1/meta/view`, `GET /api/v1/meta/view?object=OBJECT` and the by-name read serve it from the base right away, not after the next restart. + - A view container's views are reconciled the way a publish reconciles them. What the row registered before the move counts as its own, so a later write of a row of that name bound to no package no longer removes the moved row's views. + - Unchanged: the request and the response shape (`success`, `reassignedCount`, `reassigned`, `targetPackageId`). Also unchanged: rows already bound to a package, organization-scoped rows, and rows of other types, which are rebound in storage only. + - There is no public export, signature or schema change. +- 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. +- 5b68524: fix(metadata-protocol): an environment kernel that hydrates its own `sys_metadata` writes a published body through to its engine registry, so a narrowed permission set stops granting its old body without a restart + + Clause-②: no + + - **What was wrong.** The protocol's registry write-through skipped every type except `object` whenever the protocol had an `environmentId`. A kernel that hydrates its own store at boot (the standalone stack's `env_local`, or a hosted kernel with `hydrateMetadataFromDb`) put every stored body into its registry at boot and then never updated it. A save was accepted and served by `GET /api/v1/meta/:type/:name`, but the engine registry kept the boot-time body until a restart, and held nothing for an item created after boot. The security catalog reads that registry first, so a permission set narrowed through `PUT /api/v1/meta/permission/:name` kept resolving its earlier, wider body, and a permission set published after boot was not in the catalog at all: a user granted it was refused, and an `isDefault` set recorded no `everyone` suggestion. + - **What changed.** The write-through now asks whose registry it is, not whether the kernel has an environment. It registers the published body wherever the registry takes this kernel's stored rows: on an unscoped kernel, as before, and on an environment kernel once the boot has hydrated that kernel's own store into it (`loadMetaFromDb`). The delete's heal re-registers a MetadataService baseline on the same kernels, so a save followed by a reset does not leave the deleted body in the registry. + - **The owner.** The registry entry of a stored row now carries the row's package binding as `_packageId` on the boot and on the write-through, as a list read already stamped it, so a runtime package's `isDefault` set published through the metadata door is owned in the security catalog at once and records its `everyone` suggestion, on a host kernel and on a hydrating environment kernel alike; a row bound to no package stays unowned, and an artifact's own envelope still wins. + - **Unchanged.** An environment kernel whose store is never hydrated into its registry still writes nothing into it, so a registry shared with unscoped callers never receives a project-scoped row. The `object` branch and the organization-scope refusal are unchanged. What the registry holds after a save is what it holds after a restart. +- eeb95f7: fix(spec,objectql)!: a permission set whose body `packageId` disagrees with the package that ships it is refused at `defineStack` / `os validate` / `os build` and at package registration, and every reader takes a set's owner from its binding only (ADR-0086 D3) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing on a published authoring surface), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0086 D3 declares `PermissionSetSchema.packageId` as the owning package of a package-shipped set, absent for an environment-authored one. The owner every reader uses is the binding: the package that ships the set or that it is saved into, served as `_packageId`. The metadata save, restore and publish doors already refuse a body `packageId` that disagrees with it. Package source and package registration did not: a set whose body named another package was registered under the package that shipped it, with no refusal and no warning, so an author, an AI build above all, never learned that the owner it wrote was not the owner it got. + + **What is refused, and where.** One judge, `judgePermissionSetOwnerBinding` (new export of `@objectstack/spec/security`), serves every door: + + - **The stack schema** (`ObjectStackDefinitionSchema`), so `defineStack`, `objectstack validate`, `objectstack build`, the lint scorer and an artifact load all refuse a set whose body `packageId` is not the id of the package that ships it: the stack's `manifest.id`, each `packages[]` body's own `id`, and `manifest.id` for a top-level set no body declares. A stack with no `manifest` ships its sets from no package, so any key there is refused, the empty string included. The issue sits at the set's `packageId` (`permissions.0.packageId`); `defineStack` throws `STACK_SCHEMA_INVALID` (422) carrying it. + - **Package registration** (`ObjectQL.registerApp`, the manifest and a nested plugin, for a manifest no schema parse judged) throws `400 VALIDATION_ERROR` with `issues[]` at `packageId` before the set is filed, the envelope the same seam throws for a view container whose `name` disagrees with its key. + + The message prescribes first, names both values, and offers both remedies: drop the key, or ship the set from the package it names. No error code is minted. + + **Readers.** `@objectstack/plugin-security`'s packaged-set lock (`classifyPackagedPermissionSet`) and `@objectstack/cli`'s permission-set name-collision report take a set's package from its binding only. A set filed into no package whose body named one is no longer locked as that package's, and a stack with no `manifest` declares no owner for the collision report. + + **Unchanged.** A body `packageId` equal to the package that ships the set is accepted, and an absent one is accepted everywhere. A top-level copy of a set a `packages[]` body declares (an artifact built with both halves) is judged on its body, not against the artifact's own id. The metadata save, restore and publish doors answer in the same words as before (`@objectstack/metadata-protocol` now calls the shared judge). Stored `sys_metadata` rows are not rewritten. + + ## FROM → TO + + | door | FROM | TO | + |:--|:--|:--| + | `defineStack` / `objectstack validate` / `objectstack build` | `defineStack({ manifest: { id: 'com.acme.crm', … }, permissions: [{ name: 'crm_member', packageId: 'com.acme.other', … }] })` accepted; the set was owned by `com.acme.crm` | refused at `permissions.0.packageId`, `STACK_SCHEMA_INVALID` 422 | + | `ObjectQL.registerApp` | the same manifest registered the set under `com.acme.crm`, silently | `400 VALIDATION_ERROR`, `issues[]` at `packageId`, nothing filed | + | an artifact built before this change | loaded, the set owned by the shipping package | refused at load; rebuild it without the key | + + **Remedy, in one line.** Drop `packageId` from the permission set in your stack source (the package that ships a set is its owner), or move the set into the stack of the package it names, then build 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`. + + +- ee0e115: docs: the default-record-page remedies name the other package's page, not the whole package + + Clause-②: no + + A second `isDefault: true` record page for one object is refused with `DEFAULT_RECORD_PAGE_CONFLICT`. The package registry's refusal already told the author to keep one default or "compose the deployment without the other package's page". Three sibling texts still told them to drop the whole package, though a deployment can keep a package and leave out the one page it ships: + + - `@objectstack/metadata-protocol`: the metadata save door's refusal now reads "a page a package ships stays the default unless the deployment is composed without that package's page." Only the remedy clause changed; `code`, `status`, `object` and `holders` are unchanged. + - `@objectstack/spec`: `PageSchema.isDefault`'s TSDoc and the error-code ledger's comment on `DEFAULT_RECORD_PAGE_CONFLICT` say the same. + - `@objectstack/runtime`: `AppPlugin`'s private `bundle` field now documents the three readers outside the class that read it structurally, so a rename is not made blind. + + No behaviour changes; nothing to migrate. +- e67ac4b: `duplicatePackage` no longer renames, in free text, a name that is both a prefixed package object and a field key of one of the package's objects. Such a name (for example a field keyed `acme_deal` on the object `acme_deal`) may name the field, so it is now rewritten only at object-reference properties (`object`, `reference`, `objectName`, a permission set's `objects` keys, …), the way a bare object name already is. A copied list view's `columns` and a copied form's `sections[].fields` keep naming the field the copy has, instead of a field named after the renamed object: the list view is no longer refused at the copy with `list-view-field-unknown`, and the form no longer copies a field the copy does not have. Free text naming such an object (a description) is left as stored; a prefixed object no field key spells is still renamed in free text as before. +- 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. +- 8684079: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + fix(metadata-protocol,metadata): on a database the organization-ownership ceremony has not migrated, the paged family reads, `DatabaseLoader`'s count and the package rollback agree with the environment-only reads; the ceremony's apply is named with `--plan-file` (ADR-0131 D6/D7) + + Clause-②: no + + Every change below matters only where legacy organization rows still sit in the `sys_metadata` family, the database `os serve` refuses to start on until `os migrate organization-ownership` has verified. On a migrated or fresh database every answer is unchanged. + + - **`@objectstack/metadata-protocol`: a paged read leaves the legacy rows out before the page is cut.** `auditMetaItem` (`limit`), `listCommits` (`limit`), `revertStoredPackage`'s first-active-row probe and `SysMetadataRepository.list({ limit })` used to filter a legacy organization's rows out after the limit. A page they sat in came back short, and `revertStoredPackage` answered `RESOURCE_CONFLICT` (409) "has never been published" for a package the environment had published when its first active row was an organization's. When a page holds a legacy row, the read is now repeated with those rows excluded in the query. + - **`@objectstack/metadata-protocol`: `rollbackToPackageCommit` refuses a legacy organization's commit by name.** It answered `COMMIT_NOT_FOUND` (404) for a commit that is there. It now gives `revertCommit`'s refusal: `NOT_OVERRIDABLE` (403), the same sentence, naming the commit and its organization. + - **`@objectstack/metadata`: `DatabaseLoader` counts what it reads.** `exists()` answered true for an address only a legacy organization row held, while `load()` answered null, and `queryHistory`'s `total` counted legacy history rows no page serves. Neither counts them now. + - **`@objectstack/metadata-protocol`: internal cleanup.** The organization plumbing that only ever carried the environment (`null`) is removed from the list merge and the served-row resolution. No export changes. + - **`@objectstack/spec`: wording.** The four ADR-0087 entries `18.sys-metadata{,-audit,-commit,-history}-organization-column-retired` now name the ceremony's reviewed apply, `os migrate organization-ownership --out PLAN` and then `--apply --plan-file PLAN`, which `--plan-file`'s own entry requires. No id, category, version or field the migration engine reads changes. + - **`@objectstack/cli`: wording.** `os migrate apply --allow-destructive`'s refusal on an unmigrated database now reads "a drop the organization-ownership ceremony owns, and the ceremony has not verified on this database". Its `--json` payload is unchanged. +- 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. +- 66d2757: feat(spec)!: every installed-package row declares where it came from, a closed `source: 'code' | 'marketplace' | 'authored'`, and the `GET /packages` rows declare the `writable` verdict they already served (#23972) + + Clause-②: yes (narrowing — the served row gains two declared keys, and the record schema, the served-row union and the registry's installPackage signature each refuse what they accepted before) + + **BREAKING** — three published accept sets narrow. `InstalledPackageSchema` refuses a row without `source`, or with a value outside `code` / `marketplace` / `authored`. The served-row union `InstalledPackageAtEitherStageSchema` refuses a row without a boolean `writable`. `SchemaRegistry.installPackage` and `EngineSchemaRegistryView.installPackage` take `source` as their second argument and refuse a call without it. The migration is below. None of it is mechanically convertible, because only the door that registers a package knows where it came from. + + **What is new on the wire.** `GET /api/v1/packages` and `GET /api/v1/packages/:id` serve two declared keys on every row: + + - `source` names the package's origin. `code` means the deployment's own code registered it (a `defineStack()` host, a plugin's bundled manifest, a platform package). `marketplace` means the marketplace install-local plugin installed it, and that holds after a restart too, when the plugin rehydrates it from its ledger. `authored` means it is a database-authored package (a Studio base, a duplicate, an HTTP install), including after the restart that rehydrates it from `sys_packages`. The door that registers the package stamps the value. No reader derives it. + - `writable` is the server's own writability verdict (ADR-0070 D2), served on these rows since #14375 and now declared. It is computed per read and never stored, so it is declared on the served row (`InstalledPackageAtEitherStageSchema`) and not on the registry record. + + FROM → TO for a reader of these rows: + + | you did | do instead | + | --- | --- | + | treated `writable: true` as authored, and looked up each non-writable row in the marketplace's installed list (the control plane's, else install-local's) to tell marketplace from code | read `row.source` | + | read the undeclared `writable` | read the declared `writable` (unchanged value) | + + The two keys are separate statements: `source` says where the package came from, and `writable` says what this server lets an org do to it. + + **`InstalledPackageSchema` requires `source`.** It is `InstalledPackageSourceSchema`, exported from `@objectstack/spec/kernel`. A row without it, or with a value outside the closed set, is refused at the `source` key. + + **API change for code that registers packages (`@objectstack/objectql`).** The registry records the origin and never infers it: + + | you wrote | write instead | + | --- | --- | + | `registry.installPackage(manifest)` | `registry.installPackage(manifest, 'code')` (or `'marketplace'` / `'authored'`, whichever is where the package came from) | + | `registry.installPackage(manifest, settings, scope)` | `registry.installPackage(manifest, source, settings, scope)` | + | `ctx.getService('manifest').register(artifact)` for a marketplace install | `ctx.getService('manifest').register(artifact, { source: 'marketplace' })` | + + `SchemaRegistry.installPackage` throws a `TypeError` naming the closed set when `source` is missing or unknown, before anything is recorded. `ObjectQL.registerApp(body, scope?, source?)` and the `manifest` service record `'code'` when no source is given. They accept `'code'` or `'marketplace'` and refuse `'authored'`. The `IObjectQLEngine` registry contract (`@objectstack/spec/contracts`) declares the same `installPackage(manifest, source, settings?)`. + + **The one-line fix:** pass the package's origin as `installPackage`'s second argument. + + Readers: objectui's Setup → Packages Source column and its `?source=` filter (it switches from the two-read join to `source` in its own follow-up), and the client SDK's `packages.list()` / `packages.get()` row types. + + +- e46ce7b: Clause-②: no + + fix(metadata-protocol): the global search palette titles a hit by its `nameField` before the deprecated `titleFormat` + + - **What changed.** `searchAll` (`GET /api/v1/search`) now resolves a hit's `title` in this order: the primary-title pointer (`nameField`, or the deprecated alias `displayNameField`), then the `titleFormat` template, then the conventional name fields, then first/last name, then the record id. Before, `titleFormat` came first. ADR-0079 D3 and the `titleFormat` schema description both say an explicit `nameField` takes precedence. + - **What it means for a deployment.** An object that declares both keys is now titled by its `nameField` value. For example, an approval request shows `Discount Sign-off · opp_45` instead of `flow:deal_desk · opp_45`. The registry also designates a `nameField` for any object that has a title-eligible field (a text-like field, or a formula returning text). So an object that declares only `titleFormat` is now titled by that designated field too. The template still titles a row when the object has no pointer, or when the pointer's value is empty on that row. + - **Fix for an author who wants the composite title back:** add a `formula` field with `returnType: 'text'` that builds the composite, set `nameField` to it, and drop `titleFormat`. This is the migration ADR-0079 prescribes (`os explain title-format-retired`). + - **`@objectstack/spec`:** the description of `SearchAllHitSchema.title` now states the same order. No schema shape changed. +- ec749fb: fix(metadata-protocol): global search judges an object's searchable set without its `internal: true` fields, the set the engine scans + + Global search (`searchAll`, `GET /api/v1/search`) passes over an object with nothing to scan before it queries. It judged that from the object's fields including `internal: true` ones, which the engine never searches. An object whose only text field is internal therefore passed the skip and reached the engine, which now refuses a search over an empty set, so the whole sweep would have failed. The skip now leaves internal fields out, so such an object is passed over and the sweep answers `200` with the other objects' hits. + + What to do. Nothing. +- cf540a7: Clause-②: no + + fix(metadata-protocol): the save door's lock refusals and its default-record-page refusal say "authored in this system" + + - **What changed.** Three refusal texts said an item saved through the metadata API is "authored in this environment". They now say "authored in this system": the `protection`-block refusal, the lock-family-key refusal, and the holder text a default-record-page conflict prints for a page no package ships (`page 'NAME', authored in this system`). Inside the running system, user-facing copy names the system the user is in as "this system". + - **Unchanged.** Every error code (`VALIDATION_ERROR`, `DEFAULT_RECORD_PAGE_CONFLICT`), status and `issues[]` entry. +- 1827571: Clause-②: no + + perf(metadata-protocol): the by-name item read reads the item's stored active rows once, so a `GET /api/v1/data/:object` makes one `sys_metadata` read of the object instead of five + + - **What changed.** `getMetaItem` (the read the API-exposure gate makes on every `/data` request) used to send its three active-row selections to the store one at a time: which package the item belongs to, the served row once per spelling of the type (`object`, then `objects`), and the rows its lock is chosen from, once per spelling. Now it reads the item's active rows once, for both spellings (`type IN ('object', 'objects')`), and each selection picks its rows from that one read with the same conditions it used to send the store. The item read's `version` reuses the package binding the read already selected. + - **Measured.** On Postgres behind a 50 ms round-trip proxy, the median `GET /api/v1/data/todo_task` (example todo app, signed-in admin) went from 551 ms with 5 `sys_metadata` statements to 340 ms with 1. + - **Unchanged.** The served item, its lock and `version`, and every refusal stay the same. Nothing is cached across calls, so a metadata write is seen by the next request. An object with a stored row still has one more read behind `version` (the repository's write-address head). +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [913fd6d] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [cbe6d87] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [4840720] +- Updated dependencies [23dae89] +- Updated dependencies [1e8b628] +- Updated dependencies [9f12d06] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [73a0a6b] +- Updated dependencies [f2a45db] +- Updated dependencies [aa9447c] +- Updated dependencies [3d91885] +- Updated dependencies [8fc50b7] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [6c17a50] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [bb4f5cc] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [d8c7d38] +- Updated dependencies [2e11d90] +- Updated dependencies [7dbb89d] +- Updated dependencies [6a7eca6] +- Updated dependencies [63d5db2] +- Updated dependencies [a9bcbc6] +- Updated dependencies [9fbabd7] +- Updated dependencies [33ae4bf] +- Updated dependencies [ebbb8f9] +- Updated dependencies [07ee6f6] +- Updated dependencies [61bea24] +- Updated dependencies [762db99] +- Updated dependencies [d1c226d] +- Updated dependencies [996aa86] +- Updated dependencies [23419ba] +- Updated dependencies [54d32e5] +- Updated dependencies [c911ab6] +- Updated dependencies [e375315] +- Updated dependencies [cbbd96a] +- Updated dependencies [b6805ac] +- Updated dependencies [9c74d40] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [d1dbe70] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [b1f7a7a] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [46692c1] +- Updated dependencies [440bed6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [0fea05f] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [6981246] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [0f77ff5] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [9f15939] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [eed637c] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [32fa194] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [fba3712] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [54af6c0] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [30b0fc2] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [4bd7215] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [5e97907] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [077b75f] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [d537a31] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [85be049] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [69147ab] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [45bec43] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/formula@18.0.0 + - @objectstack/lint@18.0.0 + - @objectstack/metadata@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/types@18.0.0 + - @objectstack/sdui-parser@18.0.0 + ## 18.0.0-next.5 ### Minor Changes diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index 3e72bda827a..c91a1dc61c9 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "ObjectStack metadata management protocol: sys_metadata CRUD, draft/publish, locks, package ownership, diagnostics (ADR-0076).", "type": "module", diff --git a/packages/metadata/CHANGELOG.md b/packages/metadata/CHANGELOG.md index 743ead1117b..36356b5b9ac 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,616 @@ # @objectstack/metadata +## 18.0.0 + +### Minor Changes + +- 117d34d: feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: `sys_view_definition` retires as inert (ADR-0131 D13) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N` like every other stage. + + `sys_view_definition` was declared for runtime-authored shared and personal views (ADR-0017) and never used for them: no framework code writes or reads its rows, and the Studio console's view doors write the ADR-0005 `view` overlay in `sys_metadata` through the metadata API. ADR-0131 D13 retires it. A runtime-authored view is a `view` metadata item, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`); nothing about that path changes. + + **What moves for consumers.** + + - **The object.** FROM `import { SysViewDefinitionObject } from '@objectstack/metadata-core'` (or from `@objectstack/platform-objects`, or its `/metadata` subpath) TO nothing: delete the import. Neither `MetadataPlugin` nor the metadata protocol assembly registers the object any more, so the generic data door no longer serves it. + - **The migration exports** of `@objectstack/metadata-protocol`. FROM `ensureViewDefinitionActiveIndex`, `resolveIndexExec`, `buildActiveIndexSql`, `VIEW_DEFINITION_TABLE`, `VIEW_ACTIVE_INDEX_NAME`, `VIEW_ACTIVE_PROBE_INDEX_NAME`, `VIEW_ACTIVE_INDEX_COLUMNS`, `EnsureViewIndexLogger`, `EnsureViewIndexStatus` and `EnsureViewIndexResult` TO nothing: delete the import. `classifyIndexFailure` and the `IndexExec` type are still exported, unchanged, from the shared index-migration module. + - **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['metadata-core']` (`@objectstack/spec/system`) lists four objects, and `isPlatformProvidedObjectName` answers `false` for `sys_view_definition`. A stack that names it (a lookup target, a flow trigger, a permission entry, a platform-global declaration) is now flagged as naming an unknown object: remove the reference. + - **`os migrate duplicates`.** `runtimeIndexPreflight` carries three entries (the two `sys_metadata` overlay indexes and `sys_setting`'s row identity), with no `idx_sys_view_def_active` entry. A serving boot no longer runs the active-row index migration for the table, and no longer logs its `sys_view_definition active-row index` lines. + - **Translations.** The platform translation bundles no longer carry `sys_view_definition` keys. + + **Existing databases.** Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_view_definition`, with any rows a caller wrote through the generic data door; nothing reads them. `os migrate apply --allow-destructive` does not drop it either, because it reconciles declared objects only (measured on one database: it dropped an orphaned column of a declared table and left this table and its row in place). On a project with a host config, `os migrate plan` lists it among the platform-prefixed tables nothing declares. Export any row worth keeping; dropping the table is the operator's call, by hand: `DROP TABLE sys_view_definition`. +- 0fea05f: `os serve `: a multi-package config now serves the top-level metadata no package declares, under the stack's `manifest.id`, and the boot says so, as an artifact boot of the same project already did + + Clause-②: yes + + A config boot (`os serve objectstack.config.ts`, with or without `--dev`, and no compiled artifact) of a stack that carries `packages[]` registers each package body and never the stack's top level. A top-level item that no package body declares was served by no door, and the boot said nothing. Three shapes reached it: flat `src/docs/*.md` pages when the stack's `manifest.id` names no `packages[]` entry, inline `docs` spread onto a composed stack's top level, and any other top-level collection (objects, views, …) that no package carries. `os build` of the same project, booted as an artifact, registered those items under the stack's `manifest.id` and warned. + + - **What changes.** The config boot now runs the same residual rule as the artifact boot, `MetadataPlugin.registerUnclaimedTopLevel`. Every top-level item no package body declares is registered under the stack's `manifest.id`, and the boot prints one `warn` line with the count: `config stack '' carries N top-level metadata item(s) that none of its M package bodies declare`, followed by the remedy. `GET /api/v1/meta/` lists those items under that id, as on an artifact boot of the same project. This includes the flat `src/docs/` pages of a stack whose `manifest.id` names no package, which a config boot did not list before. This entry supersedes, in the same release, the note in the `os serve ` flat `src/docs/` entry that "A config boot of such a stack still does not list them": it now does, under the stack's `manifest.id`, with the warning. + - **A new refusal, in one corner.** A residual view container is judged like any other: a top-level `defineView` container that no package declares, and whose own `name` is not the object it binds to, now refuses the config boot with ``Invalid `views:` container from manifest '': the container's own `name` is …``. On the previous release that config booted, because its top level was never read; an artifact boot of the same project already refused it. `os validate` and `os build` refuse the same container first, in the same words, so the refusal is never a boot-time surprise. A divergent container that a package body declares and the top level repeats is refused once, as that package's. **The one-line fix:** drop the container's `name`, or set it to the object it binds to. + - **The remedy the line names.** Declare each such item inside the `packages[]` entry of the package that owns it. A flat `src/docs/` page moves under `src//docs/`, where `` is the package id or its last dot-separated segment. + - **What widens, in `@objectstack/metadata`.** Its root entry now exports `unclaimedTopLevel(stack)`, the pure residual decision both boot doors and `os validate` read, with its two types `UnclaimedTopLevel` and `UnclaimedTopLevelItem`, and `MetadataPlugin` gains the public static `registerUnclaimedTopLevel(sink, stack, source)`, the one residual rule both boot doors call. + - **What stays the same.** An artifact boot registers the same items and prints the same line, byte for byte. A single-package config (no `packages[]`) has no residual and is unchanged. A composed stack whose top level repeats its package bodies, as `composeStacks([…], { manifest: 'preserve' })` writes it, registers nothing extra and prints nothing. + - **What the residual is, on both doors.** It is metadata: a residual object is listed on `GET /api/v1/meta/object` under the stack's `manifest.id`, and `GET /api/v1/data/` still answers `404`. That was already true on an artifact boot, and the config boot now gives the same answer. +- c857a93: feat(spec)!: publish refuses an anonymous flow endpoint whose target flow runs as `system`, in both directions, with the prescription (ADR-0138 D2b) + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0138 D2b decides that no anonymous door triggers an elevated flow directly. An `authRequired: false` endpoint of `type: 'flow'` whose `target` flow declares `runAs: 'system'` ran every anonymous request's data steps as the system principal, past row-level security, with the endpoint's rate limit as the only brake. The runtime's own elevated-start refusal covers only self-triggered flow types (`autolaunched`, `record_change`, `schedule`), so a `system` flow of type `api` or `screen` behind such an endpoint executed elevated. Publish now refuses the pair, in both directions, because either item can be the last one edited: the endpoint's publish reads its target's `runAs`, and a flow's publish that sets `runAs: 'system'` reads the anonymous endpoints that target it. Each refusal names the endpoint and the flow and carries the prescription. + + **What is refused, and where.** Every publish door that holds the pair calls one judge, `anonymousElevatedFlowPairs` in `@objectstack/spec/api`: + + - **The stack parse** (`defineStack`, `objectstack validate`, `objectstack build`, the artifact load): a stack whose anonymous flow endpoint names one of its own flows declaring `runAs: 'system'`. One zod issue per pair, `code: 'custom'` at `apis[i].authRequired`; `defineStack` throws its schema refusal, `STACK_SCHEMA_INVALID` (422), carrying the issue. + - **The package publish** (`MetadataManager.publishPackage`, `POST /api/v1/packages/:id/publish`): an `api` item of the package naming a `system` flow anywhere in the manager's index, reported at the endpoint; and a `flow` item of the package declaring `runAs: 'system'` that an anonymous endpoint outside the package names, reported at the flow. The package publishes nothing (`success: false`, the pair in `validationErrors`), and `validate: false` does not turn it off. + - **A flow published through the metadata API** (`saveMetaItem` in publish mode, so `PUT /api/v1/meta/flow/:name`; a draft's publish, `POST /api/v1/meta/flow/:name/publish`; and `POST /api/v1/packages/:id/publish-drafts`): a flow going `active` with `runAs: 'system'` while a declared anonymous flow endpoint names it. `INVALID_METADATA` (422), `issues[0].path: 'runAs'`, nothing written; a publish-drafts batch aborts on it. A draft save is not judged (a draft does not execute; its publish is), and neither is a `migrate-stored` re-save of a row that already exists. + + **Unchanged.** Each item alone publishes as before: an authenticated endpoint on a `system` flow, an anonymous endpoint on a `user` flow (or one that leaves `runAs` at its default), and an anonymous `object_operation` endpoint are all accepted. A stored flow row that already pairs this way keeps its bytes and keeps executing as published; its next publish is refused. A guest-written row that fires a record-change flow is outside the refusal: the record leaves that indirect path undecided, because its trigger is the write, not the door. + + ## FROM → TO + + | door | FROM | TO | + |:--|:--|:--| + | `defineStack` / `objectstack validate` | `apis: [{ name: 'acme_intake_api', type: 'flow', target: 'acme_intake', authRequired: false, rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, … }]` beside `flows: [{ name: 'acme_intake', runAs: 'system', … }]` accepted; every anonymous request ran `acme_intake` as the system principal | refused, `STACK_SCHEMA_INVALID` 422, the issue at `apis[0].authRequired` naming both items and the prescription | + | package publish | a package carrying either half of the pair, with the other half anywhere in the manager's index, published | refused, `success: false`, the pair reported at the package's own item | + | metadata API flow publish | `PUT /api/v1/meta/flow/acme_intake` with `runAs: 'system'` while an anonymous endpoint names it saved and armed it | refused, `INVALID_METADATA` 422, the issue at `runAs`, nothing written | + + **The one-line fix: serve the caller another way — an authenticated endpoint (`authRequired: true`) for a partner that can hold a credential, the flow's signed inbound hook (ADR-0041: a start node with `triggerType: 'api'`, a `hookId` and a `secret`, which may keep `runAs: 'system'`) for a partner webhook, or a public form (a form view whose `sharing` sets `allowAnonymous: true`) for anonymous intake.** `runAs: 'automation'` joins that list when ADR-0073 M2 adds the value. + + No D2 conversion rewrites it: which channel replaces the anonymous endpoint depends on who the caller is, and whether the flow needs elevation is a judgment about its data steps, so no conversion can choose a posture for the author. Its D3 record is the semantic entry `api-endpoint-anonymous-elevated-flow-refused` (protocol 18). + + **`@objectstack/spec`.** The judge and its two refusal sentences join `@objectstack/spec/api`: `anonymousElevatedFlowPairs`, `anonymousFlowDoorTarget`, `anonymousEndpointElevatedFlowMessage` (the endpoint direction), `elevatedFlowAnonymousEndpointsMessage` (the flow direction) and `validateAnonymousElevatedFlows` (the stack's issues), with the types `AnonymousFlowDoorCandidate`, `ElevatedFlowCandidate` and `AnonymousElevatedFlowPair`. `ObjectStackDefinitionSchema` runs the judge beside the per-endpoint gates. The `runAs` description on the flow schema, which prescribed `system` for every user-less trigger, `api` included, now names the exception and what serves that caller. + + **`@objectstack/metadata`.** `MetadataManager.publishPackage` runs the judge over the package's `api` and `flow` items against the index the endpoint matcher serves from. + + **`@objectstack/metadata-protocol`.** The flow publish doors run the judge against the declared endpoints: the engine registry's `api` items and the `metadata` service's. + + **Who is affected, measured.** No in-repository stack pairs an anonymous flow endpoint with a `system` flow: `examples/` declares no `authRequired: false` endpoint, and every anonymous flow endpoint in the test fixtures names a `runAs: 'user'` flow. The partner-webhook example, `showcase_inbound_task_webhook`, takes the signed inbound hook with `runAs: 'system'` and is untouched. +- 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 + +- 23dae89: fix(metadata): `FilesystemLoader` remembers which file a metadata name resolves to, so a repeated read stops probing the filesystem (#24537) + + Clause-②: no + + `FilesystemLoader` used to make one `fs.access` call per resolvable extension (five under `MetadataPlugin`'s format set) on every `load()`, `exists()` and `stat()`, for a name no file holds as well as for one a file holds. Every authenticated request paid this cost: the security catalog asks the metadata service for each held permission set the engine registry does not serve, and in an artifact-mode runtime no such file exists. + + Each loader instance now remembers the answer for each `(type, name)`: the path, or no file. Only something that can change the answer retires it: + + - a `save()` through the loader; + - a walk of the type's directory by the loader (`list()`, `loadMany()`, `loadManyKeyed()`), so a name `list()` reports is not answered `null` by `get()`; + - a read with `useCache: false`, which re-resolves from disk. The file watcher's re-read on an `added` or `changed` event is such a read; + - a remembered path that the next read finds deleted. That read resolves the name again from disk and logs no stat failure. This covers an `unlink` event, and a deletion that no event reports. + + The memo holds at most 10,000 names and then starts over. + + One change is not seen at once. Suppose a name has already been answered "no file", and then another process creates that file under the loader's root while no watcher runs (`watch: false`). The loader does not see the file until the next walk of that type, the next `save()` through the loader, or a restart. Content edits to a file that already resolves are still read on the next request. Nothing in the public API changes. +- d0bb78e: fix(runtime,metadata): publishing or reverting a read-only package is refused with `422 WRITABLE_PACKAGE_REQUIRED`, and package membership reads the `_packageId` stamp + + Clause-②: no + + ADR-0070 D2 makes a code or installed package read-only. `POST /api/v1/packages/:id/publish` and `POST /api/v1/packages/:id/revert` did not check this. These two answers change: + + - **Publish of a code or installed package: 200 → 422.** Before, the publish door answered 200. For a package whose items carry an authored `packageId`, for example `com.example.showcase`'s two capabilities, it answered `success: true` and wrote `publishedDefinition`, `state` and `version` onto those read-only items. For every other code package it answered `success: false`, "No metadata items found". It now answers `422 WRITABLE_PACKAGE_REQUIRED` before anything is written, the same refusal that `PATCH /packages/:id/disable` and `DELETE /packages/:id` already give. + - **Revert of a code or installed package: 404 or 409 → 422.** Before, the revert door answered `404 RESOURCE_NOT_FOUND` "No metadata items found" (for example `com.objectstack.setup` and the platform packages that ship objects) or `409 RESOURCE_CONFLICT` "Package '…' has never been published" (for example `com.example.showcase`). It now answers `422 WRITABLE_PACKAGE_REQUIRED`. The check runs after the protocol's stored-row answer, so a revert of a code package that has a stored row bound to it, such as an organization overlay draft, keeps its answer. + + To customise what a code package provides, use an ADR-0005 organization overlay. Overlay drafts publish through `POST /api/v1/packages/:id/publish-drafts` and the per-item publish door, and this change leaves both alone. + + **Unchanged:** a writable package's publish and revert, and an id that nothing carries (revert 404; publish 200 with `success: false`). + + `MetadataManager.publishPackage` and `revertPackage` now find a package's members by `packageId`, `package` or the private `_packageId` stamp. The artifact loader writes that stamp through `applyProtection`, and the ObjectQL object bridge copies it onto every object it registers. Before, an item that carried only the stamp was not a member. `MetadataManager` has no notion of package kind; the refusal of read-only packages lives at the two doors above. +- 1959aac: feat(spec)!: `defineStack` refuses two entries of one named collection under one identity, naming the collection, the identity and every index — for 27 collections (#22934) + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + + The runtime registers the entries of these collections under one identity each, and a stack could author two entries under one identity with nothing refusing it: `defineStack` and `objectstack validate` accepted the pair (exit 0, `valid: true`, no finding naming it). Measured on a source boot (`objectstack serve`, no build) and on `objectstack build` followed by an artifact-backed boot: + + - `objects`, `pages`, `dashboards`, `reports`, `datasets`, `flows`, `jobs`, `emailTemplates`, `books`, `positions`, `permissions`, `capabilities`, `sharingRules`, `webhooks`, `mappings`, `analyticsCubes`, `connectors`, `picklists`, `tools`, `skills`, `views`, `apis`, `apps` and `objectExtensions` kept only the LAST entry on both paths, with no notice at the default log level (for `apis`, the first entry's path answered 404); + - `datasources`: the metadata described the last entry while the driver connected with the first one's configuration; + - `hooks`: the metadata listed one hook while both handlers were bound and ran; + - `docs`: `objectstack validate` and `objectstack build` refused the pair (`docs/duplicate-name`), while a source boot kept the last. + + **What is refused.** `defineStack`'s cross-reference pass refuses the stack with its existing envelope (`STACK_CROSS_REFERENCE_INVALID`, status 422, `defineStack cross-reference validation failed (N issues):`), one finding per collision, naming the collection, the identity and every index: + + ```text + ✗ Datasource 'crm_external' is declared twice in `datasources`: stack.datasources[0] and stack.datasources[1]. … + ``` + + The identity is what the runtime keys an entry by: its `name`; the `(name, locale)` pair for `emailTemplates` (one name across several locales is an i18n bundle, and an entry with no `locale` counts as `en-US`); the extended object (`extend`) for `objectExtensions`; and for `views` the object a container binds to (`object`, else `list.data.object`, else `form.data.object`, else its `name`). The check runs in an object-less stack too, and every entry counts, byte-identical twins included. `objectstack validate`, `objectstack build` and a source boot all load the config through `defineStack`, so all three refuse it at load. Like every cross-reference check it is skipped under `defineStack(…, { strict: false })`. On an `app` package, two apps under one name now get this refusal, which runs before the single-app count. + + **Unchanged.** Distinct identities; an email-template i18n bundle (one name, one entry per locale); the map form under distinct keys (two keys whose entries spell one explicit `name` are one identity, and are refused); the same name reused by another package of a multi-package project (`composeStacks(…, { manifest: 'preserve' })` keeps each package's collections in its own `packages[i]` body, which this check does not read); `plugins` (last-one-wins with a warning is the kernel's declared contract), `agents` (withdrawn for app packages) and `picklistExtensions` (merged by option value); and the existing `actions` and `functions` refusals. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `pages: [{ name: 'home', … }, { name: 'home', … }]` (any of the 24 name-keyed collections) | one entry per name: rename one (`home_admin`) and every reference that meant it, or merge the two into one entry | + | `emailTemplates: [{ name: 'acme.welcome', … }, { name: 'acme.welcome', locale: 'en-US', … }]` | one entry per name and locale: give the second its own `locale` (`zh-CN`), or merge the two | + | `datasources: [{ name: 'ext', config: a }, { name: 'ext', config: b }]` | one datasource per name — the driver used to connect `a` while the metadata described `b` | + | `hooks: [{ name: 'audit', object: 'a', … }, { name: 'audit', object: 'b', … }]` | one hook with `object: ['a', 'b']`, or two hooks with two names | + | `apis: [{ name: 'feed', path: '/api/v1/apps/x/a' }, { name: 'feed', path: '/api/v1/apps/x/b' }]` | give each endpoint its own name | + | `views: [defineView({ object: 'task', list }), defineView({ object: 'task', form })]` | one container per object: `defineView({ object: 'task', list, form })` (named views go in `listViews` / `formViews`) | + | `objectExtensions: [{ extend: 'task', fields: { a } }, { extend: 'task', fields: { b } }]` | one extension per target: `{ extend: 'task', fields: { a, b } }` | + + **The one-line fix: give each entry of a collection its own identity, or merge the duplicates into one entry.** + + No D2 conversion rewrites it: which entry keeps the identity, and what the other one becomes, is the author's call. Its D3 record is the semantic entry `stack-collection-duplicate-entry-refused` (protocol 18). + + **`@objectstack/metadata`.** `deriveViewContainerObject` — the "which object does a `views` container bind to" derivation the registrars file containers by — moved into `@objectstack/spec` (exported from its root and `./ui`), because the stack contract now judges the same slot. `@objectstack/metadata` re-exports that one declaration under the same name, from its root and from `@objectstack/metadata/view-container`; behaviour is unchanged. That subpath used to import nothing and now loads the `@objectstack/spec` root entry, which every in-repo importer of it already loads. + + **Who is affected, measured.** The four example apps (`examples/app-crm`, `app-todo`, `app-showcase`, `app-multi-package`) carry no duplicate under these identities. Deployed metadata and other repositories were not measured. +- 58f5c90: fix(metadata-core): an artifact declaring `engines.protocol: '^18'` that was built during the v18 prerelease boots on a later runtime that retired one of its shapes — the artifact door replays every retired conversion of protocol 18's step for a floor of `18.0.0` + + Clause-②: yes + + **What an operator saw.** A compiled artifact (`os build` → `dist/objectstack.json`) that declares `engines.protocol: '^18'` and was built before a protocol-18 retirement landed carries the retired shape, for example a defaulted key that every earlier parse emitted. Booted on a runtime that enforces the retirement, the metadata plugin's startup refused it at the strict parse and the boot rolled back. The artifact door compared the floor `18.0.0` with each retirement's `retiredAfter` stamp, and every retirement that lands during the v18 prerelease is stamped with the last 17.x release, below that floor, so nothing was replayed. + + **What an operator sees now.** The same artifact boots. One warning per conversion names it and its site count: `[MetadataPlugin] artifact 'LABEL' declares the first release of protocol 18 (authored engines.protocol floor 18.0.0), which a build from any 18.0.0 prerelease declares too, so it may predate retirements in the conversion step into protocol 18 that this runtime enforces (runtime spec VERSION) — converted N site(s) forward via ADR-0087 conversion 'ID' …`. The artifact file itself is unchanged, so it converts again on every boot until it is rebuilt with `os build`. An artifact built after the retirement boots exactly as before: the replay finds nothing to convert, and nothing is logged. + + - **`@objectstack/metadata-core`.** `applyArtifactForwardConversions` opens its window on a third condition. A declared floor of `M.0.0` at or above the runtime label replays every retired entry whose `toMajor` is `M`, except the default flips the door already refuses. An artifact cannot say which of those entries it predates: it carries no field naming the spec or CLI that built it. Each entry rewrites only its own old shape, so the replay changes nothing in an artifact that lacks that shape. `ArtifactForwardConversionVerdict` gains `'converted-major-step'`, so a consumer that switches exhaustively over the verdict adds that arm. `replayedRetirements` still lists only the entries the per-entry rule opened. The rule holds for every 18.0.0 prerelease label and at 18.0.0 itself. From 18.0.1 on, the label half replays the whole chain for such an artifact, as before. + - **`@objectstack/metadata`.** The artifact door places the new verdict on the open side of its window table and gives it its own clause, so the summary never claims that a `^18` artifact "predates" a runtime labelled below it. The bare-root form-predicate notice uses the same window. + + **Unchanged.** The protocol handshake still refuses a `^17` artifact on a protocol-18 runtime and prescribes `objectstack migrate meta --from 17`. A floor above `M.0.0` (such as `^18.0.1`) and a 17.x floor keep their previous verdicts. +- 8684079: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + fix(metadata-protocol,metadata): on a database the organization-ownership ceremony has not migrated, the paged family reads, `DatabaseLoader`'s count and the package rollback agree with the environment-only reads; the ceremony's apply is named with `--plan-file` (ADR-0131 D6/D7) + + Clause-②: no + + Every change below matters only where legacy organization rows still sit in the `sys_metadata` family, the database `os serve` refuses to start on until `os migrate organization-ownership` has verified. On a migrated or fresh database every answer is unchanged. + + - **`@objectstack/metadata-protocol`: a paged read leaves the legacy rows out before the page is cut.** `auditMetaItem` (`limit`), `listCommits` (`limit`), `revertStoredPackage`'s first-active-row probe and `SysMetadataRepository.list({ limit })` used to filter a legacy organization's rows out after the limit. A page they sat in came back short, and `revertStoredPackage` answered `RESOURCE_CONFLICT` (409) "has never been published" for a package the environment had published when its first active row was an organization's. When a page holds a legacy row, the read is now repeated with those rows excluded in the query. + - **`@objectstack/metadata-protocol`: `rollbackToPackageCommit` refuses a legacy organization's commit by name.** It answered `COMMIT_NOT_FOUND` (404) for a commit that is there. It now gives `revertCommit`'s refusal: `NOT_OVERRIDABLE` (403), the same sentence, naming the commit and its organization. + - **`@objectstack/metadata`: `DatabaseLoader` counts what it reads.** `exists()` answered true for an address only a legacy organization row held, while `load()` answered null, and `queryHistory`'s `total` counted legacy history rows no page serves. Neither counts them now. + - **`@objectstack/metadata-protocol`: internal cleanup.** The organization plumbing that only ever carried the environment (`null`) is removed from the list merge and the served-row resolution. No export changes. + - **`@objectstack/spec`: wording.** The four ADR-0087 entries `18.sys-metadata{,-audit,-commit,-history}-organization-column-retired` now name the ceremony's reviewed apply, `os migrate organization-ownership --out PLAN` and then `--apply --plan-file PLAN`, which `--plan-file`'s own entry requires. No id, category, version or field the migration engine reads changes. + - **`@objectstack/cli`: wording.** `os migrate apply --allow-destructive`'s refusal on an unmigrated database now reads "a drop the organization-ownership ceremony owns, and the ceremony has not verified on this database". Its `--json` payload is unchanged. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [a3d47c2] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [077b75f] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/types@18.0.0 + - @objectstack/metadata-fs@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index dc000a1f294..ad5290881fb 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "18.0.0-next.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Metadata loading, saving, and persistence for ObjectStack", "type": "module", diff --git a/packages/objectql/CHANGELOG.md b/packages/objectql/CHANGELOG.md index 6bdb323eeff..e725a246cf2 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,1957 @@ # @objectstack/objectql +## 18.0.0 + +### Major Changes + +- 41d0d40: feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING** on a deployment whose `org-scoping` service declares `platformGlobalObjects`. Nothing changes on any other deployment. + + ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." Until now the declaration stood the organization wall down for the object while the object kept its `organization_id` column, so the SQL driver went on scoping a read by the caller's organization that the wall no longer scoped. + + - **The plan** (`@objectstack/spec`): `resolveInjectedSystemColumns(def, deployment?)` takes the deployment's validated `platformGlobalObjects` as an optional second argument. An object it names is planned with no `organization_id`, and nothing else in its plan moves. With no second argument, or an empty list, the plan is the same as before. Author-time callers (the linter, the import mapper, the tenancy census) have no deployment and pass none. + - **The registry** (`@objectstack/objectql`): `ObjectQLPlugin` reads the declaration at the top of `start()`, before the first schema sync, and installs it with the new `SchemaRegistry.setDeploymentPlatformGlobalObjects()`. A declared object is registered with no `organization_id`, no tenant index and `systemFields: { tenant: false }`, and its table is created without the column. An object registered earlier, inside another plugin's `init()`, is re-planned at that moment, before its table exists. The `/meta` read exits serve the same shape. On the way back in, the write path removes the recorded `tenant: false`, so a Studio save stores the body the author wrote. The plugin logs the declared list once at boot. + - **The order** (ADR-0116): every `init()` completes before any `start()`, so a provider that registers `org-scoping` in its `init()` has registered by the time the engine reads it. `OrganizationsPlugin` now declares `providesServices: ['org-scoping']` (`@objectstack/organizations`). A provider that registers the service later, with a different declaration than the one the columns were planned from, fails the boot at `kernel:ready` with an error that names both lists and the fix. + - **The stand-down is retired** (`@objectstack/plugin-security`): `getObjectSecurityMeta` no longer reads `platformGlobalObjects`. A declared object reaches the security layer as its own `systemFields.tenant: false`, the same way every deployment-level object does: Layer 0 composes no organization predicate on it, a wildcard `organization_id` policy does not apply to it, and the ADR-0123 D2 no-active-organization write refusal does not fire on it. The `[security] deployment declares N platform-global object(s)` boot line is gone; the engine logs the list instead. + - **One reader** (`@objectstack/core`): `readDeploymentOrgScopingEntitlement` moved from plugin-security into `@objectstack/core` and is exported there, with its rules unchanged. An absent key declares nothing. A malformed key is refused whole: no object is declared, and the consumer of that key warns once. The engine warns for a malformed `platformGlobalObjects`, and plugin-security for a malformed `suppressUnboundedOrgAdminGrant`. `suppressUnboundedOrgAdminGrant` and its behaviour are unchanged. + + **What moves for consumers.** + + - **On the declaring deployment**, a declared object has no `organization_id`. FROM an authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on that object, TO the same reference with that field removed. A write that still names it is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. The object is governed by object permission, not by the organization wall: a reader the object permission admits reads every row, where the SQL driver used to narrow a read to the caller's organization and the rows with no organization. + - **An object that declares its own `organization_id`** keeps that column, because it is the author's and not the platform's, and the organization wall keeps scoping it. The engine warns once at boot and names the object. Remove the authored field, or drop the object from the declaration. + - **Author-time tools** have no deployment, so they still list `organization_id` among a declared object's columns. Only the declaring deployment differs, and it refuses the name at runtime. + - **Existing databases: nothing moves automatically** (ADR-0131 D14). Schema sync is additive, so on a declaring deployment the physical `organization_id` column stays and the boot drift report names it orphaned. The operator removes it once the rows need nothing from it. No boot step reads or writes it. +- fe98cc6: feat(objectql)!: positions, permission sets and capabilities hold one name per deployment — a package registering a name that an installed package, the environment catalog or a built-in already holds is refused, naming both holders + + Clause-②: no + + + + **BREAKING** — an accept-set narrowing at the package registration door, shipped as `major` on the v18 pre-release line (`.changeset/pre.json` is in `next` pre mode on `main`). A deployment whose packages share a position, permission set or capability name booted before this release and is refused at boot after it. + + **Why.** An assignment names a position or a permission set by its bare name, with no package to tell two definitions apart (ADR-0131 D4). Before this release two installed packages could ship one name, and which definition granted depended on registration order. Measured on a booted kernel with two packages sharing one name per type: the by-name catalog read resolved the permission set and the capability to the first-registered package, and the position to the last-registered one. An app declaring the platform's own `admin_full_access` registered beside it, and the by-name read answered the app's set. The maintainer ruled the security catalog out of ADR-0048 §3.4's cross-package coexistence: each of the three types holds one namespace per deployment. Every other metadata type keeps §3.4's coexistence unchanged. + + **What is refused, and where.** `SchemaRegistry.installPackage` refuses a package whose declared `positions`, `permissions` (permission sets) or `capabilities` — top level, or on a nested `plugins[]` entry — name something another holder already holds. It refuses ahead of every mutation, so a refused package leaves no record behind. The holders are: + + - another installed package; + - the environment catalog: an item authored in this environment, in the registry's bare slot with no package; + - a built-in: the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) and the curated platform capabilities (`manage_users`, `setup.access`, `studio.access` and the rest of `PLATFORM_CAPABILITIES`). + + The platform's own permission sets (`admin_full_access`, `member_default`, …) are declared by `@objectstack/plugin-security` on its manifest, so they are held by that package like any other package's. `registerItem` with a package id refuses the same second holder for a registration that reaches the registry directly. The platform's own declaration of a built-in name there (`@objectstack/plugin-security` declaring the six built-in positions) is the built-in holder's, so it is never refused. An environment item already stored under that name keeps answering first. Every package registration reaches this door first: `AppPlugin.init` at boot (each package of a multi-package artifact included), a hot install through `install-local`, and a post-start `manifest.register`. On an artifact boot the refusal fires in Phase 1, before the artifact door registers anything. `install-local`'s offline (inline-manifest) import answers `422` under that route's own `PLUGIN_REGISTER_FAILED` code, with this refusal's message in `error.message`, and records nothing. `POST /api/v1/packages` carries no catalog collection at all; its strict body refuses them with `400`. + + **What an author sees.** The boot, or the install, fails with an ADR-0112 envelope: `code: 'NAMESPACE_CONFLICT'` (the code the namespace gate already carries; `NAMESPACE_CONFLICT_CODE` is exported) and `status: 422`. The message names the incoming package and the existing holder of each conflicting name, all conflicts in one message. The thrown error carries `conflicts[]` with `{ catalogType, name, incomingPackageId, existingHolder }`, where `existingHolder` is `{ kind: 'package', packageId }`, `{ kind: 'environment' }` or `{ kind: 'built-in' }`. **The one-line fix: rename the item in one of the two packages, or uninstall one of them.** A built-in name is never available to a package. An assignment that named the old name must name the new one; nothing rewrites stored assignments. + + **What is NOT refused.** The same package registering its own name again (an idempotent reload, a re-install, a hot reload). An item of any other metadata type shared by two packages. An environment save over a package-held name: a registration with no package (every `sys_metadata` hydration and metadata write-through) is never judged here, and a packaged permission set is already locked against an in-place edit (`403`). `OS_METADATA_COLLISION=warn` downgrades the namespace gate only. It does not downgrade this refusal. At cold boot, packages register before the environment catalog loads from `sys_metadata`, so this door cannot see an environment-held name then; the engine checks every package-held position and permission-set name against the environment catalog right after it loads, and refuses the boot with this envelope (the cold-boot entry in this release). + + **Measured producers.** On objectstack `1604e094f5`, the four examples (`app-crm`, `app-showcase`, `app-todo`, `app-multi-package`) and the platform built-ins carry 50 catalog declarations, and no name has more than one holder. Deployed and marketplace packages NOT MEASURED. +- e030d43: feat(objectql)!: a cold boot refuses a package-held position or permission-set name the environment catalog already holds, as a hot install does + + Clause-②: no + + + + **BREAKING** — an accept-set narrowing at boot, shipped as `major` on the v18 pre-release line (`.changeset/pre.json` is in `next` pre mode on `main`). A deployment whose environment catalog holds a position or permission-set name that a configured package also declares booted before this release and is refused at boot after it. + + **Why.** Positions, permission sets and capabilities hold one name per deployment, and a package registering a name the environment catalog already holds was already refused on a hot install. A cold boot did not refuse it: every package registers in the kernel's first phase, before the environment catalog loads from `sys_metadata` in the engine plugin's `start()`, so the package door could not see the environment's name. The stored row loaded over the package's definition, the registry printed a `[Registry] Collision` warning, and the by-name read served the environment's definition in place of the package's. The maintainer ruled that the cold boot refuses too, so that a cold boot, a hot install and an artifact boot answer alike (ADR-0048 addendum N.3). + + **What is refused, and where.** Right after `sys_metadata` hydration in `ObjectQLPlugin.start()`, before any plugin that depends on the engine starts, the engine checks every package-held position and permission-set name against the environment catalog's items. One such name fails the boot. Every conflict is listed in one refusal. The check reads the two types an environment can author: the runtime metadata API refuses to create a capability (`403`, a code-only type), so the environment catalog holds none. The hydration write itself is still not judged. + + **What an operator sees.** The kernel reports `Plugin com.objectstack.engine.objectql failed to start`, and the cause is the package door's envelope: `code: 'NAMESPACE_CONFLICT'` (`NAMESPACE_CONFLICT_CODE` is exported), `status: 422`, and `conflicts[]` with `{ catalogType, name, incomingPackageId, existingHolder: { kind: 'environment' } }`. The message names the package that declares each name and the environment catalog that holds it. + + **The upgrade shape.** A deployment fails to boot after this release when an active, environment-wide `sys_metadata` row of type `permission` or `position` (or the legacy plural `permissions` / `positions`, which the boot's load folds to the same types) has the name of a permission set or position that a configured package declares. That includes a row saved over a package-held name before the packaged locks refused such saves, whether or not the row was bound to the package, and a row over one of the platform security plugin's own permission sets (`member_default`, `admin_full_access` and the rest it declares). **Run `os migrate security-catalog-overlays` before the first v18 boot**, with the flags and environment the deployment boots with: it lists exactly these rows, and with `--apply` deletes them. + + **The one-line fix: before the first v18 boot, run `os migrate security-catalog-overlays` to list the rows, then `os migrate security-catalog-overlays --apply` to delete them; or rename the item in the package.** + + - **Before upgrading, for a permission set.** On the release you run now, a boot whose environment catalog overlays a package-declared permission set logs at `kernel:ready`: `[security] N package-declared permission set(s) are being shadowed by an environment overlay`, with the set names. Those are the permission sets this release refuses at boot. The audited **Discard Overlay** action on the set's record in Setup (`POST /api/v1/security/permission-sets//discard-overlay`, documented under "Declared ≠ enforced" on the Permission Sets page) removes the overlay and resyncs the set to the package's definition. So does `DELETE /api/v1/meta/permission/`, which answers "Customization overlay deleted … reset to artifact default". Either works for the platform security plugin's own sets too, and neither needs direct database access. Positions have no such reading and no such action. + - **After upgrading, for any refused name.** The same step: `os migrate security-catalog-overlays`, then `--apply`. It needs no server, so a deployment whose boot is refused can run it, and it covers permission sets and positions, a name the platform security plugin declares, and a row stored under a legacy plural. + - **What the step deletes.** The rows that refuse the boot are the active, environment-wide ones of a package-held name: `organization_id IS NULL` and `state = 'active'`, whatever their `package_id`, under the type or its legacy plural. In SQL, for one permission-set name, the step's deletion is `DELETE FROM sys_metadata WHERE organization_id IS NULL AND state = 'active' AND type IN ('permission', 'permissions') AND name = '';` (for a position, `type IN ('position', 'positions')`). The step runs it through the metadata write path, so each deleted row leaves a `sys_metadata_history` tombstone. A draft row and an organization-scoped row are not loaded at boot and do not refuse it. + + `DELETE /api/v1/meta/permission/` and `DELETE /api/v1/meta/position/` reach a row stored under `permission` or `position` only, one row per call. A row stored under the legacy plural `permissions` / `positions` is not reached: the call answers `200` that nothing was found and removes nothing. Where a name has two active rows, for example one bound to no package and one bound to the package, each call removes one. A plural-typed row is removed by Discard Overlay before upgrading (a permission set), or by `os migrate security-catalog-overlays --apply`, for any name. + + `os migrate security-catalog-overlays` is the one `os` command that deletes these rows with no server running; `os meta delete` and `os data delete` call a running server. Nothing renames or removes either item automatically: the step deletes only under `--apply`, and adopts nothing. + + **What is NOT refused.** A stored definition under a built-in position name (`org_admin`, `everyone` and the other four): the platform declares its built-in positions itself, after this check, and the stored definition keeps answering first. The same package restarting with its own names. A package whose names the environment catalog does not hold, booting beside the environment's own items. + +### Minor Changes + +- f8ef144: feat(objectql)!: under the `single` posture, a system insert into a platform object that names no organization takes the Default Organization, or is refused; it no longer lands with `organization_id` NULL + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, D11). + + - **Before.** A system-context insert (`isSystem`, no `tenantId`) into a `sys_` / `cloud_` / `ai_` object that carries an organization column, but that the tenancy inventory had not admitted, skipped the organization rule entirely, under every posture. The row landed with `organization_id` NULL. Thirty-five platform objects in this repository were in that population, among them `sys_business_unit`, `sys_business_unit_member`, `sys_member`, `sys_team`, `sys_user_permission_set`, `sys_user_position`, `sys_sharing_rule`, `sys_share_link`, `sys_comment`, `sys_attachment`, `sys_activity`, `sys_email`, `sys_inbox_message`, `sys_secret`, `sys_setting`, `sys_setting_audit`, `sys_webhook` and `sys_flow_credential`. + - **Now, under `single`.** That insert goes through the same decision every application object already gets. With exactly one organization (the Default Organization), the row is stamped with it. With none, or with several, the insert is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), and the message names the missing organization. Nothing is written. + - **Unchanged.** Under a walled posture (`group` / `isolated`) the per-object exclusion stands as before; the walled half of this change ships in a later 18.x release. A write that carries an organization, on the execution context or on the record, is never re-stamped or refused. Objects with no organization column, objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. Updates are untouched. The engine's diagnostics-only tenant-audit mute is unchanged. + + **The remedy.** If a refusal names your insert, carry the organization on the write: `ql.insert(object, { ...row, organization_id: orgId }, { context: { isSystem: true } })`, or `{ context: { isSystem: true, tenantId: orgId } }`. On a fresh `single` deployment the auth plugin creates the Default Organization at boot, so a refusal reading `no-organization` means the write ran before it, or in a composition that registers `sys_organization` but mounts no auth plugin to create the Default Organization. +- 34dba5a: A system-context insert under the `single` tenancy posture is refused when the install holds no organization, instead of landing with no owner + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9). + + - **Before.** Under the `single` posture, in a composition that registers the organization object, a system-context insert on a tenant-scoped object with no organization anywhere landed with `organization_id` NULL when the install held no organization yet. That was the normal state of a first boot: the organization arrived with the first sign-up. + - **Now.** The Default Organization is a boot invariant under `single` (ADR-0131 D3): `@objectstack/plugin-auth` creates it before the application seeds load and before the server accepts a request. An install that registers the organization object and holds none of it therefore has no owner to derive, and the insert is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500, `reason: 'no-organization'`). The message names the missing organization. Nothing is written. + - **Unchanged.** Exactly one organization is derived and stamped. Several organizations, or a walled posture, are refused as before. A write that carries an organization, on the execution context or on the record, is never refused. Objects with no organization column, objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. The platform-namespace objects the tenancy inventory has not admitted keep their per-object exclusion; ADR-0131 C8 retires it. A composition that registers no organization object at all (a lean embedding) still lands the write unstamped. + + **The remedy.** On a `single` deployment, the refusal means the Default Organization is missing: restart, and the auth plugin recreates it at boot, or carry the organization on the write (`{ context: { isSystem: true, tenantId } }`, or `organization_id` on the record). A TypeScript caller of `resolveSystemWriteOrganization` passes whether its composition registers the organization object. +- c8a9783: feat(plugin-security)!: no seeder or materializer writes a `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and the `permission` publish materializer are deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `SecurityPlugin` no longer writes a `sys_permission_set` row for any permission set: not for the platform defaults (`admin_full_access`, `member_default`, …), not for a set a package declares, not for an organization's copy, and not on a metadata reload or an organization's creation. Permission sets are served by the security catalog (ADR-0131 D3), and since the grant names its set (`sys_user_permission_set.permission_set`), no grant path needs a row. A fresh database still promotes its first platform administrator, grants organization administrators and self-registered members, and grants every principal what it was granted before. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. Until a later stage retires the ADR-0094 projector, publishing a permission set (a package-door publish, or a writable base's direct publish) still has it write a projection row, `managed_by: 'admin'` with no `package_id` (a package-door publish wrote `managed_by: 'package'` before this release); no authorization path reads it, and uninstalling the package removes it. + + - **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. + - **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. + - **Uninstalling a package still revokes every grant of its permission sets** (ADR-0090 D5, "no ghost grants"). With no row standing for a package's set, both uninstall doors (`deletePackage` and `DELETE /api/v1/marketplace/install-local/:id`) capture the set names the package held just before they withdraw it from the registry, and the `security.package-permissions` cleanup revokes every `sys_user_permission_set` grant naming them. The withdrawal still runs before the cleanups. A grant naming such a set would otherwise survive the uninstall and be live again on reinstall. Grants written before the name column, and position bindings, are still reached through a legacy `sys_permission_set` row where an already-seeded database holds one. + - `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once. + + Added (additive, nothing an author writes changes): + + - `@objectstack/metadata-protocol`: the optional `declared?: UninstallDeclared` argument of `UninstallCleanup` (and of `runUninstallCleanups`' request), the `UninstallDeclared` type, and `captureUninstallDeclared(registry, packageId, stored?)`, the one capture both doors call. A cleanup that ignores `declared` behaves as before. + - `@objectstack/objectql`: `SchemaRegistry.securityCatalogNamesHeldBy(packageId, type)`. + - `@objectstack/plugin-security`: `cleanupPackagePermissions` takes an optional fourth argument, the captured names. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `bootstrapDeclaredPermissions` + - `normalizeManagedByVocab` + - `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` + - `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` +- 66949b7: feat(plugin-security)!: the four security-catalog row objects are retired — `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` (ADR-0131 D3) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Positions, permission sets and capabilities live in the security catalog — the environment registry, fed by package declarations and by environment metadata saved through `/api/v1/meta` (ADR-0131 D3). The four row objects that mirrored them are no longer registered, so their data and REST routes and their Setup object pages are gone. No table is dropped and no row is deleted. + + FROM → TO: + + - `GET|POST|PATCH|DELETE /api/v1/data/sys_permission_set` → `GET|PUT|DELETE /api/v1/meta/permission/:name`; to copy a set, `POST /api/v1/security/permission-sets/:name/clone` with `{ name, label }`. + - `… /api/v1/data/sys_position` → `/api/v1/meta/position/:name`. A position's permission sets are its definition's `permissionSets`. + - `… /api/v1/data/sys_position_permission_set` → the position definition's `permissionSets` (`PUT /api/v1/meta/position/:name`). + - `… /api/v1/data/sys_capability` → read-only `GET /api/v1/meta/capability`; a package declares a capability with `defineCapability` (`stack.capabilities`). + - Setup → Access Control → Positions / Capabilities / Permission Sets now open Setup's catalog lists (`/apps/setup/metadata/?scope=environment`) instead of the object pages; the `sys_position` record page (`SysPositionDetailPage`) is removed. + - An object permission on one of the four names in a permission set grants nothing and can be deleted. + + What else changes: + + - `sys_user_permission_set.permission_set_id` is a plain text column now, not a lookup: same column, same stored ids, same indexes. It is not written and grants nothing; a grant names its set by `permission_set`. + - A permission set saved through the metadata door is written to no row: the `permission` mutation projector, the `sys_permission_set` and `sys_position` data-door write-throughs and the row-only position backfill are no longer registered. + - On a fresh `single`-posture database the first human is promoted to platform administrator without reading any catalog row. An unscoped grant written before `sys_user_permission_set.permission_set` existed (it names no set) still withholds the promotion until the organization-ownership migration names it. + - Uninstalling a package still revokes every grant naming a permission set the package declared, by the names the uninstalling door captures before the withdrawal (`declared`, `captureUninstallDeclared`), and now by those names alone: the cleanup reads and removes no `sys_permission_set` or `sys_position_permission_set` row, so the `sets` and `positionBindings` counts on its outcome are always 0. A grant written before `sys_user_permission_set.permission_set` existed names no set and is not reached; the organization-ownership migration names it. + - On a `group` or `isolated` deployment the data-door half of the walled catalog refusal goes with the objects: a data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability` answers `404 OBJECT_NOT_FOUND` for every caller instead of `403 PERMISSION_DENIED` below the platform operator. The refusal at the `/meta` write doors, the permission-set clone door and the `/packages` write doors is unchanged. + - `capability-reference-unknown` no longer counts a `sys_capability` seed row as a declared capability — no runtime reader consults one. Declare the capability with `defineCapability`. + - On an existing database `os migrate plan`'s unmanaged-table sweep now lists the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` tables: they keep their rows and no object declares them any more. The sweep only reports; nothing is dropped, and the tables stay for the organization-ownership migration. + + Removed exports, no replacement: + + - `@objectstack/plugin-security`: the object definitions `SysPosition`, `SysPermissionSet`, `SysPositionPermissionSet`, `SysCapability` (from `./objects`); `securityObjects` lists the remaining three. + - `@objectstack/platform-objects/pages`: `SysPositionDetailPage`. + - `@objectstack/spec`: `SystemObjectName.POSITION`, `SystemObjectName.PERMISSION_SET`; the four names leave `PLATFORM_OBJECTS_BY_PACKAGE['plugin-security']` and `PLATFORM_PROVIDED_OBJECT_NAMES`. + - `@objectstack/core`: `isRowActive` and `ActivatableRow` — a catalog row's `active` column decides nothing; deactivation is the activation ledger's (`sys_metadata_activation`). + - `@objectstack/objectql`: the published `PLATFORM_OBJECT_TENANCY` record loses its `sys_permission_set` key (the other three names were never in it). A removed key of an exported constant is a narrowing, so `@objectstack/objectql` takes `minor` under the launch-window convention for breaking changes. +- 4806b18: Under a walled posture a package that carries security catalog items is the platform operator's: tenants are refused installing, upgrading, uninstalling or deleting it + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant (managed packages, Studio)."). + + **Why.** A package registers its positions, permission sets and capabilities environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack, with an organization owner granted `manage_metadata` inside their own organization (never the platform operator): + - the owner re-installed an operator-installed package at a new version through `install-local`, adding `manage_users` to a permission set another organization's member holds, and that member's next resolution carried `manage_users`; + - the owner's `install-local` uninstall of that package took the set away from that member; + - the owner's `DELETE /api/v1/packages/:id` of a runtime package the operator had saved a permission set into did the same. + + The existing name-conflict refusal stops only a package that shadows a name another holder has, never a write by the package that holds it. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `POST /api/v1/marketplace/install-local` (`@objectstack/cloud-connection`) of a package whose incoming version declares a position, permission set or capability, or, on an upgrade, whose installed version does: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the package is registered or the ledger written. The whole package is refused, since a package installs whole. The message names the posture, and `error.details.catalogItems` lists the items. + - `DELETE /api/v1/marketplace/install-local/:id` of a package whose installed version declares one: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the ledger is touched. + - `DELETE /api/v1/packages/:id` on the runtime dispatcher, of a writable package holding stored positions or permission sets: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is deleted. + + **Unchanged.** A package with no catalog item installs, upgrades and uninstalls as before, for the same callers. The platform operator does all of the above as before. Under `single` (and with no `tenancy` service) nothing changes. Disabling a package is unchanged: it was measured not to change what any organization resolves. + + **The remedy.** The platform operator installs, upgrades and removes catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-writes-retired`. Both doors ask the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), once per catalog type the package declares. `@objectstack/objectql` now exports `declaredSecurityCatalogNames`, the engine's own reading of what a manifest declares, so a package door judges the declarations the engine registers. +- c8b062f: feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. + + **What changes.** + + - The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. + - `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. + - `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. + - `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. + - [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling instead: the reads do not decline it, a cold boot refuses a deployment that holds one (ADR-0048), and the offline `os migrate security-catalog-overlays` removes it. + - `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. + - Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. + - The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. + - The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | + | `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | + | `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | + | `SysMetadataRepository.listDrafts` row `organizationId` (`@objectstack/metadata-protocol`) | gone: every listed row is an environment draft, whichever repository asks | + | `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | + | `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | + | `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | + | `organizationId` on `projectPermissionMutation`'s `evt` (`@objectstack/plugin-security`) | drop it: the projection reads the environment → code layered read, whatever organization the event names | + | a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | + | an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | + + **What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. +- eebaf3b: feat(objectql,core): the driver's tenant read scope is the organization wall's verdict — one predicate for Layer 0 and every driver (ADR-0131 D8) + + Clause-②: yes (widening) + + - **What changed.** The engine no longer derives `DriverOptions.tenantId` / `tenantIds` beside the organization wall. `buildDriverOptions` projects the wall's own verdict onto them: the verdict plugin-security records on the operation (`OperationContext.tenantLayer0Verdict`) where its wall ran, and otherwise the same function over the engine's own facts (the posture in force, the context's organization and membership set). The function, `computeTenantLayer0Verdict` with its input type `TenantLayer0Input`, moves to `@objectstack/core` (new export); `@objectstack/plugin-security`'s `tenant-layer.ts` re-exports it and its Layer 0 is byte-identical. + - **FROM → TO, per posture, for a read, count, aggregate, update or delete on an object with an organization column:** + + | posture / caller | driver scope before | driver scope now | + |:--|:--|:--| + | `single`, any caller naming an organization | that organization (plus organization-less rows) | **none** | + | `isolated`, member | the active organization | unchanged | + | `group`, member | the membership set | unchanged | + | walled, exempt `PLATFORM_ADMIN` on a posture-permitting object, or the verified platform owner's read | the active organization | **none** — what Layer 0 already admitted | + | walled, system context naming an organization | that organization | unchanged | + + - **Not changed.** Inserts and upserts keep the active organization as `tenantId` in every posture: the row is stamped with it, its counter scopes by it, and an upsert that would merge into another organization's row is still refused. Both `orWhereNull` compatibility arms in `SqlDriver.applyTenantScope` stay (their retirement is a later stage of ADR-0131 D13). + - **Who notices.** A `single` deployment that holds more than one organization was kept apart by the driver only by accident; postures now differ only in enforcement (ADR-0131 D11), and `single` enforces no wall. To keep organizations apart, run the `isolated` or `group` posture. Code that relied on a context organization to narrow a read on `single` names the organization in its own `where`. + - **Migration.** ADR-0087 semantic entry `driver-read-scope-follows-tenant-wall`. A deployment's `platformGlobalObjects` carve-out already reaches the driver through the column plan (ADR-0131 D7): a declared object carries no organization column, so neither wall has anything to scope. +- 4578c56: fix(import): `createMissingOptions` now keeps an unmatched option value through the engine's option check + + Clause-②: yes (widening) + + - **What was wrong.** With `createMissingOptions: true` on an import (objectui's Import Wizard sends it for *Keep unknown option values*), the import coercion kept a `select` / `radio` / `multiselect` cell that matched none of the field's options. The engine's write-path option check then refused the same value ("Priority must be one of: high, low"), so on every writable field the row still failed. Only the stage that refused it changed. + - **What happens now.** The import stores the kept value: a single-value cell trimmed, and each unmatched item of a multi-value cell. The dry run admits the same rows the write does. The engine admits exactly the values the import kept, on that import's writes. Every other value outside the options is still refused, and a field bound to a shared picklist that did not resolve is still refused. + - **The field's options do not change.** No option is added to the field. A later write that sends the field is judged against the options again, so sending the kept value back is refused and picking an option is accepted. A write that leaves the field out, such as an edit form saving only the fields it changed, is not judged on that field. + - **`@objectstack/spec`.** `ExecutionContext` declares `keptOptionValues`: per field name, the option values an import kept. It is server-constructed only, like `skipStateMachine` and `preserveAudit`, and is never client-supplied. The `ImportRequest.createMissingOptions` description now states exactly the behaviour above. + - **`@objectstack/core`.** `coerceRow` returns `keptOptionValues` beside `data` and `errors`, and `coerceFieldValue` reports the values it kept. `runImport` puts exactly those values on each write's context. A per-row write carries its own row's values, and a batched create carries the union of its rows' values. A row that kept nothing is written with the import's context unchanged. + - **`@objectstack/objectql`.** `ValidateRecordOptions.keptOptionValues` lets the single-value and multi-value option arms admit a listed value. The engine fills it from the write's `ExecutionContext.keptOptionValues` on insert, update and `validate`. +- f05649f: A `beforeInsert` hook is no longer shown a caller-supplied value for a `readonly` field, so a hook that stamps an absent read-only column stamps it even when the caller sent one. + + Clause-②: no (narrowing) + + + + **BREAKING** — what a `beforeInsert` handler reads on `ctx.input.data` changes on a non-system create. This ships as `minor` under the launch-window convention for breaking changes. + + - **What it reads now.** A caller-supplied value for a field the create-side static `readonly` strip takes is not on `ctx.input.data`. The field holds its `defaultValue`, or there is no key, which is exactly what the hook sees when the caller did not send the field. + - **Which fields.** The withheld set is the create strip's own subject (`staticReadonlyInsertSubject`): author-declared `readonly: true` fields that are not runtime-owned, on an object outside the `sys_` namespace and the platform-internal `managedBy` buckets. It has two edges: + - a runtime-owned `autonumber` value stays visible to the hook; + - under `preserveAudit`, the audit timeline (`created_at`, `created_by`, `updated_at`, `updated_by`) stays visible, because the historical-import channel reinstates it through a `beforeInsert` hook. + - **Two answers at the REST door change.** + - A hook that self-assigns a withheld field with no `defaultValue` (`data.x = data.x`) used to turn the caller's forged value into a hook write, which was stored. Under `strictReadonlyWrites` that create now answers 400 where it answered 201. + - A hook or sandboxed `body` that reaches through a withheld field (`ctx.input.meta.who = …`) now faults, and the create answers 400 naming the field where it answered 201. + - A `system`-context create (`isSystem`) is unchanged: the strip does not apply to it, so nothing is withheld. + + ## The defect + + On a non-system create, the engine took a caller's value for a static `readonly` field out of the row only after `beforeInsert` had run. A hook that stamps such a field only when it is absent (`if (!data.stage_entry_date) data.stage_entry_date = today`) saw the caller's value and did nothing, and the strip then dropped that value. The row stored NULL where the hook would have stamped today. Measured on a real engine over the SQL driver: with the key in the payload, the row stored `stage_entry_date` and `days_in_stage` NULL; without it, the hook stamped both. This is the insert-side twin of the `beforeUpdate` rule: a hook is never handed a value that will not be stored. + + ## What did not move + + - **The strip still runs after the hooks.** The caller's values are handed back to it there, wherever no hook wrote the key. So `onFieldsDropped`, the read-only WARN and `strictReadonlyWrites` judge the same payload as before. + - **A hook that assigns a read-only field still has its value stored.** + - **A self-assignment (`data.x = data.x`) of a withheld field** follows the engine's rule: + - with no `defaultValue` the hook reads `undefined`, the write is undone, and the caller's value is stripped and reported. `undefined` is never stored. + - with a `defaultValue` the hook reads and re-assigns the default, which counts as the hook's write. The default is stored, and nothing is reported. + - **Faults are explained.** A hook that faults reaching through a withheld field is answered 400 naming the field, the way `beforeUpdate` already answers. + + ## Who is affected + + A `beforeInsert` handler that reads a `readonly` field from `ctx.input.data` on a non-system create. + + - **A handler that derives a value from the field:** the value it used to read was never stored, so it should derive nothing from it. + - **A handler that owns the field:** it assigns the field. + - **A handler that reports what the caller sent:** a create has no `ctx.submitted` to read it from. +- db9cf80: feat(cli): `os migrate security-catalog-overlays` lists, and with `--apply` deletes, the environment-wide rows a v18 cold boot refuses — run it before the first v18 boot + + Clause-②: yes (widening) + + - **What it is for.** A v18 cold boot refuses to start when the environment catalog holds a permission-set or position name that a configured package also declares (ADR-0048 N.3). A deployment upgraded from 17.x can carry such rows: a set or position saved in the environment over a package's name before the packaged locks refused that, or over one of the platform security plugin's own sets (`member_default`, `admin_full_access` and the rest). The server cannot clear them, because it does not start. This step is the offline remedy. + - **What it lists.** Every active, environment-wide `sys_metadata` row (`organization_id IS NULL`, `state = 'active'`) of type `permission` or `position`, the legacy plurals `permissions` and `positions` included, whose name a configured package holds. That is the population the cold boot refuses: the list is computed from the engine's own reading of who holds a name, not from a copy of it. A draft row and an organization-scoped row are not loaded at boot, so they are never listed. Each row is shown with its stored type, its `package_id` binding and the package that holds its name. + - **How it gets there without the refusal.** It composes the deployment as `os serve` does: the host config's plugins, the application or the compiled artifact, and the security plugin behind `serve`'s auth gate. It runs the kernel's first phase for them only, and boots without reading `sys_metadata` back into the registry, so the cold-boot check meets nothing and the boot comes up. No server starts. The security plugin's shipped sets are package-held names only where `serve` composes that plugin, so the step reads the environment `serve` reads. With `OS_AUTH_SECRET` (or `AUTH_SECRET` / `BETTER_AUTH_SECRET`) set, or on a development boot, the plugin is composed and its sets are held, unless the `auth` tier is off. The step takes `os serve`'s `--preset` and `--dev` with `serve`'s meaning, read through the rules `serve` reads them by: `--preset` names the tier preset the auth tier falls back to, and `--dev` composes the config's `devPlugins` and makes the boot a development one (as does `NODE_ENV=development`). The report says which way it went. **Run the step with the flags and environment the deployment boots with.** + - **Preview by default.** The preview boots read-only, like `os migrate plan`. It writes nothing, creates no database file, and exits `1` when it lists a row: the next boot would be refused. `--apply` deletes the listed rows, after a `[y/N]` prompt or `--yes`, and prints one audit line per row: what was deleted, through which write path, or why not. It exits `0` when every listed row is gone. `--json` prints one document (`listed`, `deleted`, `failed`, `rows[]` with `outcome`, `securityPlugin` and `serveFlags`). `--force` gets past a busy SQLite file. `--database-url` / `$OS_DATABASE_URL` name the target. + - **How it deletes.** A row stored under `permission` or `position` goes through the metadata protocol's own delete, the one `DELETE /api/v1/meta/:type/:name` uses. That writes a `sys_metadata_history` tombstone and a `sys_metadata_audit` row, with actor `os migrate security-catalog-overlays`. A row stored under a legacy plural is out of that door's reach, so it goes through the `sys_metadata` repository beneath it. That delete is addressed by the row's stored type, name, `package_id` and checksum, and writes the same history tombstone. + - **The refusal names it.** The cold boot's `NAMESPACE_CONFLICT` message now points at `os migrate security-catalog-overlays` for the environment's rows, in place of "through the metadata API on a boot that leaves the package out of the configuration, or in the database". The envelope (`code`, `status`, `conflicts[]`) is unchanged. + - **What it never does.** It adopts nothing: `managed_by` and `package_id` are never rewritten, and no row is renamed. A row the environment needs under its own name must be re-created under a name no package holds. A host config that exists and cannot be loaded is refused before any row is read, because the held names would be incomplete. + - **New exports it is built on.** + - `@objectstack/objectql` exports `findPackageHeldSecurityCatalogNames(registry)`: every package-held permission-set and position name, with its holder packages. The cold-boot check now reads the same list. + - `@objectstack/core` exports the auth gate `os serve` and the step share. `resolvePlatformAuthComposition` answers whether the platform composes `AuthPlugin` and the security plugin beside it, or why not. With it come `resolveStackTiers`, `STACK_TIER_PRESETS`, `CAPABILITY_TO_TIER`, `resolveAuthSecret`, `isDevelopmentBoot`, `DEV_AUTH_SECRET_FALLBACK`, `stackSuppliesAuthPlugin`, `isHostKernelComposition` and the `PlatformAuthComposition` / `PlatformAuthSkipReason` types. `Serve.TIER_PRESETS` and `Serve.CAPABILITY_TO_TIER` are now handles over the core declarations, and `os serve` composes exactly what it composed before. + - `@objectstack/runtime`'s `createStandaloneStack` accepts `hydrateMetadataFromDb: false`, which skips the `sys_metadata` read-back. The default stays on. + - `@objectstack/spec` registers the ADR-0087 D3 entry `security-catalog-environment-overlay-refused` in its migration registry and `spec-changes.json`, so `os migrate meta --from 17` names the refusal and this step as its remedy. Its projection is the entry under protocol 18 in the upgrade guide (`docs/protocol-upgrade-guide.md`). +- 440bed6: A select option whose `visibleWhen` cannot be evaluated refuses the write instead of admitting the value with one warn line (#22402) + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing on `@objectstack/objectql`, shipped as `minor` under the launch-window convention for breaking changes (recorded in `scripts/check-changeset-no-major.mjs`). The governing decision is ADR-0137 D2, which the maintainer's ruling on #22402 (letter A) extended to the option gate on the write path. + + A select, multiselect, radio or checkboxes option may gate itself with a `visibleWhen` predicate, and the server re-checks it for every value a write picks. ADR-0137 D2 says a field-rule predicate that cannot be evaluated refuses the submit. The option gate is the server's enforcement of who may pick the option (ADR-0124 D1), so D2 now reaches it on the write path too. + + - **Before.** When an option's predicate faulted on a write (an undeclared key, a root or member the gate does not bind, a read through a reference, a computed key or receiver, `current_user.can()` with no permission data), the server logged "the option's gate was NOT enforced on this write" and admitted the value. A role-gated option whose rule was broken was open to every caller. + - **Now.** The write is refused with `VALIDATION_FAILED` (HTTP 400) and one field entry in the envelope a faulting `requiredWhen` or `readonlyWhen` already uses: `code: 'rule_violation'`, `constraint.reason: 'unevaluable'`, `constraint.rule: 'visibleWhen'`, `constraint.fault` naming the fault, and the picked option as `value`. The message names the option, the field and the fault, for example `Option 'gold' of field 'tier' visibleWhen could not be evaluated (runtime: No such key: statsu) — write rejected.` Nothing is written. This holds on insert (one row or a batch), by-id update and bulk update, and so on an import, which writes through them; `validate()` previews the same refusal. + - **Unchanged.** A predicate that evaluates is judged exactly as before: false refuses with `invalid_option`, true admits. A system write with no acting user whose predicate reads the acting user (`'admin' in current_user.positions` on a seed) is still admitted and logged with reason `no-acting-user`, because the gate has nobody to ask about. A system write whose predicate faults for any other reason is refused, as a faulting `requiredWhen` is. The form still offers an option whose predicate faults (ADR-0137 D3); the refusal comes at save. + - **`current_user.can()` with no permission data.** On an engine with no effective-permission resolver registered (no security plugin composed), a write by an acting user that picks a `can`-gated option is now refused, naming the missing permission data, instead of admitted. + - **A preview limit this inherits.** An `update`-mode `validate()` preview (an import dry run of a matched row) reads no stored row, so a cascade predicate whose parent column the patch omits faults there and the preview now refuses the row, as it already refuses a `requiredWhen` that reads an omitted column. The write itself reads the stored row and judges the predicate cleanly. + + **The remedy: fix the predicate, which `os build` names for every statically judgeable shape.** The shapes no build can judge (a computed key, a computed receiver, a row stored before the build verdicts, or a save under `OS_ALLOW_UNLINTED_METADATA_WRITES=1`) now meet this refusal at the first write that picks the option, naming the fault. + + Measured before the change, on `origin/main` 3054516ef1: the objects this repository ships carry 5 option predicates, all on the showcase's `showcase_cascade` (four `record.country == …` cascades and one `'org_admin' in current_user.positions` role gate), and none of them faults on a write. Insert and update evaluate them over a total record, so a payload without `country` reads `null == 'cn'`, a clean false. No seed or dogfood write picks a gated option. + + Earlier entries in this release that say an option predicate which faults is still logged and admitted describe the gate before this change. + + `@objectstack/lint` and `@objectstack/spec`: the published text that said the server admits a faulting option predicate now says it refuses the write. This covers the `objectstack validate` finding for an option predicate that reads through a reference, a root or member the gate does not bind, and the `SelectOption.visibleWhen` description. No verdict moves. +- 35ef501: An `update`-mode `validate()` preview judges the stored row merged with the patch, as the by-id update does, so an import dry run of a matched row admits the rows the import's update admits (#22445) + + Clause-②: yes + + The preview's accept set widens: rows it refused, and the write admits, are now admitted. No key, export or error code is added, removed or renamed. + + - **Before.** An `update`-mode preview (`engine.validate(object, rows, { mode: 'update' })`, `validateData`, and the import dry run of a row that matched a stored record) read no stored row, so the record its rules judged held only the patch. A `requiredWhen`, an option `visibleWhen` or a `validations[]` rule that reads a column the patch omits faulted there, and the preview refused the row with `rule_violation` / `unevaluable`, although the real update reads the stored row first and admits it. The refusal also said the omitted column was one "which this object does not declare", sending the author looking for a field that exists. + - **Now.** A row that carries its `id` (the address every update door already folds into the payload) is judged against the row that id names: the stored row is the rules' `previous`, and their record is the stored row merged with the patch, exactly as the by-id update judges it. A reference field a traversing rule reads is resolved from that merge too. The import dry run of a matched row sends the matched record's id this way, so nothing new crosses the protocol. + - **Read under the caller's access.** The engine's read door, under the caller's own context, decides whether there is a row to judge: a row the caller cannot read gets the verdict a missing row gets. The values judged are the row as stored, read the way the by-id update reads its prior row (so a formula column, which has no stored value, is judged as the update judges it). A stored column is judged only where the read door served this caller that very value. A column the read door hid, or served transformed (a partially masked field, a masked secret), is judged as empty, so the verdict never depends on a value the caller could not read in full. File fields are compared in their stored form, so a readable file reference is judged as the id it holds. Measured on `driver-sql` (SQLite) and `driver-memory`, a plain readable column (number, currency, percent, boolean, date, datetime, JSON, multiselect, select, text) reaches the rules as its stored value. + - **Unchanged.** A row with no `id`, or whose id names no row the caller can read, is judged on the patch alone, as before. `required` and value checks still look only at the supplied keys, matching a PATCH. A merge that really violates a rule is refused, as the write refuses it. The preview still runs no `readonlyWhen` or primary-key strip, so it can report fewer dropped fields than the update. + - **The refusal names the real cause.** Where a rule reads a column the object declares and the judged record does not hold it (a preview with no stored row), the refusal now says the value was not supplied and no stored row was read, instead of saying the object does not declare the column. A column the object really does not declare keeps the old sentence. + + `@objectstack/core`: the import runner's dry run passes the matched record's id with an update row. `@objectstack/spec`: the `ValidateDataRequest.mode` description and the `ValidateDataResponse` note say what an `update` preview now reads. + + The pending entry for the option-gate change in this release says an `update`-mode preview reads no stored row and refuses a cascade pick whose parent column the patch omits. That describes the preview before this change: with the row's `id`, the preview now reads the stored row and judges the pick as the write does. +- da989bb: A `validate()` preview binds the master-detail header the write binds, so an import dry run of a detail row whose field rule reads `parent` admits the rows the import admits (#22474) + + Clause-②: yes + + The preview's accept set widens: rows it refused, and the write admits, are now admitted. No key, export or error code is added, removed or renamed. + + - **Before.** A preview (`engine.validate(object, rows, { mode })`, `validateData`, and the import dry run) bound no master-detail header. A field `requiredWhen` that reads the header, such as `parent.status == 'sent'` on an invoice line, faulted there and the preview refused the row with `rule_violation` / `unevaluable`, in both modes, although the insert and the update resolve the header and admit it under a draft header. + - **Now.** The preview binds `parent` as the write binds it: from the row's master-detail reference on an insert, and on an update from the reference the patch carries, else from the stored row the row's `id` names. An update that moves the row to another header also binds the header the stored row hangs off, which the write reads to decide whether the stored row already violated the rule. So a row under a draft header is admitted and a row that leaves the field empty under a sent header is refused with `required`, as the write refuses it. Only an object that declares a field `requiredWhen` reading `parent` reads a header, as on the write. + - **Read under the caller's access.** The write reads the header with system access, kept to the caller's organization. The preview first reads it through the engine's read door, under the caller's own context: a header the caller cannot read gets the verdict a missing header gets. A header column is judged only where the read door served this caller that very value. A column the read door hid, or served transformed (a partially masked field, a masked secret), is judged as empty, so the verdict never depends on a header value the caller could not read in full. For such a caller the preview can therefore answer differently from the write, which judges the stored header. + - **Unchanged.** An object with no field `requiredWhen` reading `parent` reads no header and gets the verdict it got before. The preview still runs no `readonlyWhen` strip, so an update whose `readonlyWhen` would keep the row on its stored header is judged against the header the patch names. +- 018b077: fix(objectql)!: a detail write asks the caller's read door for its master-detail header before judging it — a header the caller cannot read answers as a header that exists nowhere (#22519) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A write of a master-detail DETAIL reads its header for two things: the reference check of the `master_detail` value the caller supplied, and the `parent` that a parent-scoped `requiredWhen` / `readonlyWhen` judges. + + **FROM.** Both read the header elevated (row-level access bypassed, the caller's tenant kept), on the premise that the caller's right to write the detail settled its right to see the header. ADR-0055 §3 guarantees that only for a `controlled_by_parent` detail (its master-edit gate). For any other detail, a non-system caller who could write the detail but could not read its header met: + + - a reference check that admitted the header id, where a header id that exists nowhere answers `reference_not_found`; + - a parent-scoped rule judged against the header's stored state, so the write's answer depended on a row the caller cannot read. + + **TO.** The header is read in two steps, the shape `validate()`'s preview already uses: + + 1. The caller's read door, under the caller's own context, so every enforcement layer in the middleware chain answers it. A header it does not return (hidden by row scope, a refused read of the header object, or a failed read) binds as a missing header binds: the reference check answers `reference_not_found` (`400 VALIDATION_FAILED`), and a parent-scoped `requiredWhen` / `readonlyWhen` meets an unbound `parent` (the requirement is refused as unevaluable, the lock holds), exactly as for a header id that exists nowhere. + 2. The elevated, tenant-kept read, only for the headers step 1 returned. A header the caller can read is still judged on its STORED values, a column field-level security hides or masks for the caller included. The preview's per-column masking stays the preview's own. + + **Bindings.** + + - Every detail, `controlled_by_parent` or not, on every write door: `insert` (single and batch), `insertMany`, update by id (a repoint included) and the predicate (bulk) update. + - System callers (`isSystem`: seed replay, boot, package install) ask no read door and resolve exactly as before. + - A plain `lookup`'s reference check keeps its elevated existence probe, unchanged. + - No new error code and no status change: the answers are the ones a header that exists nowhere already got. + - Cost: one id-projected read under the caller's context per header resolution (once per batch), and one per supplied `master_detail` value in the reference check. + + **Fix, if a write relied on the old answer:** give the writer read access to the header — an object permission or sharing rule on the master, or declare the detail `controlled_by_parent` so the header's access governs it. + + **Not measured.** Which deployed configurations let a caller write a detail whose header it cannot read was not measured: the rule only tightens, so it holds whatever that answer is. +- 02d9f69: A file field whose `sys_file` read was refused to the reader reads as refused, not as "no file" + + Clause-②: yes + + The engine expands a stored file id (`file` / `image` / `avatar` / `video` / `audio`) by reading `sys_file` as the caller. A reader who may read the record but not `sys_file` (the default for any member whose application grants no `sys_file` read) is refused that read, and the field used to come back as the bare id: the same value an id with no committed file row produces. A refused file and an absent one were one answer, and the server logged a `warn` per read telling the operator to check storage availability. + + - **From.** `{ "attachment": "f_7cQx2Lm90a" }` for a reader refused `sys_file`, plus `warn: sys_file lookup failed; file fields keep their raw ids and will render as "no file"`. + - **To.** `{ "attachment": { "id": "f_7cQx2Lm90a", "metadataRefused": true } }`. Multi-value fields mark each id. Nothing else is served, because nothing else was read: no `name`, `size`, `mimeType` or `url`. A consumer that needs the download link derives the stable `/api/v1/storage/files/:fileId` endpoint from the id, as it does for a bare id; the download door judges access by the record that owns the file. The refusal is logged at `debug` only, because the reader is told in the value. + - **Unchanged.** A reader allowed to read `sys_file` gets the hydrated `{ id, name, size, mimeType, url }`. An empty field reads empty. A missing `sys_file` table still leaves the bare id, silently. Any other read failure (connection, timeout, a database-level ACL fault such as `42501`) still leaves the bare id with the `warn`. Only the security layer's `PERMISSION_DENIED` refusal takes the new form. + + `@objectstack/spec` declares the shape: `FileRefusedValueSchema` / `FileRefusedValue` (`{ id, metadataRefused: true }`, closed), and `valueSchemaFor(def, 'expanded')` for the file types admits it beside the bare id and `FileValueSchema`. It is a read form only. The stored form stays the bare id. + + **For consumers.** Code that reads a file field's expanded value as `string | FileValue` should handle the third form: an object with `metadataRefused: true` and no `url` is a file this reader may not see the details of, not an empty field. +- 1bb1da1: A reader refused `sys_file` read now sees the name, size and type of a file the record they are reading owns, whenever the download door would serve them its bytes + + Clause-②: yes + + The download door already judges a field-owned file by the record that owns it (ADR-0104 D3): a declared `fileAccessDelegate` decides where the owner object names one, otherwise the caller's read of that record. Record file-field hydration judged the same file by `sys_file` object read instead, which the default member grants do not include. So one reader could download a contract's signed PDF but saw the field as `{ id, metadataRefused: true }`: the bytes yes, the name no. + + - **From.** A reader with read on `contract` and no `sys_file` read gets `{ "signed_pdf": { "id": "f_7cQx2Lm90a", "metadataRefused": true } }` for every file id the record holds. + - **To.** For a file whose `ref_object` / `ref_id` name the record being read, that reader gets the hydrated `{ "id": "f_7cQx2Lm90a", "name": "signed.pdf", "size": 2048, "mimeType": "application/pdf", "url": "/api/v1/storage/files/f_7cQx2Lm90a" }`, exactly as a `sys_file` reader does, when the door's field-owned verdict allows it. A delegate-governed object (for example `sys_approval_action`, delegate `approvals`) gets its delegate's answer, not the raw record read. + - **Unchanged.** Every other id keeps `{ id, metadataRefused: true }`: an id copied in from another record, an attachment-only or unclaimed file, a `public_read` file the record does not own, and an owned file the delegate or the record read refuses. `sys_file`'s own read rule is unchanged, so a direct `sys_file` query by the same reader is still refused. A reader with `sys_file` read is served by their own read, as before. The download door's verdict for every request is unchanged. + + **One verdict, two callers.** `@objectstack/service-storage` moves the download door's field-owned arm into one module-private function (not exported) and hands it to the engine through a new seam, `ObjectQL.registerFieldOwnedFileReadAuthorizer` (type `FieldOwnedFileReadAuthorizer`, exported from `@objectstack/objectql`), the same handover `registerHeldFileResolver` already makes. The engine never re-derives it. An engine with nothing registered keeps the refused marker for every id. On a refused hydration the engine makes one more `sys_file` read under the caller's tenant (it reads only ids whose ownership pair names a record in the result) and asks the verdict once per read. + + **For consumers.** Nothing to change. A refused reader may now receive the full file object where it used to receive the marker, and code that already handles all three `expanded` read forms handles this. +- 615cba8: fix(objectql,metadata-protocol)!: the data door honours a field's `internal: true` in its filter (related-object conditions included), sort, group-by, aggregate, per-aggregation-filter, `$search` and `$expand` positions at every depth, not only the row (#22646) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A field declared `internal: true` is withheld from every generic exit (#21197): the engine omits it from every row, select-named included. The ROW position honoured that; the generic data door's EVALUATE positions did not, so a value the platform withholds from rows could still be learned through a position that answers by its stored value. + + **FROM.** On `origin/main`, for a member holding read and for an administrator alike, on the list and query data routes (`GET /api/v1/data/:object`, `POST /api/v1/data/:object/query`): + + - A **filter** naming such a field was admitted — the predicate reached the driver, so the row came back only when the guess matched: a confirmation oracle over a withheld value. + - A **sort** by such a field was admitted — the order leaks the comparative stored value. + - A **group-by** of, or an **aggregate operand** over, such a field was refused by the engine, but as an undeclared `500 INTERNAL_ERROR` (a bare `Error` with no `code` or `status`), not a declared refusal. + - A **per-aggregation filter** (`aggregations[].filter`) naming it, and a **filter beside an aggregation**, were admitted — the same oracle at a second filter position. + - A **`$search`** whose resolved set included such a field (a non-hidden `internal` text column enters the auto-default) scanned it — a cross-field substring match that confirms a guessed prefix. + - A **nested-relation condition** (a condition on a lookup's related object) naming the related object's `internal` field was evaluated on that object — the parent row came back only when the related value matched, the same oracle one hop away. + - An **`$expand`** entry's own `where` / `orderBy` on its TARGET object's `internal` field was admitted into the expansion sub-read, at the first level and at every deeper level — the expanded record's presence is an oracle on the related row. + + **TO.** Each evaluate position is refused `400 INVALID_FIELD`, a declared ADR-0112 envelope naming the object and the field, before the engine is asked: + + - Filter, sort, group-by, aggregate operand and per-aggregation filter are refused at the generic data door (`@objectstack/metadata-protocol`), after the field-existence gates, in the shape and envelope of the stored-metadata body/hash family one axis over. A nested-relation condition inside any filter is judged against the RELATED object's own `internal` fields, and every level of an `$expand` tree has its own filter / sort judged against that level's target object; the refusal names the object that declares the field. + - The engine's `aggregate()` refusal (`rejectCredentialAggregation`) now carries the `INVALID_FIELD` / 400 envelope instead of the bare `Error`, so the group-by and aggregate-operand positions answer a declared `400` rather than a `500` — and the `secret` / `password` case that shared that bare `Error` is upgraded with it. + - A `$search` never scans an `internal: true` column: the field is dropped from the set the engine's search expansion resolves over (a withhold, like the auto-default's `hidden` / credential-type exclusions), and an explicit `$searchFields` naming one is refused `400 INVALID_FIELD` at the door rather than silently widened back to the default set. + + **Bindings.** + + - **Every caller.** The refusal is at the generic data door and takes no user, so an administrator is refused exactly as a member is (#21197). There is no system carve-out at the door. + - **The engine's privileged consumers are untouched.** The API-key verifier's `where: { key: }` lookup, the share-link / SCIM / approval-token lookups — all call the engine directly under a system context and never pass the generic data door, so authentication keeps working, exactly as #7823 kept the engine's write results whole while the generic-data-path ingress stripped them. + - **One flag reader.** Every position judges `collectInternalReadFields` (engine) or its byte-identical `@objectstack/core` twin `collectInternalWriteResponseFields` (the door, which sits below objectql), never a second list. + - **Unchanged.** An ordinary field in every position is served as before; an object that merely has an `internal` field is served for every other field. + + **Scope.** Target-OBJECT exposure through `$expand` is a separate axis (#22661); the cross-object search sweep (`searchAll`) is #22640's region and inherits the engine's search-set withholding through `engine.find` without a change to its own code. +- 490cb6d: A write answer now carries the advisory validation-rule hits of that write as `warnings` + + Clause-②: yes (widening) + + A `severity: 'warning'` or `'info'` validation rule never blocks a write. Until now its hit was only logged on the server, so no client could show the person who saved what the rule says. A create, an update or a clone now answers the hits of that one write, and the validate-only preview reports the same entries. + + - **Write answers.** `CreateDataResponseSchema`, `UpdateDataResponseSchema` and `CloneDataResponseSchema` declare an optional `warnings`: one entry per advisory rule hit, carrying the rule's `name` as `rule`, its `severity`, the `field` it is about, the finding `code` and the author-written `message`. The key is absent when there is no hit, so an existing client's reading of the answer does not move. REST relays it in the response body unchanged. The write's status and record are unchanged, and an `error` rule still refuses the write. + - **One element, shared with the preview.** `ValidateDataIssueSchema` gains optional `rule` and `severity`, set only on an advisory rule hit. It is the element of the new `warnings`. `validateData` appends the advisory hits of the same evaluation to each accepted row's existing `results[].warnings`, after the value-shape findings the deployment admits. An import's dry run copies that array to the row verbatim, as before. + - **Engine.** `WriteObservabilityOptions` gains `onValidationAdvisory`, an in-process listener beside `onFieldsDropped`, with its event schema `ValidationAdvisoryEventSchema`. The engine calls it once per advisory hit on the write it was passed to: per row on `insert`, and on `update` by id and per matched row of a `multi` update. A nested write a hook or a flow makes inside that write carries its own options, so its hits never reach the outer write's listener or answer. `evaluateValidationRules` returns its advisory hits (an empty array when there are none) instead of `void`. + - **Unchanged.** Rule semantics, the per-write `warn` log line and the seed and boot load's one summary line per rule are all as before. No authorable key is added: `ValidationRuleSchema` is untouched. A rule whose condition could not be evaluated is not a hit. Its fault text is for the operator, so it stays in the server log only. + - **Client.** `CreateDataResult`, `UpdateDataResult` and `CloneDataResult` declare `warnings?: ValidateDataIssue[]`. + + **For authors.** Nothing to change in metadata. Write each advisory rule's `message` for the person saving the record, because a client can now show it to them. +- 3f0b6f2: An `after*` hook whose `onError` is `abort` (the default) now rolls its write back on the default write door, as `HookSchema.onError` says ("abort: Rollback transaction (if blocking)") + + Clause-②: yes (narrowing) + + + + **BREAKING** stored-outcome narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0087, the ratified pre-launch launch-window exemption, as amended 2026-09-13, #18003). + + - **FROM:** `insert` / `update` / `delete` (REST `createData` / `updateData` / `deleteData`, `ctx.api`, the engine door) on an object with a blocking (not `async`) `after*` hook bound from metadata whose `onError` is `abort` or omitted. When that hook threw, the caller was refused, but the row stayed stored, changed or deleted, and so did every write the hook chain had already made through `ctx.api`. A client that retried the refused create made a duplicate. + - **TO:** that write runs inside a unit of work, so the abort rolls back the row and the hook chain's engine writes together. The caller is refused exactly as before. Hook count and order are unchanged. + - **Unchanged:** a write already inside a unit (a caller's `transaction()`, an atomic batch or cascade) joins it, and that unit's owner still decides. No unit is opened for an `onError: 'log'` or `async: true` hook that carries no `condition` (#22856), a hook registered in code with `registerHook` (no declared `onError`), a write with `skipAutomations`, an object whose datasource is not the default one, or a default driver with no transactions (ADR-0119 D1, such as a libSQL remote transport). On those the row stays stored after an abort, as before. + - **Inside the new unit, the rules of any unit apply.** An `after*` hook on such an object that writes a business row to an object on another datasource is now refused with `CrossDatasourceTransactionWriteError`, which the abort turns into a refused write. A MongoDB default store needs a replica set for this unit, as it does for `transaction()` already. If either applies to you, mark the hook `onError: 'log'` or `async: true` and give it no `condition` (#22856), or keep the objects on one datasource. + - **A sandboxed hook body's own `ctx.api.transaction`** on such an object, when no transaction is open around the write, used to open and own its transaction, so a throw from it rolled back only the body's own writes and the triggering row stayed stored. It now joins the door's unit, as it joins any open unit, and the abort rolls back the triggering row too. + - **Work a unit starts and does not await** (an `async: true` hook) no longer inherits the unit's finished transaction handle. It used to fail with "Transaction query already complete" and lose its write. It now runs on its own connection, as it would outside any unit. + - **Cost:** one transaction per write on the objects above only. Measured on the in-process SQLite driver: about 1.36x the per-insert time of the same object with an `onError: 'log'` hook. Objects without such a hook pay one map lookup. +- 9814035: feat(spec): `ReadableIds`, the branded `$in` comparand leaf meaning "the ids of object X this caller may read" (ADR-0139 D1) + + Clause-②: yes (widening) + + - **What is new in `@objectstack/spec/data`.** `ReadableIds.of(object)` builds a frozen leaf, `{ object }`, placed as the comparand of `$in` on a pointer field: `{ record_id: { $in: ReadableIds.of('cpz_doc') } }`. `isReadableIds(value)` answers whether a value is one. `READABLE_IDS_BRAND` is the `Symbol.for('objectstack.filter.readableIds')` key the brand lives under. The `ReadableIds` type carries the brand, so an object literal does not type-check as one. `ReadableIds.of()` throws a `TypeError` for an object name that is not a non-empty string. + - **No request can spell it.** No filter operator is added. The brand is a non-enumerable symbol-keyed property, so JSON, a structured clone or a spread produces an unbranded `{ object }`, and a plain object as the comparand of `$in` is refused `INVALID_FILTER` / 400, as it was before. + - **The comparand-shape door admits it only for a caller that composes it.** `assertListComparandShapes` takes an optional fourth parameter, `ListComparandShapeOptions`. With `{ admitReadableIds: true }` a branded leaf is admitted as the comparand of `$in`, and nowhere else. Without it, which is every existing caller (`parseFilterAST`, the save door, the analytics doors, the RLS compiler), the leaf is refused as a non-list comparand, unchanged. + - **`@objectstack/objectql` does not compose the leaf yet.** The engine's comparand door passes the option, then refuses a branded leaf found anywhere in a `where`, an aggregation's `filter` or a `having` with `INTERNAL_ERROR` / 500, before any driver is asked. Do not emit the leaf until the engine composes it. Until then, read the parent object as the caller and match the ids that read returns with a list. + - Nothing emits the leaf yet, and no read that ran before this change answers differently. +- 55382dc: A paged list under a search now reports the real number of matching records as `total`, and `hasMore` agrees with it on every page. + + Clause-②: yes (widening) — `EngineCountOptionsSchema` gains two OPTIONAL keys, `search` and `searchFields`, so the accept set of the count options grows. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write them. + + Until now `GET /api/v1/data/:object?$top=25&$search=Line` over 75 matching records answered `total: 26` (offset + page + 1, a page-local estimate), and the last page claimed `hasMore: true`; `POST /api/v1/data/:object/query` with a `search` did the same. Nothing on the response marked the number as an estimate, so the Console rendered it as the record count and offered the wrong number of pages. + + - **`@objectstack/spec`** — `EngineCountOptionsSchema` declares `search` (the bare string, or the structured `FullTextSearchSchema` form) and `searchFields`, identically to `EngineQueryOptionsSchema`. FROM `count(object, { where })` only → TO `count(object, { where, search, searchFields })`. The structured `search` form carries flag defaults, so the parsed state is named `EngineCountOptionsParsed` (ADR-0122). `HookCountQuery` (`ctx.api.object(name).count()`) derives from it and gains the same two keys. + - **`@objectstack/objectql`** — `engine.count()` (and `ctx.api.object(name).count()`) accepts the two keys it used to refuse as unknown options, and expands them through the same ADR-0061 expansion `find()` and `aggregate()` use: the same server-resolved searchable fields, the same `internal: true` exclusion, the same `searchFields` narrowing, AND-ed with `where` before the security middlewares run. There is one expander, not two, so a count under a search is the number of rows `find()` returns for the same bag. + - **`@objectstack/metadata-protocol`** — `findData`'s paged branch passes `search` / `searchFields` to `engine.count()`, and the page-local estimate branch is gone. `$count=false` still skips the count and omits `total`. + + Nothing to migrate. A client that worked around the estimate, for example by labelling the count as page-local under a search, can read `total` as the match count again. +- 3caae97: fix(objectql)!: a write refused because an `after*` hook's `condition` could not be evaluated no longer leaves its row behind when that hook is `onError: 'log'` or `async: true` (#22856) + + Clause-②: no (narrowing) + + + + **BREAKING** stored-outcome narrowing, shipped as `minor` under the launch-window convention for breaking changes. + + A hook `condition` that cannot be evaluated aborts the operation whatever the hook's `onError` says. Examples are a condition reading `previous` on an insert, a key the object does not declare, or `null > null`. The condition is checked before an `async: true` hook detaches, so that hook aborts too. + + - **FROM:** `insert` / `update` / `delete` (REST `createData` / `updateData` / `deleteData`, `ctx.api`, the engine door) on an object whose `after*` hooks bound from metadata are all `onError: 'log'` or `async: true`, with at least one carrying a `condition`. The write door opened no unit of work, because it opened one only for a blocking `onError: 'abort'` hook. So a condition that could not be evaluated refused the caller while the row stayed stored, changed or deleted. Measured on REST `POST /data/:object`: `500`, and the row was stored. + - **TO:** an `after*` hook bound from metadata that carries a `condition` opens the write door's unit, whatever its `onError` and `async`. A condition that cannot be evaluated rolls back the row and the hook chain's engine writes together. The caller is refused exactly as before. + - **Inside the new unit, the rules of any unit apply,** so writes that were accepted are now refused on objects carrying such a hook: + - A blocking `onError: 'log'` hook whose body writes a business row to an object on another datasource has that write refused with `CrossDatasourceTransactionWriteError`. The hook's `onError: 'log'` logs the refusal and the row is not written, where it used to land. The caller's own write is still stored. + - The same applies to the part of an `async: true` hook's body that runs before the unit's callback settles. A write the body makes after that lands as before. + - A MongoDB default store without a replica set now refuses writes to objects carrying such a hook, because this unit needs a replica set, as `transaction()` already does. + - **Fix, if one of these applies to you:** drop the hook's `condition` (and test it in the hook's body instead), or keep the objects on one datasource, or run MongoDB as a replica set. + - **Unchanged:** a condition's verdict is read as before. `false` skips the hook, and with `true` an `onError: 'log'` hook's thrown error is logged and the caller's row is stored. A write already inside a unit joins it. No unit is opened with `skipAutomations`, off the default datasource, or on a default driver with no transactions. An `onError: 'log'` or `async: true` hook with no `condition` still opens no unit. + - **Cost:** one transaction per write on objects that carry such a hook. This is the same per-write cost as the unit for `onError: 'abort'` hooks. In the showcase example this covers updates to `showcase_task` and `showcase_project`, whose `async: true` `afterUpdate` hooks carry conditions. +- 87e902d: feat(objectql): the engine composes a `ReadableIds` leaf at its executor boundary, on the final query immediately before the driver call, and refuses it by name wherever it cannot (ADR-0139 D2, D5) + + Clause-②: no + + - **Composed.** On `find`, `findOne`, `count` and `aggregate`, after the middleware chain and the `beforeFind` hooks, every `{ FIELD: { $in: ReadableIds.of(P) } }` in `where` is replaced by `ReadableIds.compose(leaf, scope)`, where `scope` is the registered security service's `getReadFilter(P, caller)`, marked `'policy'` (`undefined` composes with no row restriction). The comparand becomes `$in: []`, which matches no row, when: + - `canReadObject(P, caller)` is false or throws (a service without `canReadObject` is asked `explain`, and only `allowed: true` admits); + - `getReadFilter` answers `RLS_DENY_FILTER` (by identity) or throws; + - no security service is registered, or the registered one cannot answer both halves (the engine reads the kernel's `security` service through `ObjectQLPlugin` when a leaf is composed, so an engine used without that plugin has none); + - the registry holds no object `P`. + + Each parent object is asked once per read, and nothing is kept across reads. A leaf that a middleware or a `beforeFind` hook writes is composed the same way. + - **Refused, `INTERNAL_ERROR` / 500, before any driver is asked:** + - on `update` and `delete`: the caller's leaf at the comparand door, a middleware's at the executor's entry, before the prior-row reads; + - in `having` and in an aggregation's own `filter`; + - anywhere but a `$in` comparand, including a leaf the array form of `where` lowers into another position; + - a composed leaf the engine did not compose, written by a middleware or a hook (the caller's own, as a `$in` comparand, stays the comparand door's `INVALID_FILTER` / 400); + - ADR-0139 D5's three classes: a delegated (on-behalf-of) caller, a parent with an object-scoped read middleware, and a parent bound to another datasource than the object read. + - **Before:** the comparand door refused every `ReadableIds` leaf `INTERNAL_ERROR` / 500 ("this engine does not compose that leaf yet"), and a leaf that a middleware or a hook wrote reached the driver unrefused. + - **`@objectstack/core`:** `resolveFilterTokens` returns a `ReadableIds` leaf by reference. When the filter also held a placeholder, it used to rebuild the leaf as a plain `{ object }` and drop its brand. + - **Not yet:** no driver evaluates the composed leaf (ADR-0139 E3 and E4), and no code in the platform writes a `ReadableIds` leaf today. +- 038d4c9: feat(metadata-protocol): one language of an email template is read, reset, published, viewed in history and reverted by name — the by-name `/meta` doors accept a bundle member selector (#22877) + + Clause-②: yes + + **What an administrator can now do.** An email template kept in several languages is one item per language in the store. Each language can now be addressed by name on every by-name door, with `?member=` naming the language: + + - `GET /api/v1/meta/email_template/:name?member=zh-CN` reads the `zh-CN` wording: the stored customization when there is one, else the package's `zh-CN` template. A language that nothing stores or ships answers as an absent item (404). + - `DELETE /api/v1/meta/email_template/:name?member=zh-CN` resets the `zh-CN` customization alone; the other languages keep theirs. + - `POST /api/v1/meta/email_template/:name/publish?member=zh-CN` publishes the `zh-CN` draft. + - `GET /api/v1/meta/email_template/:name/history?member=zh-CN` lists the `zh-CN` saves. All languages of one template share one version counter, so the version numbers of one language are not consecutive. + - `POST /api/v1/meta/email_template/:name/rollback?member=zh-CN` reverts the `zh-CN` customization to one of its own versions. A version saved for another language answers `404 VERSION_NOT_FOUND`. + + With no `?member=`, every door addresses the canonical member (`en-US`), exactly as before; naming `en-US` is the same request. This closes the window the previous release's changeset stated: a non-`en-US` language could not yet be reset, published, listed or reverted by name. + + **The selector.** It is named `member`, after what the store keys a row by, and not `locale`: on `/meta`, `?locale=` already chooses the language the response is translated into. The protocol requests gain one optional field, `member`, on `GetMetaItemRequest`, `DeleteMetaItemRequest`, `PublishMetaItemRequest` and `HistoryMetaItemRequest`. A wrong value is refused with `400 VALIDATION_ERROR`, naming the parameter: the selector on a type whose items have no languages (every type except `email_template` today), a blank value, or a value longer than a stored member can hold (100 characters). + + **Each of the five routes now declares its query parameters.** `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` refuse a query parameter they do not read with `400 VALIDATION_ERROR`, naming the parameter and listing the supported ones, instead of ignoring it. A misspelled `?memeber=zh-CN` is therefore refused, not answered with the `en-US` wording. The supported names are the ones each route has always read, plus `member`: + + - `GET /meta/:type/:name`: `layers`, `state`, `preview`, `package`, `locale`, `member`. + - `DELETE /meta/:type/:name`: `state`, `dropStorage`, `member`. + - `GET /meta/:type/:name/history`: `sinceSeq`, `limit`, `member`. + - `POST /meta/:type/:name/publish`: `package`, `member`. + - `POST /meta/:type/:name/rollback`: `toVersion`, `member`. + + A client that sent any other parameter to these routes was having it ignored; it now gets a 400 and can drop it. The deprecated `?layers=` view reads no language, so `?layers=` together with `?member=` is refused rather than answered with the `en-US` layers. A read naming a member skips the shared read cache. + + **What else now follows the language** (`@objectstack/objectql`, `@objectstack/metadata-protocol`): + + - A by-name read that names no language answers the `en-US` template even when only another language is customized. Before, the registry's read of the name could answer the other language's customization. + - Resetting one language keeps the other languages' customizations served at once, not only after the next restart. + - Publishing, discarding and reverting a package's changes (`POST /packages/:id/publish-drafts`, discard, and commit revert) address each language's draft or change as its own item. Before, a package whose only pending draft was a second language failed to publish (`NO_DRAFT`), and a discard reported that draft discarded while it stayed. The draft list and the commit timeline keep the fields they declare. + - In a cluster, a save, reset or revert of one language converges the other servers on that language. + + **For code that uses these packages directly.** Every addition below is optional; code that does not pass it behaves as before, except the bare-name read noted in the first item. + + - `SchemaRegistry` (`@objectstack/objectql`) takes an optional last argument, `member`, on `getItem`, `getArtifactItem`, `removeRuntimeShadow` and `removeOverlayEntry`. It is the discriminator value of one bundle member, such as `'zh-CN'`. + - `getItem(type, name, packageId, member)` answers that member, in the usual order (customization, then the requested package, then any package), or `undefined`. With no `member`, `getItem` now answers the canonical (`en-US`) member of a bundle. A bundle with no `en-US` member still answers its first member. + - `getArtifactItem(type, name, packageId, member)` answers that member's packaged definition. + - `removeRuntimeShadow(type, name, member)` and `removeOverlayEntry(type, name, member)` remove that member's customization entry only. With no `member`, they remove the whole bundle's customization entries, as before. + - A type whose items have no languages ignores the argument. + - `MetadataMutationEvent` (`@objectstack/metadata-protocol`) has an optional `member`: the language the mutated row belongs to. It is present only for a non-`en-US` language. Listeners registered with `onMetadataMutation` receive it. It also rides `ClusterMetadataMutationPayload`, and a server that receives it re-reads that language's row. + - The entries that `SysMetadataRepository.listDrafts` returns have an optional `member`, present for a draft of a non-`en-US` language. The protocol's draft list (`GET /meta/_drafts`) and a package's commit timeline serve it too, on every entry of a template, with the canonical `en-US` spelled out. +- 8d8f965: fix(objectql)!: a validation rule that reads its master through a master-detail field asks the caller's read door for the header first — a header the caller cannot read is unavailable to the rule as a header that exists nowhere is (#22892) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A detail write answers a header the caller cannot read exactly as a header that exists nowhere: its `parent` binding and its reference check ask the caller's read door first (the companion entry for #22519). A validation rule on the detail that reads the header through the master-detail field (`record.invoice.status == 'locked'`) still read the header elevated, and rules run before the reference check, so that rule could still answer by what an unreadable header holds. + + **FROM.** For a non-system caller who may write the detail but cannot read its header, a rule reading the header through the master-detail field judged the header's stored values. Measured with a rule refusing a line under a locked invoice: a header id that exists nowhere was refused with `_record` `rule_violation` ("the related record was not found"); an unreadable open header passed the rule and was refused `reference_not_found`; an unreadable locked header was refused with the rule's own message. Three answers, one bit of the header's state. + + **TO.** Through a `master_detail` field the rule's related read takes the write's two reads: + + 1. The caller's read door, under the caller's own context. A header it does not return (hidden by row scope, a refused read of the header object, or a failed read) is unavailable to the rule exactly as a header that exists nowhere is: the rule cannot be evaluated and the write is refused `400 VALIDATION_FAILED` at `_record` (`rule_violation`, "the related record was not found"), whatever the header holds. + 2. The elevated, tenant-kept read of the columns the rule names, only for the headers step 1 returned. A header the caller can read is still judged on its STORED values, a column field-level security hides or masks for the caller included. + + **Bindings.** + + - Every write door that runs the rule: `insert` (single and batch), `insertMany`, update by id (a repoint included), the predicate (bulk) update, and the `validate()` preview. + - System callers (`isSystem`: seed replay, boot, package install) ask no read door and are judged exactly as before. + - A rule reading through a plain `lookup` is unchanged. + - No new error code and no status change: the answer is the one a header that exists nowhere already got. + - Cost: one id-projected read under the caller's context per master-detail field a rule reads through, once per write batch. + + **Fix, if a write relied on the old answer:** give the writer read access to the header (an object permission or sharing rule on the master), or declare the detail `controlled_by_parent` so the header's access governs it. + + **Not measured.** How many deployed detail objects carry a rule reading their master was not measured: the rule only tightens, so it holds whatever that answer is. +- 21914c3: fix(objectql)!: each element of a multi-valued `lookup` / `user` / `file` / `image` field is held to the single value's stored form — an embedded record, a boolean or a number as an element answers `invalid_type` (#22908) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **FROM.** On a `lookup`, `user`, `file` or `image` field declared `multiple: true`, the record validator checked that the value was an array and stopped there. So an element of the wrong shape was stored: + + - REST `POST` / `PATCH /api/v1/data/:object`, as a signed-in caller: an embedded record `[{ id }]` answered `201` / `200` and was stored verbatim. `[false]` and `[42]` were refused only by the reference-existence check, as `reference_not_found`. + - A system write (`isSystem`: seed replay, hooks, package install): `[false]`, `[42]` and `[{ id }]` were all stored verbatim. + - The single-valued field refused the same three `invalid_type` on both doors. + + **TO.** Each element is parsed against the single value's stored form (ADR-0104 D1, `valueSchemaFor`): a record id for `lookup` / `user`, an opaque `sys_file` id for `file` / `image`. The deployment's value-shape posture decides, the same posture a single value answers to: + + - **Strict** (a fresh store is born attested; or after `os migrate value-shapes --apply` for references, `os migrate files-to-references --apply` for media; or `OS_DATA_VALUE_SHAPE_STRICT_ENABLED=1`): the write is refused `400 VALIDATION_FAILED`, with a `fields[]` entry `{ field, code: 'invalid_type' }` whose detail names the index of the wrong element (`at index 1: …`). + - **Warn-first** (an unverified deployment, or `OS_ALLOW_LAX_VALUE_SHAPES=1` / `OS_ALLOW_LAX_MEDIA_VALUES=1`): the write is admitted, logged and reported as an admitted ADR-0104 violation, exactly as a single value is. + - The shape check runs for a system write too. Whether a referenced id EXISTS is unchanged: a system write still skips that check. + - `os migrate value-shapes` already counted these elements; its report detail now names the index too, because the scan and the write path share one reading. + + **Fix, if a write relied on the old answer:** write the ids as the elements, never the record a read with `$expand` returns — `team: ['acc_1', 'acc_2']`, not `team: [{ id: 'acc_1', name: 'Acme' }]`. To find stored rows that hold such an element, run `os migrate value-shapes` (a dry run writes nothing). +- 5f37d36: feat(spec)!: at most one `isDefault: true` record page per object per deployment — a second is refused at `defineStack` / `os validate`, at package registration and at metadata save, naming both pages (#23015) + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + + `PageSchema.isDefault` now states the rule (maintainer ruling A on #23015): at most one `isDefault: true` record page may exist for an object in a deployment. The record view opens that page with no tie-break, so with two defaults for one object the page that opened used to be whichever plugin initialised first — an accident no author wrote. A record page is `type: 'record'` (the default when `type` is left out) bound to an `object`; `isDefault` on any other page is outside the rule. + + **What is refused, and where.** Every door that can add a second default refuses it, naming both pages: + + - **`defineStack`** (and so `objectstack validate`, `objectstack build` and a source boot) — two default record pages for one object inside one stack, `STACK_CROSS_REFERENCE_INVALID` (422), one finding per object naming the object, every page and its position (`stack.pages[0]`). **`composeStacks`** refuses the pair across the stacks it composes, in the same envelope; under `manifest: 'preserve'` one page name in two stacks is two packages' pages. + - **Package registration** (`@objectstack/objectql`'s `SchemaRegistry`, at `installPackage` ahead of every mutation and at the `registerItem` seam) — a package whose default record page meets another package's default for the same object, an environment's stored default, or a second default in its own manifest, `DEFAULT_RECORD_PAGE_CONFLICT` (422, exported as `DEFAULT_RECORD_PAGE_CONFLICT_CODE`). Two packages' pages under one name are two pages and are refused too. `OS_METADATA_COLLISION=warn` does not soften it. + - **Metadata save** (`@objectstack/metadata-protocol`'s `saveMetaItem`, so `PUT /api/v1/meta/page/:name`) — a page saved as the default record page of an object a stored page or a packaged page already holds, `DEFAULT_RECORD_PAGE_CONFLICT` (422), in draft and publish mode, before anything is written. + + **Unchanged.** A save under the existing default's own name is that page (an ADR-0005 overlay by name), not a second default. One default per object, a second record page that is not a default, and two objects with one default each are accepted. The same package registering again — a reload, or an upgrade that moves its default to another of its pages — is one holder. A save under the name of a page a package ships is still answered by the packaged-base refusal (`NOT_OVERRIDABLE`, 403), as before. A stored page reaching the registry from `sys_metadata` (hydration, write-through) is not refused again. + + ## FROM → TO + + | door | FROM | TO | + |:--|:--|:--| + | `defineStack` / `objectstack validate` | `pages: [{ name: 'account_a', type: 'record', object: 'crm_account', isDefault: true }, { name: 'account_b', type: 'record', object: 'crm_account', isDefault: true }]` accepted; the boot kept both and the record view opened whichever registered first | refused, `STACK_CROSS_REFERENCE_INVALID` 422 — set `isDefault: false` on all but one | + | `composeStacks` | two stacks each shipping a default record page for one object composed clean | refused in the cross-reference envelope, naming both stacks' pages | + | package registration | a second package (or a plugin's `manifest.register`) shipping a default record page for an object another package holds registered; init order decided the page | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, naming both pages; keep one default, or compose the deployment without the package that ships the other | + | metadata save | `PUT /api/v1/meta/page/my_page` with `isDefault: true` for an object that already has a default record page stored or shipped saved | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, naming both pages; save it with `isDefault: false`, or first demote the stored default | + + **The one-line fix: keep `isDefault: true` on the one record page per object that should open the record view, and set `isDefault: false` on every other.** To replace a page a package ships, compose the deployment without that package's page; shipping a second default is not an override path. + + No D2 conversion rewrites it: which page stays the default is the author's call. Its D3 record is the semantic entry `page-default-record-page-one-per-object-refused` (protocol 18). + + **`@objectstack/spec`.** `defaultRecordPageObject(page)` (exported from `@objectstack/spec/ui`) is the one reading of "which object is this page the default record page of" that the three doors share. `PageSchema.isDefault` gains its `.describe()`. `DEFAULT_RECORD_PAGE_CONFLICT` joins the error-code ledger under `@objectstack/objectql` and `@objectstack/metadata-protocol`. + + **`@objectstack/objectql`.** The registration refusal, `SchemaRegistry.defaultRecordPageHoldersOf(object)` (every page holding the object's default — the reading the save door meets with the stored pages) and the `DEFAULT_RECORD_PAGE_CONFLICT_CODE` constant. + + **`@objectstack/metadata-protocol`.** The save-door refusal. + + **`@objectstack/platform-objects`.** The page form's `isDefault` help text, which said "Set as default page for this page type" — a rule nothing enforced — now states the one-default-per-object rule, in all four locales. + + **Who is affected, measured.** In this repository every object with a default record page has exactly one: `sys_organization` and `sys_user` (`@objectstack/plugin-auth`), `sys_position` (`@objectstack/plugin-security`), `sys_approval_request` (`@objectstack/plugin-approvals`), and `showcase_project`, `showcase_task` and `showcase_preference` (`examples/app-showcase`). A deployment that also composes a package shipping its own default record page for one of those objects is refused at boot until one of the two pages stops being a default. +- 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. +- 24e064f: fix(objectql,service-messaging,service-automation,plugin-approvals)!: a notification carries the organization of what it is about, and one no organization owns is refused instead of stored with `organization_id` NULL + + Clause-②: no (narrowing) — notification rows gain the organization they should already carry; what narrows is an emit that names no organization on an install where none is derivable, which the engine now refuses where it used to store the event row with `organization_id` NULL. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Every notification emitted from a system-context run — a scheduled flow, a record change written by a system context, an approval node — was stored in `sys_notification` with `organization_id` NULL, so reports and cleanup that filter by organization did not see it. Each emit also logged a `[notify] no organization in scope` warning. On an install holding several organizations, the deliveries of the same emit were refused while the flow step reported success, so nothing reached the inbox. + + **Who owns a notification now:** + + - **The run's organization.** A run that carries one emits exactly as before. + - **Else the record it is about.** A `notify` node in a run with no organization reads the organization of the record the notification is about. That is the declared click-through record (`sourceObject` + `sourceId`), else the run's trigger record. It is read from the stored row by id, never from the record snapshot the run carries. + - **Approvals.** Every approval notification carries its request's organization: `approval.requested`, reminders, comments, send-backs, reassignments, escalations and out-of-office notices. + - **Else the owner the engine derives for a system write** (ADR-0131 D9). `sys_notification` is now in the platform-object tenancy ledger, so an emit that names no organization gets the install's sole organization under the `single` posture. + - **The rows below follow the event.** `emit()` stamps the stored event row's organization on its delivery, inbox row and receipt, on both the inline and the outbox path. + + **Refused now:** where no organization is derivable (a walled posture, or a `single` install holding more than one organization), an emit that names none is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. Nothing is written for it. A `notify` step that hits the refusal fails with `notify failed: ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED: …` instead of succeeding. `MessagingService.emit()` rejects with the engine's error for any other producer. + + **What to do when it refuses:** give the notification an owner. Name the record it is about on the `notify` node (`sourceObject` + `sourceId`), declare the scheduled flow's acting organization (`organization` on its start node), or pass `organizationId` to `emit()` from your own code. + + The `[notify] no organization in scope` warning is gone, because the NULL row it predicted is no longer written. Rows already stored with `organization_id` NULL are not rewritten here. The v18 organization-ownership ceremony attributes them from their source record. +- eeb95f7: fix(spec,objectql)!: a permission set whose body `packageId` disagrees with the package that ships it is refused at `defineStack` / `os validate` / `os build` and at package registration, and every reader takes a set's owner from its binding only (ADR-0086 D3) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing on a published authoring surface), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0086 D3 declares `PermissionSetSchema.packageId` as the owning package of a package-shipped set, absent for an environment-authored one. The owner every reader uses is the binding: the package that ships the set or that it is saved into, served as `_packageId`. The metadata save, restore and publish doors already refuse a body `packageId` that disagrees with it. Package source and package registration did not: a set whose body named another package was registered under the package that shipped it, with no refusal and no warning, so an author, an AI build above all, never learned that the owner it wrote was not the owner it got. + + **What is refused, and where.** One judge, `judgePermissionSetOwnerBinding` (new export of `@objectstack/spec/security`), serves every door: + + - **The stack schema** (`ObjectStackDefinitionSchema`), so `defineStack`, `objectstack validate`, `objectstack build`, the lint scorer and an artifact load all refuse a set whose body `packageId` is not the id of the package that ships it: the stack's `manifest.id`, each `packages[]` body's own `id`, and `manifest.id` for a top-level set no body declares. A stack with no `manifest` ships its sets from no package, so any key there is refused, the empty string included. The issue sits at the set's `packageId` (`permissions.0.packageId`); `defineStack` throws `STACK_SCHEMA_INVALID` (422) carrying it. + - **Package registration** (`ObjectQL.registerApp`, the manifest and a nested plugin, for a manifest no schema parse judged) throws `400 VALIDATION_ERROR` with `issues[]` at `packageId` before the set is filed, the envelope the same seam throws for a view container whose `name` disagrees with its key. + + The message prescribes first, names both values, and offers both remedies: drop the key, or ship the set from the package it names. No error code is minted. + + **Readers.** `@objectstack/plugin-security`'s packaged-set lock (`classifyPackagedPermissionSet`) and `@objectstack/cli`'s permission-set name-collision report take a set's package from its binding only. A set filed into no package whose body named one is no longer locked as that package's, and a stack with no `manifest` declares no owner for the collision report. + + **Unchanged.** A body `packageId` equal to the package that ships the set is accepted, and an absent one is accepted everywhere. A top-level copy of a set a `packages[]` body declares (an artifact built with both halves) is judged on its body, not against the artifact's own id. The metadata save, restore and publish doors answer in the same words as before (`@objectstack/metadata-protocol` now calls the shared judge). Stored `sys_metadata` rows are not rewritten. + + ## FROM → TO + + | door | FROM | TO | + |:--|:--|:--| + | `defineStack` / `objectstack validate` / `objectstack build` | `defineStack({ manifest: { id: 'com.acme.crm', … }, permissions: [{ name: 'crm_member', packageId: 'com.acme.other', … }] })` accepted; the set was owned by `com.acme.crm` | refused at `permissions.0.packageId`, `STACK_SCHEMA_INVALID` 422 | + | `ObjectQL.registerApp` | the same manifest registered the set under `com.acme.crm`, silently | `400 VALIDATION_ERROR`, `issues[]` at `packageId`, nothing filed | + | an artifact built before this change | loaded, the set owned by the shipping package | refused at load; rebuild it without the key | + + **Remedy, in one line.** Drop `packageId` from the permission set in your stack source (the package that ships a set is its owner), or move the set into the stack of the package it names, then build again. +- 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. +- d050a8f: fix(spec,objectql)!: an installed or stored page carrying a declaration spec 18 retires converts at load instead of stopping the kernel build — `isDefault` off a bound record page is deleted and `template: 'full-width'` re-spelled `default`, each with a named notice; the authoring, save and publish doors keep refusing + + Clause-②: yes (widening) + + **What changes.** Protocol 18 refused `isDefault: true` on a page that is not a record page bound to an object, and closed the page `template` set (retiring `full-width`), at every door — the page parse (`definePage`, `defineStack`, so `os validate` / `os build`, and the metadata save door) and the kernel's package registration (`SchemaRegistry.installPackage` / `registerItem`, `MisplacedDefaultPageError`). The registration refusal also fired when a kernel was built from a package that was installed BEFORE the retirement: every hosted environment holding such a package failed its kernel build, with no remedy short of upgrading the package in each one. Data at rest now converts, and only data at rest: + + | Door | Before | Now | + | :--- | :--- | :--- | + | `definePage` / `defineStack` (`os validate`, `os build`), the metadata save door, package publish | refused at `isDefault` / `template` | **unchanged** — refused with the same sentence | + | a `sys_metadata` page row read back | served with `_diagnostics.valid: false` | converted on read: the flag deleted, `full-width` → `default`, `OS_METADATA_CONVERTED` notice | + | a compiled artifact inside the window its `engines.protocol` opens | the artifact door refused the artifact | converted before the strict parse, as every retired entry is | + | a package body registered through `manifest.register` / `ObjectQL.registerApp` that declares `engines.protocol` (an installed package, a marketplace install) | `MisplacedDefaultPageError`, kernel build stopped | converted before anything registers, with one `warn` per (package, conversion) naming the conversion id and the first site; the body is left unchanged on disk | + | the same body with NO declared range (a manifest a plugin builds in code), or declaring a floor newer than every retirement | refused | **unchanged** — refused | + + **The two ADR-0087 D2 entries**, both `retiredFromLoadPath` from the day they land (the authoring funnel never replays them): + + - `page-is-default-off-record-page-removed` (`page.isDefault`): deletes `isDefault: true` from exactly the pages `misplacedDefaultPageRefusal` refuses — a `home`, `app`, `utility` or `list` page, or a record page with no `object`. Lossless: nothing read the flag there (an app opens on its first navigation item). A default record page bound to an object keeps its flag. + - `page-template-full-width-to-default` (`page.template`): `'full-width'` → `'default'`. Lossless: the console drew both names with one template component. An unregistered template name (`centered`, `two-column`) and a region the template does not place have no lossless rewrite and are NOT converted — they stay with the parse, and the D3 entries `page-is-default-off-record-page-refused` / `page-template-closed-region-unplaced-refused` name the hand edits. + + **`@objectstack/objectql`** — `ObjectQL.registerApp` (and the `manifest` service, for an artifact carrying `packages[]`) replays the versioned forward conversion (`applyArtifactForwardConversions`, `@objectstack/metadata-core`) over a package body that declares its protocol range, before `installPackage`; the converted copy is what the package record and every registered item carry. Public: `ObjectQL.convertPackageBodyForward(body)`. + + **`@objectstack/metadata-core`** — `applyArtifactForwardConversions` reads the declared range off a FLAT package body (the manifest half beside its collections, no `manifest` key) when the definition carries no `manifest`; a definition with a `manifest` object is read there, as before. + + Migration: none for authors — the authoring doors refuse exactly what they refused. An installed package converts again at every registration until it is rebuilt (`os build`) and re-published with tooling from a release that ships the retirement; the boot log's warn names the conversion and the site. + + +- b25d98a: Clause-②: yes (widening) + + On insert, `previous` is now bound to `null` in validation rules and in declarative hook + conditions, so a rule or condition that reads it decides the insert — the binding a flow's start + condition already gets on its create leg. Until now `previous` was unbound on insert: any + reference to it faulted `Unknown variable: previous`, and the write was refused (validation) or + aborted (hook) as `unevaluable`. So this widens what an insert can pass: a rule shaped + `previous != null && …` ("judge updates only") now ADMITS the inserts it used to refuse with that + fault, and a field's `requiredWhen: previous == null` now requires the field on insert instead of + refusing every insert. `previous == null || …` judges an insert, `previous != null && …` skips + one. A column read off `previous` on insert (`previous.stage`) still has no value and still + refuses the write; the refusal now names that read and the guard to add. + + A violating insert under a rule that reads `previous` now answers with the rule's own message. + The platform's own `validity_window_order` on `sys_user_permission_set` is the measured case: an + inverted grant window answers `400 VALIDATION_FAILED` with "Valid Until must be later than Valid + From …", not a CEL fault. + + The binding is the same for every predicate a write evaluates — a `script` / `cross_field` + condition, a `conditional`'s `when`, a field's `requiredWhen` and an option's `visibleWhen` — + and for a hook's `condition` on `beforeInsert` / `afterInsert`; the validate-only preview judges + an insert the same way. Only the CEL binding is `null`: a hook's `ctx.previous` is unchanged. No + shipped rule's verdict changes: every rule and hook this repository ships that reads `previous` + refused and admitted the same inserts before and after; only the refusal's reason moved. A write + to an existing row whose prior state is not in hand keeps `previous` unbound and still fails + closed, so `previous == null` never reads an update as an insert. +- 66d2757: feat(spec)!: every installed-package row declares where it came from, a closed `source: 'code' | 'marketplace' | 'authored'`, and the `GET /packages` rows declare the `writable` verdict they already served (#23972) + + Clause-②: yes (narrowing — the served row gains two declared keys, and the record schema, the served-row union and the registry's installPackage signature each refuse what they accepted before) + + **BREAKING** — three published accept sets narrow. `InstalledPackageSchema` refuses a row without `source`, or with a value outside `code` / `marketplace` / `authored`. The served-row union `InstalledPackageAtEitherStageSchema` refuses a row without a boolean `writable`. `SchemaRegistry.installPackage` and `EngineSchemaRegistryView.installPackage` take `source` as their second argument and refuse a call without it. The migration is below. None of it is mechanically convertible, because only the door that registers a package knows where it came from. + + **What is new on the wire.** `GET /api/v1/packages` and `GET /api/v1/packages/:id` serve two declared keys on every row: + + - `source` names the package's origin. `code` means the deployment's own code registered it (a `defineStack()` host, a plugin's bundled manifest, a platform package). `marketplace` means the marketplace install-local plugin installed it, and that holds after a restart too, when the plugin rehydrates it from its ledger. `authored` means it is a database-authored package (a Studio base, a duplicate, an HTTP install), including after the restart that rehydrates it from `sys_packages`. The door that registers the package stamps the value. No reader derives it. + - `writable` is the server's own writability verdict (ADR-0070 D2), served on these rows since #14375 and now declared. It is computed per read and never stored, so it is declared on the served row (`InstalledPackageAtEitherStageSchema`) and not on the registry record. + + FROM → TO for a reader of these rows: + + | you did | do instead | + | --- | --- | + | treated `writable: true` as authored, and looked up each non-writable row in the marketplace's installed list (the control plane's, else install-local's) to tell marketplace from code | read `row.source` | + | read the undeclared `writable` | read the declared `writable` (unchanged value) | + + The two keys are separate statements: `source` says where the package came from, and `writable` says what this server lets an org do to it. + + **`InstalledPackageSchema` requires `source`.** It is `InstalledPackageSourceSchema`, exported from `@objectstack/spec/kernel`. A row without it, or with a value outside the closed set, is refused at the `source` key. + + **API change for code that registers packages (`@objectstack/objectql`).** The registry records the origin and never infers it: + + | you wrote | write instead | + | --- | --- | + | `registry.installPackage(manifest)` | `registry.installPackage(manifest, 'code')` (or `'marketplace'` / `'authored'`, whichever is where the package came from) | + | `registry.installPackage(manifest, settings, scope)` | `registry.installPackage(manifest, source, settings, scope)` | + | `ctx.getService('manifest').register(artifact)` for a marketplace install | `ctx.getService('manifest').register(artifact, { source: 'marketplace' })` | + + `SchemaRegistry.installPackage` throws a `TypeError` naming the closed set when `source` is missing or unknown, before anything is recorded. `ObjectQL.registerApp(body, scope?, source?)` and the `manifest` service record `'code'` when no source is given. They accept `'code'` or `'marketplace'` and refuse `'authored'`. The `IObjectQLEngine` registry contract (`@objectstack/spec/contracts`) declares the same `installPackage(manifest, source, settings?)`. + + **The one-line fix:** pass the package's origin as `installPackage`'s second argument. + + Readers: objectui's Setup → Packages Source column and its `?source=` filter (it switches from the two-read join to `source` in its own follow-up), and the client SDK's `packages.list()` / `packages.get()` row types. + + +- d537a31: feat(objectql)!: a formula field reads one hop through a reference field — `record.book.title` on a loan now reads the book's title instead of `null` + + Clause-②: no (narrowing) + + + + The read path (`find`, `findOne`, and the record a create or update hands back) now preloads the related fields a formula names ONE hop through a lookup or master-detail field, before it computes the formula. A junction object's composed title, `record.book.title + ' · ' + record.reader.name`, shows both names. + + - **How it reads.** One batched read per reference field per page of rows, never one per row, naming only `id` and the related fields the formulas name. The loader is the one validation rules already use for `record.LOOKUP.FIELD`. The row a read returns still holds the foreign key; only the formula sees the related record. + - **As whom.** The hop is read under the reader's own permissions (row-level and field-level security, the organization wall). A related record the reader may not see reads as not found, and a related field it may not see reads as empty, so a formula never shows the reader a value they could not read directly. + - **When there is no related record.** A reference to a record that is not found, or that the reader may not read, leaves the formula null and reports why, naming the related object and field. An empty reference is evaluated: write `record.reader.?name.orValue('—')` to give the formula a value while the reference is empty. + - **One hop only.** A second hop (`record.book.shelf.code`) and a reference read both through and as a plain value are not evaluated: the field reads null and the reported reason uses the same wording the lint uses. A formula on the related object that reads a hop of its own reads null on a hop read, so two objects whose titles read each other terminate. + - **Sorting and search.** A formula value is stored nowhere, so a formula title still cannot be sorted by (`400 INVALID_SORT`) or searched, as before. + + What to do. Nothing for a formula that reads its own fields. A formula that reads through a reference now shows a value. One that reads two hops should read a field stored on the related object instead. +- 1bf5f6e: feat(objectql): evaluate a formula field over a record in hand the way a read serves it, one hop through a reference included + + A formula title over lookups (`record.book.title + ' · ' + record.reader.name`) is computed on every read since the formula read path reads one hop. A record no read returned, such as the state a hook fires on, had no way to get the same value: the synchronous `evaluateFormulaField` reads no hop, so it answered `null`. + + - **`ScopedContext.serveFormulaField(objectName, record, field)`** evaluates one declared formula field over `record` with the read path's own evaluation, under that context: the one hop it reads through a reference (one batched read per reference field, through the reader's own permissions, on the context's transaction when it carries one), the `can()` permission map, and the fault report. A related record the reader may not read, a missing one and a second hop leave the field `null` with the read's reason, as a read does. `ObjectQL.serveFormulaField(objectName, record, field, context)` is the engine method behind it. + - **`resolveServedRecordTitle(schema, record, channel)`** is the async sibling of `resolveRecordTitle`: this record's title, with a formula title served on `channel` (a `ScopedContext`). `resolveRecordTitle` is unchanged and still reads no hop. + - **`servedRecordTitle(schema, row)`** reads the title a read already served off the row it returned, instead of re-deriving it. + + What to do. Nothing. Code that names a record it holds in hand and wants the title a read shows calls `resolveServedRecordTitle` with the caller's `ScopedContext`. +- ec749fb: feat(objectql)!: a `search` over an object with no searchable field is refused with `400 INVALID_FIELD` instead of answering every row + + Clause-②: no (narrowing) + + + + A search term is matched against the object's resolved search fields: its `searchableFields` declaration (entries that name a stored field), or else the default set (the name/title field and the text, email, phone, url, autonumber, textarea, markdown and select fields). On an object where that set is empty, such as a junction object made of two lookups and a formula title, the term used to add no filter, so `?search=zzzz-nothing` and `?search=Dune` returned the same rows, and a user read every row as a match. + + - **What changes.** `find`, `findOne`, `count` and `aggregate` answer a non-blank `search` over an empty resolved set with `400 INVALID_FIELD` (`param: 'search'`). That covers every door that reaches the engine: the REST list and query routes (and the list's `total`), the grouped read, the dispatcher, the SDK and a hook's `ctx.api`. The message names the object, says why the set is empty, and gives the two remedies. + - **Unchanged.** A blank or whitespace term is not a search and still answers every row. An object with a searchable field filters as before. An explicit `searchFields` naming a field outside the set keeps its own `400 INVALID_FIELD` at the data door. Global search (`GET /api/v1/search`) still passes over an object with nothing to scan and answers `200`. + + What to do. If a read now answers `400 INVALID_FIELD` with "has no searchable field", declare `searchableFields` on that object naming a stored field, or mirror the composed title onto a stored text field and search that. A client that offers a search box on such an object should hide it, or show the refusal. +- fa26308: fix(objectql,runtime): a hook whose `handler` names a function gets `ctx.title(field?)` in-process, as an inline handler and a `body` do + + Clause-②: yes (widening) + + - **What was wrong.** A hook whose `handler` is a string naming a function ran in-process without `ctx.title`: `defineStack({ functions })` plus a hook naming an entry on a source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over a config), and a string `handler` resolved from the artifact's `runtimeModule` (what `objectstack build` ships for a callable it could not lower to a `body`). The engine's binder resolved the name after the runtime had wrapped the inline handlers, so the function was called with the bare engine context and `await ctx.title()` threw `TypeError: ctx.title is not a function`. + - **What changed.** `bindHooksToEngine` (`@objectstack/objectql`) takes a `wrapHandler(handler, hook)` option, applied at bind to every handler a hook runs in-process, after a string `handler` resolves: an inline function, a name from the bind's `functions`, or a name the same package registered on the engine. A `body`'s handler (built by `bodyRunner`) never takes it. `ObjectQL.bindHooks` and the `IObjectQLEngine.bindHooks` contract (`@objectstack/spec`) carry the option. `bindAppArtifactHandlers` (`@objectstack/runtime`) passes the existing `withInProcessHookTitle` through it, in place of wrapping inline handlers before the bind, so the inline and the named handler take one wrap. + - **Unchanged.** A bind that passes no `wrapHandler` binds every handler exactly as resolved, so a host or harness that calls `bindHooks` itself, and a hook registered with `ql.registerHook(...)`, are not handed `ctx.title`. `HookContext` still declares no `title`. +- 2f569a9: fix(objectql)!: an `objectExtensions` entry no longer adds platform system columns to the object it extends, so an extension of `sys_organization` or `sys_user` no longer hides the record from its own organisation + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The registry ran the injected-system-columns plan on every contributor, an extension body included. An extension body carries none of the keys the plan reads (`managedBy`, `systemFields`, `tenancy`, `ownership`), so it was planned as an ordinary object, and the merge added `organization_id`, `created_by` and `updated_by` (and on a non-`sys_` target, `owner_id` and `owning_business_unit_id`) to a target whose own declaration withholds them. On a multi-tenant deployment an extension of `sys_organization` or `sys_user` (`managedBy: 'better-auth'`) therefore created an `organization_id` column that is NULL on every row, and the tenant wall then answered a member's list of their organisation and of the users with `[]`, and their own records `404 RECORD_NOT_FOUND` by id. The injected definition also replaced a column the target declares itself. + + Now only a base layer (the owner, or a tenant overlay) is planned. The extended object's system columns are its base layer's, in whichever order the two packages register. + + - **Served less now:** an extended object whose declaration withholds a system column no longer reports or stores it. This covers `managedBy: 'better-auth'`, `systemFields: false`, `tenancy.enabled: false` and an `ownership` other than `user`. For example, `sys_sso_provider` (which plugin-auth extends with `oidc_client_secret`) no longer carries `created_by` or `updated_by`, and its `organization_id` is again the `text` column the object declares instead of the platform's read-only lookup. + - **Unchanged:** the extension's own fields, labels, validations and indexes merge exactly as before. An audit field an extension declares (`created_at`, `created_by`, `updated_at`, `updated_by`) is still forced read-only wherever the target carries the audit family. An extended ordinary object carries the same system columns, which come from its owner. No existing column is dropped from a database: a column an earlier release created stays in the table, and the registry no longer declares it. + + What changes for you: nothing to rewrite. If code read `organization_id`, `created_by` or `updated_by` on an extended `sys_*` identity object, it read a NULL column that the object never declared. Read the owner's own columns instead. +- 69147ab: fix(objectql)!: the one-hop read through a reference never reads a stored-metadata-body table, and the lint refuses that read where it is written + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The tables of the stored-metadata-body family (`STORED_METADATA_BODY_OBJECTS`) are projected or refused at every door that serves them. The one-hop read a formula field and a validation rule share (`resolveOneHopRelated`) was an ordinary engine read, which the family's projection does not reach. + + - **`@objectstack/objectql`.** A hop whose target is a family table is not read, whichever authority the hop is read under. A formula field reading through it is `null` with its reason, on every read, write response and `serveFormulaField` call (the seam a hook body's `ctx.title()` reads through). A validation rule reading through it has no verdict, so the write it judges is refused. The answer is the one an unreadable related record gets, so it says nothing about the row. A hop into any other object is unchanged. + - **`@objectstack/lint`.** A formula field's `expression`, or a validation rule's hydrated `condition`, that reads through a reference into a family table is an error, with the prescription. `os validate`, `os build` and the object save door run the same pass, so the author learns it at save rather than from a `null`. + + What to do. Remove the read through the reference. Stored metadata is read through the metadata API (`GET /api/v1/meta/:type/:name`), where its body is projected. Reading the reference field itself (the stored id) is unchanged. +- cf16a0d: fix(objectql)!: `expand` and the master-detail `parent` root never hydrate a stored-metadata-body table + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The tables of the stored-metadata-body family (`STORED_METADATA_BODY_OBJECTS`) are projected or refused at every door that serves them, but only for the object a request names. Two relationship hydrations in the engine read a family row as an ordinary row, which that projection does not reach. + + - **`expand`.** A reference field whose target is a family table is not expanded, at any depth and for any caller: the field keeps its stored id, the answer a reference the caller may not expand already gets. This covers `expand` / `$expand` on the data read, `findOne`, the nested query form and a hook body's `ctx.api` read. Expanding a reference into any other object is unchanged. + - **The master-detail `parent` root.** A detail whose master is a family table never reads its header. `parent` is unbound, so a `requiredWhen` or `readonlyWhen` that reads it fails closed exactly as it does for a header the caller cannot read, on insert, update and the `validate()` preview, and the verdict never depends on the row. A master outside the family is unchanged. + + What to do. Read stored metadata through the metadata API (`GET /api/v1/meta/:type/:name`), where its body is projected, rather than by expanding a reference to it or reading it as a master-detail header. The stored id on the reference field is unchanged. +- fb0d85b: feat(spec,objectql)!: a top-level action bound by `objectName` to an object another package owns is listed on that object wherever it is served, and a bound action whose object no loaded package registers fails the boot + + Clause-②: yes (narrowing) + + + + **BREAKING** (one narrowing beside a widening), shipped as `minor` under the launch-window convention for breaking changes. + + - **Widening** (`@objectstack/spec`, `@objectstack/objectql`): an action declared in a stack's top-level `actions` with `objectName` naming an object that another package owns now reaches that object. `defineStack()` accepts the action when the stack extends the object through `objectExtensions`, or when the object is a known platform object (`sys_organization` and the rest of the platform's named list). It keeps the action in the top-level `actions`. The engine's schema registry then attaches it to the object at the one step every object read ends in. That covers `GET /api/v1/meta/object/` (stored-row, MetadataService and registry layers alike), `engine.getSchema()` and the action router's object lookup, in whichever order the two packages register. The attached entry is the action's own declaration, so its `requiredPermissions`, `requiresMembershipReach` and execution identity are unchanged. An action the object already declares under the same name keeps the object's declaration. A disabled package's bound actions are not attached. Before this, such an action was refused by `defineStack()` whenever the stack declared an object of its own. Where it got through (an object-less stack, or `strict: false`), it registered but was listed on no object, so no client rendered it. + - **FROM** (`@objectstack/objectql`): a top-level action bound by `objectName` to an object no loaded package registers was registered and listed nowhere, and the boot succeeded. **TO:** the boot fails at `kernel:ready` with `INVALID_METADATA` (422), naming each such action, its package and the object. An artifact registered through the `manifest` service after the boot is refused before any of it registers. **Fix:** register the object (in the package or in one it depends on), or correct the action's `objectName`. + - **Unchanged:** a name `defineStack()` cannot attribute (an object this stack neither defines nor extends, and not a known platform object) is still refused at build with `STACK_CROSS_REFERENCE_INVALID`. A same-stack bound action is still merged into its object by `defineStack()` and is attached once. + + The `ObjectExtensionSchema` refusal for `actions` now says exactly this: declare a top-level action with `objectName: ''` in the same stack; the schema registry attaches it to the object wherever the object is served; a target no loaded package registers fails the boot. + +### Patch Changes + +- 913fd6d: fix(objectql): a validation rule reading a field its reference's target does not declare no longer refuses every write through the other rules reading the same reference + + Clause-②: no + + A write loads, once per page, every column the object's validation rules read through each reference. When one rule named a field the target does not declare, the whole reference was answered as undeclared. Every rule reading through it then had no verdict and refused the write, each naming the other rule's field. + + - The undeclared fields are left out of that shared read and judged for each rule on its own. + - A rule that names one has no verdict and refuses the write, naming only its own undeclared fields. + - A rule that reads only declared fields through the same reference gives the verdict it gives alone: it admits what it admits alone, and refuses with its own message. + - The related read keeps its authority and its bound; an undeclared field never enters it. + + What to do. Nothing. To find the rule at fault, re-save the object: the save door refuses the misspelt read and names the field. +- de43524: fix(objectql): a bound action named like an action its target object already lists, with a different declaration, is reported at boot naming both packages; `registerAction` reports replacing another owner's handler + + Clause-②: no (a warning only: no accept set narrows, no door answer moves, and no published surface gains a key; owes the seat's review of record before the queue) + + - **The listing.** A top-level action bound by `objectName` to an object that already lists an action of the same name is still not attached: the object keeps serving its own declaration, as the action router does. When the two declarations differ, the boot now warns once per pair, at `kernel:ready`, naming the object, the action, the package whose declaration is served and the package whose declaration is not listed. Two packages binding different declarations of one name to a third package's object are reported the same way (the first one attached is served). + - **Not a collision.** One declaration carried twice is compared without its provenance stamps (`_packageId`, `_provenance`, the `_lock` envelope, `protection`) and without `objectName`, and is not reported: a same-stack bound action merged onto its object, or the same declaration shipped by two packages. + - **Later artifacts.** An artifact registered after the boot (a marketplace install, a ledger rehydrate) is judged once it has registered, for the pairs naming one of its packages; a pair the boot already reported is not reported again. + - **The handler.** `ObjectQL.registerAction` keys handlers by `OBJECT:ACTION`. A registration under a key another owner holds still replaces it (the last registration runs), and now warns, naming both owners: the action door runs the new owner's handler whichever declaration the object lists, and withdrawing that owner leaves the action with no handler. The same owner re-registering is silent, and so is the authored-action resync, which tears its set down first. A registration with no package is its own owner. + - **Unchanged.** Nothing is refused and no boot fails on it; the served `actions`, the router's precedence and which handler runs are as before. Measured on stock CRM, showcase and todo boots: no new warning. + - **Fix.** Rename one of the two actions, or drop the duplicate declaration. +- 04669a4: fix(objectql): a `runAs: 'user'` hook now sees the acting user's organization members in `sys_user`, as the same user's own request does + + A hook declaring `runAs: 'user'` is pinned to the triggering user. When the triggering write carried a hand-built system context such as `{ isSystem: true, userId, tenantId }` (the approvals writes are the shipped producers), the pinned context carried no `org_user_ids`, the peer list the request path resolves for every user. The shipped `sys_user_org_members` policy (`id in current_user.org_user_ids`) therefore dropped out, and the hook's `ctx.api` read of `sys_user` returned the acting user's own row only, while the same user's request returned their organization's members too. + + The hook wrapper now seeds the pinned context through the resolver the request path uses (`resolveUserAuthzGrants` from `@objectstack/core`): + + - The peer list is the members of the context's organization (`tenantId`) plus the acting user. A context with no organization gets the acting user alone, which is what an organization-less request gets. A user who belongs only to other organizations is never in it. + - A context with no `permissions` also takes the user's resolved positions and permission sets, so the hook acts with the user's real grants rather than the security plugin's member baseline. Positions and permission sets the context already carries stay exactly as they were, so a narrowed ceiling is not re-broadened; only the peer list is added. + - A context that already carries a peer list, such as a request-resolved one, resolves nothing and is handed through as before. + - If the resolver fails, the hook runs on the unseeded pin, as before, and a warning says so. Policies that read `org_user_ids` then drop out: the hook sees less, never more. + + `runAs: 'inherit'` (the default) and `runAs: 'system'` are unchanged, and a `runAs: 'user'` hook whose trigger names no user is still refused with `HOOK_UNSCOPED_DATA_ACCESS`. A `runAs: 'user'` hook fired from a context that lacks either list now pays one grants resolution per dispatch. + + Clause-②: no (a runtime producer fix: a `runAs: 'user'` hook reads what the same user's own request reads. Nothing `@objectstack/spec` declares moves; owes the seat's review of record before the queue) +- 7879391: fix(objectql): a `date` dimension buckets on its own calendar day under every timezone on the in-memory path, as the SQL pushdown path already did + + Clause-②: no (no authorable key, export or signature moves; the in-memory answer for a `date` dimension under a non-UTC zone now equals the stored day, which is what the pushdown path answers) + + - **A `Field.date` is a calendar day, not an instant (ADR-0053).** When `engine.aggregate` buckets in memory (any non-UTC `timezone` forces that path), a `groupBy` item with a `dateGranularity` on a field the object declares a `date` is now labelled by that field's own `YYYY-MM-DD`, with no zone. Before, the value was read as that day's UTC midnight and placed on the requested zone's calendar, so in every zone west of UTC each row landed one day early: `2026-10-01` bucketed as `2026-09-30`, and month, quarter and year totals moved across their edges (`POST /api/v1/analytics/query` with `timezone: "America/New_York"` answered `{2026-09: 1, 2026-10: 5, 2026-11: 4}` for six October and four November deliveries). + - **A `datetime` keeps the zone.** An instant still lands on the requested zone's calendar day, unchanged. So does a field the declaration cannot type: `applyInMemoryAggregation` called without its `fields` argument, an undeclared field, or a `formula`. + - **The value is read by the `date` storage rule first** (`temporalStorageForm(value, 'date')` in `@objectstack/core`): a bare `YYYY-MM-DD` is itself, and a `Date` handed back by a driver is its UTC calendar day. +- 95a8435: feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted + + Clause-②: no + + `SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served. + + A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run. + + In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `CAPABILITY_NAME_COLLISION` + - `capabilityNameCollisionDiagnostic` + - `formatCapabilityNameCollisionDiagnostic` + - `reportCapabilityNameCollisions` + - `CapabilityNameCollisionDiagnostic` (type) + + FROM → TO: + + - FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`. + - FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place. +- 6729e10: feat(plugin-audit,plugin-security)!: the compliance ledger `sys_audit_log` loses its injected organization column; the organization a row is about stays in `tenant_id`, and an organization reader is scoped on it by a platform row policy (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + + Some ledger rows are about deployment-level actions no organization owns: a change of platform-administrator standing at boot, a change to a global setting, plugin-auth's administrative user writes. An injected organization column made the tenant wall the ledger's anchor, so under a walled posture those rows were hidden from every reader, platform administrators included. ADR-0131 D7 takes the column off: the ledger is governed by object permission, and the organization a row is about is the plain attribution field `tenant_id`, which the tenant-field resolver does not claim. + + - **`sys_audit_log`** (`@objectstack/plugin-audit`) declares `systemFields: { tenant: false }`, so the registry injects no `organization_id` and a new table is provisioned without it. `tenant_id` (a lookup to `sys_organization`) is unchanged and is the only organization column. The record mirror, the record-view writer and the sign-in writer stamp it as before and no longer stamp `organization_id`. A write that still names `organization_id` on the ledger is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. + - **The read scope** (`@objectstack/plugin-security`, the shipped permission sets): a platform row policy, `sys_audit_log_org` (`tenant_id == current_user.organization_id`), in `organization_admin` (and its no-bypass variant), `member_default` and `viewer_readonly`. `organization_admin` also names `sys_audit_log` explicitly, read only and without `viewAllRecords` / `modifyAllRecords`: its wildcard's superuser bypass would otherwise skip the policy on an object with no tenant column. Under an organization wall an organization administrator or viewer reads the rows about its active organization; a platform administrator (`admin_full_access`) reads every row, the rows about no organization included. Under `single` the policy is stripped by provenance (ADR-0105 D3), as every platform tenant policy is. + - **`config_change` rows** (`@objectstack/service-settings`): a GLOBAL-scope settings change is about no organization, so its ledger row carries no `tenant_id`, whatever organization the writing session had active. Tenant- and user-scope changes keep the writer's organization. The settings writer and plugin-security's platform-admin standing writer no longer probe for or stamp `organization_id`. + - **Retention** (`@objectstack/objectql`): a tenant-scope `lifecycle.retention_overrides` window on `sys_audit_log` partitions the reaper's and the archiver's passes on `tenant_id`, and the global pass keeps the rows with no `tenant_id`. + - **`view_all_audit_log`**: its description (`@objectstack/spec/security`) now names the row scope it does not lift; the capability still lifts only the parent-record read gate. + + **What moves for consumers.** + + - **Authored references.** A filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` names `tenant_id` instead; the two held the same value on every row a writer wrote. + - **Who reads what, under a wall.** A platform administrator now also reads the rows about no organization, global settings changes included. An organization administrator reads exactly its active organization's rows. Under the `group` posture an organization reader is scoped to the ACTIVE organization's rows, not the union of its memberships. A permission set an application ships that grants `viewAllRecords` on the ledger (directly or through a wildcard) skips the row policy, as it skips Layer 1 on every object the wall does not cover. + - **Who reads what, under `single`.** The row policy is stripped under `single` (ADR-0105 D3), as every platform tenant policy is. So a deployment that holds more than one organization under `single` serves every organization's ledger rows to every ledger reader. The remedy is a walled posture. + - **Existing databases — nothing moves automatically** (ADR-0131 D14). Schema sync is additive: the physical `organization_id` column stays and the boot drift report names it orphaned. The v18 upgrade ceremony confirms its values equal `tenant_id` and drops it (`os migrate apply --allow-destructive`), reporting any row where they differ. +- 1920cf3: fix(objectql): the lifecycle reaper and archiver no longer partition an object with no tenant column by organization + + A tenant-scope `lifecycle.retention_overrides` entry gives one organization its own retention window, and the reaper and the archiver apply it by partitioning the object's rows on `organization_id`: one pass for that organization's rows, then a global pass for everyone else's. On an object that has no `organization_id` column — one declaring `systemFields: { tenant: false }`, such as the deployment-level platform tables (`sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal`, `sys_presence`), or any other object the registry injects no tenant column into and whose author declares none — both passes named a column the table does not have. The SQL driver refused them (`INVALID_FILTER`), the sweep reported the object in its errors, and the table's retention stopped. + + Such an object now has no tenant partition, the answer a federated object already got: the sweep runs its one global pass at the global window. No row of it belongs to an organization, so a tenant override naming it has nothing to select, and it is not applied. An object that has the column keeps its per-tenant windows unchanged. +- f85a83b: fix(objectql): a formula field that does not evaluate is logged once per object and field, instead of reading `null` in silence (#22019) + + A formula the engine cannot evaluate reads `null`, on `find`, on `findOne` and on the write response. Before this change nothing said why. A formula calling an unregistered function (`sqrt(record.amount)`) read `null` on every row with no log line anywhere. ADR-0032 says a call site must not silently swallow an expression fault. + + The engine now reports the fault through its logger at `warn`, once per (object, field) per engine instance, however many rows and reads hit it. The line names the object, the field and the evaluator's error (kind and first line; the full message is in the log metadata). It also says where the repair is: `os validate` or a re-save of the object refuses an expression-level fault with a located message, and a fault that depends on a record's values needs a guard on the operands it reads. + + Unchanged: the field still reads `null`, because what a read returns is protocol. `evaluateFormulaField`, the hook-side helper with no engine, still returns `null` without a log line. The built entry declarations gain three `private` member names on `ObjectQL`. +- c8d06a9: `skipAutomations` no longer skips ObjectQL's own audit stamps: a data import with "run automations & triggers" unchecked stamps `created_by` / `updated_by` from the session user again + + Clause-②: no + + `ExecutionContext.skipAutomations` suppresses the lifecycle hooks bound from metadata and, by its own + description, never bypasses audit. ObjectQL's builtin audit stamps (`sys_stamp_audit_insert` on + `beforeInsert`, `sys_stamp_audit_update` on `beforeUpdate`) were registered through the hook binder, + so they carried the metadata binding the opt-out keys on and were skipped together with the app's + hooks. Rows that a data import wrote with automations off landed with no `created_by` / `updated_by` + and no value in a declared plain `tenant_id` field. On a driver that does not stamp its own + timestamps, they also landed with no `created_at` / `updated_at`. The organization column was not + affected: the driver stamps it from the engine's driver options either way. + + The builtins are now registered in code (`registerHook`, no metadata binding), which is how the + contract describes audit. They run under `skipAutomations` exactly as they run without it, and they + keep the same wrapper, priority and `sys:audit` package id. Every hook bound from metadata is still + skipped under the flag. ⛔ Nothing you author changes: no key, option, export or type is added or + removed. +- 0b997ea: fix(objectql): a stack-declared position is registered under its package, so the metadata save door refuses a save over it like every other non-overridable security type + + Clause-②: no + + - **What was wrong.** `PUT /api/v1/meta/position/NAME` naming a position an installed package declares answered `200`, and the saved environment row then won the by-name read (`GET /api/v1/meta/position/NAME`). The type registry declares `position` `allowOrgOverride: false`, as it declares `permission` and `capability`, so the save door should refuse it. + - **Why.** The save door decides "a code package ships this item" from the engine's SchemaRegistry entry that a package registered. `ObjectQL.registerApp()` puts a stack collection into that registry under its package only for the collections it enumerates. That list carried `permissions` and `capabilities`, but still carried the retired `roles` instead of `positions`. So no package-declared position had an entry, and the save took the runtime-create path. + - **What changes.** `registerApp()` (and the nested-plugin seam) now registers a stack's `positions` under the owning package, with the same ADR-0010 provenance as its permission sets. A save over a package-declared position is refused `403 NOT_OVERRIDABLE` on every topology. A save that names the read-only package (`?package=`) is refused `403 ITEM_LOCKED`, which is how a save naming a read-only package is refused for any non-overridable type. The by-name read keeps serving the package's position. + - **What does not change.** A position no package declares still saves (`allowRuntimeCreate`). Permission sets and capabilities are refused as before. The declared-positions seeder in `@objectstack/plugin-security` now reads the stack's positions from the engine registry rather than the metadata service, as it does for permission sets. It seeds the same names, labels and descriptions. + - **To customize a packaged position,** create a position with a different name. +- e87070e: fix(objectql): a `find` / `findOne` whose `fields` names a formula field returns that projection, not every stored column + + Clause-②: no + + To evaluate a formula field, the engine widens the projection it hands the driver to every stored column of the object (CEL's `record.` reads whatever the formula needs off the full row). The rows were never cut back, so a projection that named a formula field returned every column: the tenant, owner, owning unit and audit columns, and every field the caller did not name. A flow's `get_record`, whose `config.fields` is declared as "only these fields are read", passed all of them on to its later nodes. + + Each row is now cut back to the caller's projection once the read is done: the columns the caller named, plus the formula's computed value. `id` is returned only when it is named. `driver-memory` and `driver-mongodb` add `id` to every projection at the driver layer, so on those two drivers a projection that names a formula field but not `id` no longer carries `id`, while the same projection without the formula still does: name `id` when you need it. The formula still sees the full row, and so do the `afterFind` hooks and the middlewares, as before. A key an `afterFind` hook derives is kept. + + Unchanged: a projection that names no formula field, and a read with no projection (every declared column, with the formulas computed). Field-level security is unchanged as well: a field the caller may not read was already masked off the widened row, and still is. +- c8c803c: Deleting a record no longer fails because of a record that the same delete removes. Before this fix, when a record's delete cascaded to two kinds of children, and one child held a required lookup to the other, the delete could answer `409 DELETE_RESTRICTED` naming the child that holds the lookup. Whether it did depended on which object was registered first. A measured example: an account cascades to its contacts and to its contracts, and each contract has a required lookup to a contact. With the contacts registered first, deleting the account was refused, naming a contract that the same delete was about to remove. With the contracts registered first, the same delete succeeded. + + Clause-②: no + + A by-id cascade delete now collects, before any refusal is judged, every record it will delete across its cascading relations, the root record included. A `restrict` raised by a record in that set against another record in the set is no longer a refusal. That covers an authored `deleteBehavior: 'restrict'` and a required lookup whose `set_null` cannot clear it. A `set_null` writes nothing to a record in the set, so that record's hooks and audit rows show the delete without an earlier update. A refusal from a record outside the set is unchanged. It still answers `409 DELETE_RESTRICTED` and names the dependent object, and on a single datasource the whole delete still rolls back. The refusal now counts only rows outside the set. + + The cascade also no longer re-enters a record it is already deleting. A cycle in the data, such as two rows that cascade to each other, now deletes both rows. Before, the walk re-entered the same rows without end. A row that two cascade paths reach is deleted once, instead of the second path answering `404`. + + The cost is one more read for each cascading relation of each record the cascade deletes: the set is collected by a read-only walk before the existing walk deletes. No key, export or error code changes. +- fdfdd7e: fix(service-automation, objectql): the `runAs: 'user'` refusals and the run-setup warning say what a principal-less operation meets on both kernels + + Clause-②: no + + Three author-facing texts told a flow or hook author that a `runAs: 'user'` data operation with no trigger user "would execute UNSCOPED (elevated, RLS-bypassing)" if it were not refused. That holds only on a kernel with no security plugin. Since ADR-0096 D5, a kernel with `@objectstack/plugin-security` refuses an operation that carries no principal (`403 PERMISSION_DENIED`). Each text now says both: without a user the operation would carry no principal, refused by the security plugin where one is composed, unscoped where none is. + + - `@objectstack/service-automation`: the `UnscopedRunDataAccessError` message, and the `[runAs]` warning logged at run setup for a flow whose trigger resolved no user. + - `@objectstack/objectql`: the `HookUnscopedDataAccessError` message. + + Only the wording changes. The refusals, their order, their codes (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`, `HOOK_UNSCOPED_DATA_ACCESS` with its `403` status) and their remedy are unchanged: declare `runAs: 'system'` to make the elevation explicit and intended, or arrange for the trigger to supply a user. +- 87cd458: A top-level object that no package body of a multi-package stack declares is now served by the data door, under the stack's `manifest.id`, the id the metadata door already lists it under + + Clause-②: no + + - **What was wrong.** A stack that carries `packages[]` registers each package body under that package's id. A top-level object that no body declares is the stack's residual. The metadata door registered it under the stack's `manifest.id` and the boot warned that every door would report that id as its owner. The engine's `manifest` service registered the bodies only. So `GET /api/v1/meta/object` listed the object while `GET /api/v1/data/` answered `404`, on the artifact boot (`os serve` of a built artifact) and on the config boot (`os serve objectstack.config.ts`) alike. + - **What changes.** After the bodies, the `manifest` service registers the residual's objects under the stack's `manifest.id`. It reads which objects are residual from `unclaimedTopLevel` (`@objectstack/metadata`), the same answer the metadata door and `os validate` read. No package record is created for that id: the residual is not a package. When the id names one of the bodies (a composed stack keeps one member's manifest), that body's record is unchanged. A residual object that arrives after boot, with a late artifact, is copied to the metadata service as a body's objects are. + - **Listed and not served, by name.** Registering two classes of residual object would refuse a boot that the stack passes today, so they are not registered. They stay listed by the metadata door and unserved by the data door, and the boot prints one warning naming each object and why: + - an object whose name another package already owns: the data door keeps serving that package's object; + - an object with a field that names a picklist no package body declares: the stack's top-level picklists are metadata only, so the boot's picklist check would fail. + + The remedy in the warning is the residual warning's own: declare the object inside the `packages[]` entry of the package that owns it, with every picklist its fields name. To add fields to an object another package owns, use `objectExtensions`. + - **Unchanged.** A stack without `packages[]`, and a normally composed one whose top level repeats its bodies, has no residual and registers exactly as before. +- b62260c: An app, object or other item registered through `ObjectQL.registerApp` is now served with its package's `_packageVersion`, as the artifact loader already serves it + + Clause-②: no + + `_packageVersion` ("Owning package version") is declared on the protection envelope, and the metadata plugin's artifact loader stamps it. The registry load path stamped only `_packageId`: `ObjectQL.registerApp(manifest)` called `SchemaRegistry.registerItem` and `registerObject` with the package id alone. So `GET /api/v1/meta/app` served such an app with no version, and a console waiting for an upgrade could not tell the new app from the old one that a stale kernel still serves. + + `registerApp` now stamps `manifest.version` on every item it registers: owned objects, object extensions, apps, every metadata collection, seed datasets, and the objects, app and collections of a nested plugin, which carry their owning package's version. A multi-package stack's residual top-level objects, which the engine registers under the stack's own id, now carry the stack's version too, as the metadata door stamps them. Each item is stamped before it reaches the registry, with the artifact loader's own call, `applyProtection(item, { packageId, packageVersion })`. The registry's stamp knows the id only, and it keeps a version that is already set. No signature changes: `SchemaRegistry.registerItem`, `registerObject` and `registerApp` keep theirs. + + A caller that passes only a package id still stamps the id alone. No version is looked up or invented. Those callers are the metadata-service re-sync and the facade, which pass along the stamp the artifact loader already put on the item; the stored-row hydration, whose rows are tenant-authored; and the security catalog's built-ins. +- 8532783: feat(service-datasource,metadata-core)!: the datasource-admin plugin reads the environment's stored `datasource` rows only; a legacy organization-scoped row is neither loaded at boot nor served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** Graded `minor` on the v18 prerelease line, as the read narrowing it completes. + + **What changes.** `DatasourceAdminServicePlugin` reads stored `datasource` rows in five places — the boot restore (`restoreRuntimeDatasources`), the by-name read behind cluster convergence and the `/meta` door's datasource projection, and the existence probes behind its own create, update and remove. Each now reads the environment's rows only, judged by the same row-value test the metadata reads apply. A row stored organization-scoped by an earlier release: + + - is no longer registered at boot, so the datasource-admin door and the metadata read door no longer serve it, and no live pool is opened from it; + - is never updated or deleted by an admin create, update or remove under its name — those write and remove an environment row of their own; + - stays at rest, and the boot report `[metadata_org_scoped_unserved]` keeps naming it. Nothing is deleted or rewritten; the v18 migration ceremony (`os migrate`, ADR-0131 D10) carries it. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | a legacy organization-scoped runtime datasource a deployment relied on | re-create it through the datasource-admin door or save it through the `/meta` door to make it environment-wide now, or wait for the v18 migration ceremony | + + `@objectstack/metadata-core` exports `isLegacyOrganizationRow`, the one row-value test for such a row; `@objectstack/objectql`'s authored hook and action binders now read it from there, with no change in what they bind. +- eb20c33: fix(cli): the nine rule ids the CLI produces print one verdict sentence each, `os explain ` carries their reasoning, and the undeclared-handlers boot line is one line + + Clause-②: no + + - **One verdict sentence of at most 200 characters.** Measured on every finding the CLI's suites fire (message length; before → after): + - `os lint`'s hook-body rules: `hook-body/not-lowerable` 565–661 → 165–178, `hook-body/bundled-fallback` 324–947 → 109–171, `hook-body/unparseable` 469 → 157, `hook-body/extraction-failed` 331 → 140. Over every refusal the extractor can throw (14 forbidden constructs and 7 parameter refusals), `hook-body/bundled-fallback` runs 296–1,011 → 113–199: the verdict quotes the extractor's own verdict clause, cut to fit and marked `…` when cut. + - package docs (`os build`, `os validate`, `os lint`): `docs/uncollected-directory` 433–558 → 124–183, `docs/nav-target` 263–299 → 147–184, `docs/frontmatter-tags` 233–285 → 135–195, `docs/duplicate-name` 330 → 167 (its within-package arm is unchanged at 101). A doc finding no longer repeats its path, which every printer already leads with; lists of files, packages and directories name the first three and count the rest. + - `picklist-reference-unverified` (the `os validate` / `os build` notice): 240–245 → 173–175, naming at most two outside dependencies and counting the rest. + - **A `fix:` line for the hook-body rules.** Their remedy moves out of the message into the finding's `fix`, at most 200 characters: `os lint` prints it as `→ fix:` under `--fix`, and `os lint --json` now carries `fix` on these four ids' issues. The remedy is still chosen per case: inline a module-scope name, or keep a check on a host-only global such as `Intl` in a string handler ref or a validation rule. A doc finding carries no separate fix, so its sentence ends with the remedy; the picklist notice's `hint` is unchanged. + - **`os explain ` carries the rest.** Each of the nine ids gains a `RULE_EXPLANATIONS` entry in `@objectstack/lint`, the one table `os explain` reads, and `os lint`'s `rule:` line now points at it. They hold what the messages stopped saying: why a bundled handler is a change of deployment shape and how to bundle on purpose, what the sandbox cannot run and where `os build` prints the full refusal, why an instrument limit is not a verdict, where package docs are read from and why the collector never guesses, why a doc name may appear once, what a doc navigation item can open, how doc tags are read, and why a reference into a dependency outside the stack is not judged. + - **The `[action-governance]` boot line for registered handlers with no declaration** (`os dev`, `os serve`, and any host that boots ObjectQL) is one line of 192 characters, where it was 628: it names the three sources it read, states the 404 as expected rather than measured, and says to declare each with `defineAction`. Its structured `count` / `handlers` tail is unchanged, and the dead-button line beside it is unchanged. + - **Unchanged:** every rule id, severity and `path`, the boot lines' level and payload, and what each check accepts or refuses. A tool that matched on this message text should match on `rule` and `path` instead. +- 6373952: Work a `ctx.api.transaction(fn)` unit starts and does not await no longer keeps the unit's finished transaction handle + + Clause-②: no + + `ctx.api.transaction(fn)` is `ScopedContext.transaction()`, the transaction a hook or action handler opens. When the unit it opened finished, work the callback had started and not awaited still held its handle. The typical case is an `async: true` hook on a record the callback wrote. That work then failed with "Transaction query already complete" and its write was lost. Only the hook's log showed it. Measured on the in-process SQLite stack: the record was stored and the hook's later note was not. + + The handle reached that work two ways, and both now close when the callback settles, before the commit: + + - **Ambient.** `ScopedContext.transaction()` published its unit into the engine's ambient store and never closed the entry. It now runs its callback through the same helper `transaction()` uses, which closes the entry. + - **Threaded.** The unit's handle is also threaded into the callback's context (`tx.object(…)`), and from there into the triggered hook's `ctx.api`. A handle whose unit has closed is now treated as absent, so that work runs on its own connection, as it would outside any unit. That includes the write door's own unit for an aborting blocking `after*` hook: a late write opens one exactly when the same write outside any unit would, so that hook's abort rolls the write back. This applies on both surfaces: it also covers a caller who opens `transaction()` and passes its context to a write (`{ context: trx }`). + + **Unchanged:** work the callback awaits still runs inside the unit and commits or rolls back with it. A nested `transaction()` still joins the open unit, and its handle stays live until the outermost callback settles. Writes with no transaction are unaffected. A handle opened through the discrete `beginTransaction` / `commitTransaction` / `rollbackTransaction` trio (the sandboxed hook body's path) is not covered and behaves as before. +- 4663801: fix(objectql): a `tree` field's `deleteBehavior` now takes effect when its parent record is deleted, and the default clears the reference + + Clause-②: no + + The spec gives a `tree` field (a record's parent in a hierarchy on its own object) a `deleteBehavior`, like a `lookup`. When the field declares none, the parse fills in `set_null`. The engine's referential delete pass only followed `master_detail` and `lookup` fields. So deleting a parent node returned success and left every child pointing at the deleted id. An authored `cascade` or `restrict` on a `tree` did nothing either. + + A by-id delete now applies a `tree` field's behaviour the way it applies a `lookup`'s: + + - `set_null` (the default): the children stay, and their parent field is cleared. + - `cascade`: the whole subtree is deleted, at any depth, and nothing outside it. A cycle in the data (a row whose parent is its own descendant, or itself) ends: each row is deleted once. + - `restrict`: deleting a node that still has children is refused with `DELETE_RESTRICTED` (409), and the dependent named is the tree's own object. A leaf node still deletes. + - On a required `tree` field, `set_null` cannot clear the parent, so deleting a node that has children is refused with `DELETE_RESTRICTED`, as it is for a required `lookup`. + + Deleting a record of an object that has a `tree` field now runs as one transaction when the object is on the default datasource, as every delete that can cascade already does. If a node deep in the subtree refuses its delete (a `beforeDelete` hook, say), the nodes already deleted are restored and the call is refused. `lookup` and `master_detail` behave exactly as before. +- d294951: Boot-time system-table provisioning honours `OS_SKIP_SCHEMA_SYNC` / `skipSchemaSync` + + Clause-②: no + + `OS_SKIP_SCHEMA_SYNC=1`, or `ObjectQLPlugin`'s `skipSchemaSync` option, declares that a deployment's DDL is managed out-of-band ("Use after running migrations manually"). The engine's boot honoured it, but `AuditPlugin` and `MessagingServicePlugin` still created their system tables at `kernel:ready`. A deployment that set the flag got tables created behind its back while its boot log said no DDL ran, and a database role without DDL rights got a DDL failure it never asked for. + + - **What changes.** With the flag set, `AuditPlugin` (`sys_audit_log`, `sys_activity`, `sys_comment`, `sys_comment_reaction`) and `MessagingServicePlugin` (`sys_notification`, `sys_inbox_message`, `sys_notification_receipt`, `sys_notification_delivery`, `sys_notification_preference`, `sys_notification_subscription`, `sys_notification_template`, `sys_http_delivery`) issue no DDL and no existence probe at boot. Each logs one `info` line naming the objects it did not provision and the flag, and pointing at `os migrate apply`, which creates any that are missing (`os migrate plan` lists them as `create_table`). That path was measured to create all twelve tables, on one database, with the telemetry sibling database, and with the flag set in the migrate run's own environment. + - **Which value is read.** The one `ObjectQLPlugin` resolved: the option, else the environment variable. An explicit `skipSchemaSync: false` therefore still provisions under `OS_SKIP_SCHEMA_SYNC=1`. `ObjectQLPlugin` publishes that value on the engine instance it registers. It is a property of the instance, not a declared member, so no type or export changes. + - **What is unchanged.** With the flag unset, provisioning is exactly as before. `syncObjectSchema` is not gated, so on a flag deployment a runtime publish still gives a new object its table. +- 8464380: fix(spec, objectql): a `tree` field with no `reference` points at its own object, so its `deleteBehavior` takes effect when a parent is deleted + + Clause-②: yes (widening) + + A `tree` field is a record's parent in a hierarchy on its own object. `reference` is optional on it, and when you write one it must be the object's own name — the object schema refuses any other target and tells you to drop the key. But a `tree` with no `reference` behaved as if it pointed at nothing. Deleting a parent answered success and left every child holding the deleted id, whatever `deleteBehavior` said (`set_null`, `restrict` or `cascade`). The same field also could not be expanded (`expand=parent` answered "declares no target object"), could not be filtered through (`{ parent: { title: … } }` was refused), and accepted a parent id that does not exist. + + Now a `tree` with no `reference` is a reference to the object that declares it, exactly as if you had written the object's name: + + - **`@objectstack/spec`.** `ObjectSchema` and `ObjectExtensionSchema` write the object's own name (an extension's `extend`) into such a field's `reference` when they parse it. `defineStack` and `ObjectSchema.create()` output, and built artifacts, now carry the object's name in `reference` on those fields, byte-identical to a field that wrote it. Nothing you could write before is refused now. A new export, `materializeTreeSelfReferences(objectName, fields)`, is the one helper both the parse and the engine use. + - **`@objectstack/objectql`.** The object registry writes the same self-reference when an object is registered. That covers an object saved through the metadata API (`PUT /meta/object/:name` stores the body as written) and a direct `registerObject` call, neither of which goes through the parse. + + What changes on a parent delete: + + - `set_null` (the default) clears the children's parent field and keeps the children. + - `restrict` refuses the delete with `DELETE_RESTRICTED` (409), naming the tree's own object as the dependent. + - `cascade` deletes the whole subtree. + + `expand`, nested relation filters, the write-time check that a parent id exists, the dangling-reference audit and `os verify --delete-behaviors` now treat the field as a self-reference too. A `tree` that already wrote its own name, and every `lookup` and `master_detail`, behave exactly as before. +- abf8655: A language the environment added to an email template, which the package does not ship, is no longer treated as packaged code + + Clause-②: no + + An `email_template` is identified by its name and its `locale`, so the languages of one template form a bundle. When an administrator customizes a template in a language its package does not ship (for example a `zh-CN` version of a template the package ships in `en-US` only), that version is the environment's own item. Until now it was registered and served wearing the package's protection envelope, taken from the shipped `en-US` version: + + - the registry's artifact lookup answered the environment's `zh-CN` version as the package's own `zh-CN` artifact; + - `GET /meta/email_template/:name?member=zh-CN` and the `/meta/email_template` list served it with `provenance: 'package'`, the package's id and its version; + - the registry heal that runs after `DELETE ?member=zh-CN` refused to retire it as packaged code. + + - `@objectstack/metadata-protocol`: the artifact envelope (`_packageId`, `_packageVersion`, `_provenance`, and an artifact lock) is copied only onto a version of the same language as the shipped artifact. Each place that copies it looks up the artifact of the body's own language first. A language the package does not ship is registered and served as the environment's item. A language the package does ship keeps its envelope, its lock and its reset exactly as before. Types without languages are unchanged. + - `@objectstack/objectql`: `SchemaRegistry.removeRuntimeShadow(type, name, member)` lifts a named language's shadow only when the package ships that language. For any other language it declines, and the heal's last step (`removeOverlayEntry`) retires the entry. Naming no language still sweeps the whole bundle, as before. + + A lock still binds the template name, at the read and at the write gate alike: a template the package locks stays locked in every language. +- 37510aa: feat(security): the guest's grants channel — an anonymous request at a guest-entry door holds the sets bound to the `guest` anchor, in the one organization the deployment resolves for it (ADR-0138 E1, D3/D4) + + ADR-0087 disposition: none. No authorable key, accept set or stored shape moves: no spec schema, metadata type, stored row or published response field is added, removed, renamed or re-shaped. A declared binding that was not enforced becomes enforced (ADR-0090 D9, as ADR-0138 D3 amends it). + + **The bindings that start granting.** On the reference apps, none: `app-showcase`, `app-crm`, `app-multi-package` and `app-todo` declare no definition of the `guest` anchor that names a set. `app-showcase` ships `showcase_guest_portal` as a capability an administrator may bind and binds it to no anchor (its `client_portal_user` position is an ordinary position), and `app-crm`'s `guest_portal` is the public form doors' set, which keeps its own posture. No reference app declares a permission set named `guest`, so no grant stops either. A set starts granting only where a deployment holds an environment definition of the `guest` position that names it. + + What changes: + + - **`@objectstack/core`** — the anonymous branch of `resolveAuthzContext` resolves the guest's grants on a face that takes the explicit guest entry (the new `guestEntry` input). It reads the sets the `guest` position's definition names, through the same position-bound read every position uses, and the activation ledger as for any held set. It resolves them only in the organization the guest entry's organization step derives (`resolveGuestOrganization`), which asks ADR-0131 D9's predicate and no second spelling of it. With nothing bound, the guest holds no set and no read is issued. The predicate itself, `resolveSystemWriteOrganization`, moves to `@objectstack/core` so both of its askers share it. New exports: `resolveGuestOrganization`, `resolveSystemWriteOrganization` (with `SystemWriteOrganizationDecision` and `SystemWriteRefusalReason`), and `GuestOrganizationUnresolvedError` with `isGuestOrganizationUnresolvedError` and its constants. + - **`@objectstack/objectql`** — `resolveSystemWriteOrganization` and its two types are re-exported from `@objectstack/core` as the same bindings. Nothing a caller imports changes. + - **`@objectstack/plugin-security`** — the set resolution treats the guest's sets as any requested set, and no longer folds the guest's position into the request. A permission set that merely shares the anchor's name no longer reaches an anonymous caller, and the baseline and `everyone` never did. At the CRUD gate, a guest that reaches a data operation acting in no organization, on a deployment where the organization step refuses, is refused before the operation runs. + - **`@objectstack/plugin-sharing`** — the read filter answers deny-all for the guest on every object that record sharing scopes, before the sharing-model branch: the guest reaches rows only through share links, never through the object's sharing model (ADR-0138 D3 item 6, as the maintainer's ruling C amends it). `externalSharingModel` is still not read for any caller. Share links are redeemed at their own door and are unchanged. + - **`@objectstack/runtime`** — the runtime face, which serves the guest, asks for the guest's grants. + + What an anonymous caller sees at an `authRequired: false` endpoint of `type: 'object_operation'`: + + - **One organization, a set bound to `guest`:** the operation runs as the guest, under that set, in that organization. A read the set grants is admitted and returns no row, whatever the object's sharing model (`private`, `public_read`, `public_read_write` or `controlled_by_parent`): the guest reaches a record only through a share link. A create the set grants lands in that organization with no owner. + - **Nothing bound:** `403 PERMISSION_DENIED`, as before. + - **A walled deployment, several organizations, or none:** `501 NOT_IMPLEMENTED`, naming the missing site binding (ADR-0138 D5), where it used to be `403 PERMISSION_DENIED` with nothing granted. Nothing that was served stops being served. The operator's log names the reason once. + + Unchanged: share links, which still serve an anonymous caller the one record they name, with `publicSharing.eligibility` checked at the mint and at every redemption; the public form doors, which keep their own posture and organization; the flow door, which runs under the flow's own declared posture; every surface that answers an anonymous request `401`; and every signed-in caller. + + Level: `minor` for `@objectstack/core`, because new exported symbols widen its published index; `patch` for the other four, whose exports do not move. +- 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. +- a69a40a: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + feat(spec)!: a page declares nothing the console does not honour — `isDefault: true` is refused off a record page bound to an object, `template` closes to the registered layouts, and a region the template does not place is refused + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** two accept sets of `PageSchema` narrow to what the console runs, and the `PageTypeSchema` text stops promising what it does not. + + | Writing | Before | Now | + | :--- | :--- | :--- | + | `isDefault: true` on a `home`, `app`, `utility` or `list` page, or on a record page with no `object` | accepted, stored and served; nothing read it | refused at `isDefault` — by `definePage`, `defineStack` (so `os validate` and `os build`, `STACK_SCHEMA_INVALID` 422), the metadata save door (`422 INVALID_METADATA`) and package registration (`SchemaRegistry.installPackage` / `registerItem`, `422 INVALID_METADATA`) | + | `template: 'full-width'` | accepted; drawn exactly as `default` | refused by name: "Page `template: 'full-width'` was removed in @objectstack/spec 18 … Use `template: 'default'`" | + | any other `template` outside `default`, `header-sidebar-main`, `three-column`, `dashboard` (`two-column`, `single-column`, …) | accepted; drawn as the page type's default layout | refused at `template` | + | on a `kind: 'full'` page, a region its template does not place (`left_sidebar`, `right_sidebar`, `content`, …) | accepted; stacked under `main` at full width, its `width` ignored | refused at `regions.N.name`, naming the template's slots and the rename | + | on a `kind: 'full'` page, a second region filling a slot an earlier one fills (two `main` regions; on `dashboard`, a second `header` or `footer`) | accepted; not drawn at all — the console draws the first region of a name and leaves that name out of the rest | refused at `regions.N.name`, naming the region that already fills the slot | + + Migration: + + - **`isDefault` on a non-record page** → delete it. A page does not choose where an app opens: an app's landing page is its **first navigation item** (by `order`), so to land on the page put its entry first in the app's `navigation`; the root landing follows the app's own `isDefault`. A record page meant as an object's default record page binds that object with `object`. + - **`template: 'full-width'`** → `template: 'default'` (or delete the key — `default` is the default). The page renders exactly as before. + - **A `template` held in a widened `string`** (a shared base object, `{ template: 'default' }` spread into `definePage`) no longer type-checks against the closed set → `template: 'default' as const`. + - **An unregistered template** → one of `default` (header, sidebar, main, aside, footer), `header-sidebar-main` (header, sidebar, main), `three-column` (header, sidebar, main, aside, footer) or `dashboard` (header, footer, and every other region a grid cell). + - **An unplaced region** → name it for the slot it fills: `left_sidebar` → `sidebar`, `right_sidebar` → `aside`, `content` → `main`; or choose `dashboard` when the regions are a grid. Names match case-insensitively, as the console's do. `slotted`, `html` and `react` pages are not judged on region names. + - **A second region for a filled slot** → move its components into the first region of that name, or name it for an unfilled slot. On `dashboard`, two regions of any other one name stay two grid cells and are accepted. + + The template set and each template's slots are now published: `PageTemplateSchema`, `PAGE_TEMPLATE_SLOTS` and `PAGE_TEMPLATE_OPEN_GRID` (`@objectstack/spec/ui`), with the two checks `checkPageDefaultPlacement` and `checkPageRegionPlacement` and the one refusal sentence `misplacedDefaultPageRefusal` that the parse and the registry share. + + `os migrate meta` lists both as hand edits — the D3 entries `page-is-default-off-record-page-refused` and `page-template-closed-region-unplaced-refused`. No D2 conversion rewrites them: where an app opens, and which slot a region fills, are the author's calls. + + **Stored metadata keeps loading.** No conversion touches these shapes and the read path does not refuse them, so a `sys_metadata` page row already stored with a non-record `isDefault: true`, `template: 'full-width'` or an unplaced region is served unchanged and renders as before (a stored row hydrates into the registry without a package, so the registry does not judge it either). Saving such a row again through the metadata API is refused until it is fixed. + + The text, without a contract change: `PageTypeSchema`'s docblock and enum comments now say that `home`, `app` and `utility` are the same region layout at three content widths — `home` is not a landing declaration, and `utility` is an ordinary inline page (the console has no floating or docked shell slot for it); `utility` stays a page type, read by the print door and the page-type width. The `print`-on-`utility` refusal no longer calls a utility page "a floating panel". + + `@objectstack/objectql`: the registry refuses `isDefault: true` on a non-record page a package registers (`MisplacedDefaultPageError`, `INVALID_METADATA` 422), at the package door and at the package-bound item seam, with the page parse's sentence. + + +- 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. +- abb6df0: Clause-②: no + + fix(cli): `os storage orphans`, the `os secret rewrap` dry run and the `os migrate summary-nulls` dry run say they write no row or table, and that opening a SQLite file can still switch its journal mode, instead of saying nothing was written + + **What was wrong.** Each of these runs 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, each run 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 "nothing was written". Nothing was lost. + + **What changed.** Each sentence below now claims no row or table. Where the run opened a SQLite file, the report's line is followed by the note the other read-only 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 line ends without it. + + - `os storage orphans` (`formatStrandedOrphanInventory`, `@objectstack/service-storage`). FROM: "This pass is READ-ONLY: nothing was written, tombstoned or deleted." TO: "This pass is READ-ONLY: no row or table was written, tombstoned or deleted." and the note. + - `os secret rewrap` (the dry run's note, `REWRAP_NOTES.dryRun`, also in `--json` `report.notes`). FROM: "Dry run: every attributed row was opened, re-sealed and verified in memory, and nothing was written. Re-run with --apply to write." TO: "… and no row or table was written. Re-run with --apply to write." The note already follows the dry run's closing line on a SQLite file, so this sentence carries none. + - `os migrate summary-nulls` (the dry run that found NULL roll-ups, `formatSummaryBackfillReport`, `@objectstack/objectql`). FROM: "Dry run — nothing was written. Re-run with --apply to backfill." TO: "Dry run — no row or table was written. Re-run with --apply to backfill." and the note. + + **Unchanged.** No exit code, flag, prompt, refusal or export signature changes, and no `--json` document gains a field; 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. +- a33f3d5: Clause-②: no + + fix(cli): `os migrate plan`, a refused `os migrate summary-nulls --recompute-undefined-on-empty` name and every `os secret rewrap` run say what they vouch for, and that opening a SQLite file can still switch its journal mode, instead of saying nothing was written + + **What was wrong.** Each of these runs 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, each run 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 "a plan writes nothing", "nothing was written" or "a finished run writes nothing". Nothing was lost. + + **What changed.** Each sentence below now names what it vouches for. On a SQLite file, its line is followed by the note the other read-only 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 line ends without it. + + - `os migrate plan`, the unmanaged-table section. FROM: "They are reported for information only — nothing here drops them, and a plan writes nothing." TO: "… and a plan writes no row or table." and the note. + - `os migrate plan`, the declaration boot's note when a plugin's write was refused. FROM: "Refused N write(s) during the declaration boot — a plan writes nothing: …" TO: "… — a plan writes no row or table: …", and the note at the end of that note's line. + - `os migrate plan`, the same note when a raw command was forwarded. FROM: "If those commands only read, nothing was written; if one wrote, it landed." TO: "If those commands only read, no row or table was written; if one wrote, it landed.", and the note at the end of that note's line. + - `os migrate summary-nulls --recompute-undefined-on-empty` naming a roll-up the run cannot find (`backfillSummaryNulls`, `@objectstack/objectql`), in either mode. FROM: "Refused before any row was read; nothing was written." TO: "Refused before the walk read or wrote any row." and the note. Under `--apply` the boot before it can also create a table a never-started database lacks, so the sentence speaks for the walk only. + - `os secret rewrap`, the "Re-running is safe" note, in both modes and in `--json` `report.notes`. FROM: "… and a finished run writes nothing." TO: "… and a finished run writes no sys_secret row." Under `--apply` the note follows it; a dry run's closing line already carries it. A finished `--apply` run on a database that has never been started also creates its tables, so the sentence names the rows the re-wrap writes. + + **Unchanged.** No exit code, flag, prompt, refusal or export signature changes, and no `--json` document gains a field: the `--json` documents carry the changed sentences, and the note is printed in the text output only. What the driver does on connect is unchanged. +- 2ac0616: refactor(objectql): the record validator reads `isMissing` / `isEmptyForRequired` from `@objectstack/core` instead of private copies + + Clause-②: no + + - **No behaviour change.** The object-level `required` check, the `required_cleared` PATCH check, the blank-to-`null` normalisation of typed columns and the value-shape scan judge "missing" exactly as before; the private copies of the two predicates are deleted and `@objectstack/core`'s exports, moved there unchanged, are imported at every read site. + - **Why:** `@objectstack/rest`'s public-form submit route now judges a form's `required` by the same rule, and it cannot import the engine. +- aa31569: Clause-②: no + + fix(objectql): withdrawing a package that extends another package's object puts the owner's definition back at `GET /api/v1/meta/object/NAME` at once, not at the next restart + + - **What was wrong.** When a package whose content is an `objectExtensions` field on another package's object is registered on a started kernel, the ObjectQL bridge rewrites the OWNER's copy of that object in the `metadata` service with the merged body, extension included. `DELETE /api/v1/marketplace/install-local/:manifestId` of the extender withdrew its contribution from the registry, so the object listing and the data plane dropped the field, but nothing rewrote the owner's copy. The by-name read asks that copy first, so `GET /api/v1/meta/object/NAME` kept serving the withdrawn field until the runtime restarted. + - **What changes.** The ObjectQL bridge remembers which owners' copies each package's extensions rewrote, and the `manifest` service gains `restoreExtendedObjectCopies(packageId)`: once the registry no longer holds the package, it re-registers each such copy with the registry's current definition. The install-local DELETE calls it right after the registry withdrawal. The by-name read then serves the owner's body without the extension's fields, as the listing and the data plane already did. + - **Not touched.** Only a copy stamped with the registry owner's id is rewritten, the same rule the bridge overwrites by, so an authored copy stays. The DELETE answer is unchanged. A copy that cannot be restored is logged once at `error`, naming the objects and the remedy: restart the runtime. +- 9112bcd: fix(objectql): a record read that hydrates a file field no longer makes a `sys_file` access attempt for a reader without `sys_file` read + + The engine expands a stored file id by reading `sys_file`, and it issued that read as the caller. A member who may read the record but not `sys_file` (the platform default) was refused it, and the security layer logged `[Security] Access denied: operation 'find' on object 'sys_file'` at WARN on every by-id and list read by that member, although the body served was right. Those lines buried the real denials in the operator's log. + + The engine now asks the security service's `canReadObject('sys_file')` first, the same object-level read gate the security middleware applies, and serves a refused caller the field-owned answer without the read. Nothing else changes: + + - The served body is byte-identical, by id and by list, for a reader with and without `sys_file` read. A file owned by the record still hydrates, and every other id still reads `{ id, metadataRefused: true }`. + - A direct `GET /api/v1/data/sys_file/:id` by the same reader still answers `403 PERMISSION_DENIED` and still logs its denial. + - With no security service, or one that offers no `canReadObject`, the engine reads as the caller, as before. + + The ask is one cached permission check per read, not per row. + + Clause-②: no (no accept set or public surface moves: the served bodies and every refusal are unchanged; owes the seat's review of record at `TIER_DEFAULT` before the queue) +- cf540a7: Clause-②: no + + fix(objectql): the default-record-page and security-catalog refusals name an item made here, not shipped by a package, as "authored in this system" + + - **What changed.** The holder text a `DEFAULT_RECORD_PAGE_CONFLICT` refusal prints for a page no package ships now reads `page "NAME", authored in this system`, and the holder text a `NAMESPACE_CONFLICT` refusal prints for the environment catalog reads `the environment catalog (an item authored in this system, not shipped by a package)`. Both said "authored in this environment". Inside the running system, user-facing copy names the system the user is in as "this system". + - **Unchanged.** Every error code, status and envelope field (`existing`, `incoming`, `existingHolder: { kind: 'environment' }`). "The environment catalog" is the documented name of that holder (ADR-0048), so it stays. +- 45bec43: fix(objectql): a formula reading a field its reference's target does not declare no longer makes the other formulas reading through the same reference read null + + Clause-②: no + + + + The formula read loads, once per page, every column the object's formulas read through each reference. When one formula named a field the target does not declare, the whole reference was answered as undeclared. Every formula reading through it then read `null` and reported the other formula's field. + + - The undeclared fields are left out of that shared read and judged for each formula on its own. + - A formula that names one reads `null`, with a fault report naming only its own undeclared fields. + - A formula that reads only declared fields through the same reference gets the value it gets when read alone (`?fields=`). + + What to do. Nothing. A formula that read `null` this way now reads its value. To find the formula at fault, re-save the object: the save door refuses the misspelt read and names the field. +- Updated dependencies [5a871e4] +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [3ae0b77] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [4840720] +- Updated dependencies [23dae89] +- Updated dependencies [1e8b628] +- Updated dependencies [4ff18d3] +- Updated dependencies [3566c08] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [8c105e3] +- Updated dependencies [e02833c] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [1abfc58] +- Updated dependencies [db87a02] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [6befe19] +- Updated dependencies [8caa131] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [2015c54] +- Updated dependencies [73a0a6b] +- Updated dependencies [f2a45db] +- Updated dependencies [aa9447c] +- Updated dependencies [3d91885] +- Updated dependencies [8fc50b7] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [ae97841] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [bb4f5cc] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [35afb15] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [b1f7a7a] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [3f80f17] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [46692c1] +- Updated dependencies [440bed6] +- Updated dependencies [3ca71b6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [0fea05f] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [cb3bb93] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [c63028e] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [3b5475a] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [6a6a4d3] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [0a29bec] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [aa5bfdf] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [c6139f2] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [78c13bd] +- Updated dependencies [9543e9c] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [47d569e] +- Updated dependencies [2de9c03] +- Updated dependencies [5b68524] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- 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 [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [e67ac4b] +- Updated dependencies [4feb2d0] +- Updated dependencies [7dc7d22] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [954bf72] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cad343e] +- Updated dependencies [cbe7712] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [9bb9370] +- Updated dependencies [23d0bc7] +- Updated dependencies [14aff67] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [cf07168] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [6e18016] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [077b75f] +- Updated dependencies [d6bd98a] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [f3174ef] +- Updated dependencies [e46ce7b] +- Updated dependencies [a06f0d4] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [2bcd406] +- Updated dependencies [e74ec56] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [ec749fb] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [41c7405] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [1827571] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [33a45cc] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/metadata-protocol@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/formula@18.0.0 + - @objectstack/metadata@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Minor Changes diff --git a/packages/objectql/package.json b/packages/objectql/package.json index 6ba1db3ba29..10cc1602985 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "18.0.0-next.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Isomorphic ObjectQL Engine for ObjectStack", "main": "dist/index.js", diff --git a/packages/observability/CHANGELOG.md b/packages/observability/CHANGELOG.md index 72388c9d3f3..051bedcea44 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,344 @@ # @objectstack/observability +## 18.0.0 + +### Patch Changes + +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index 62738f99fac..2c4685c1f58 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "18.0.0-next.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Observability contracts and exporters for ObjectStack — MetricsRegistry, ErrorReporter, Logger plus noop/console/OTLP-HTTP exporters. Deployment-target neutral; runtime and services depend on this so the same instrumentation works on Cloudflare Workers, Node, and self-hosted Kubernetes.", "type": "module", diff --git a/packages/platform-objects/CHANGELOG.md b/packages/platform-objects/CHANGELOG.md index 9497566ee1c..0f695e00147 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,1413 @@ # @objectstack/platform-objects +## 18.0.0 + +### Minor Changes + +- 29d0ed3: fix(platform-objects)!: `sys_organization` no longer declares `change_slug`, whose door only the cloud orchestrator mounts (#24318) + + Clause-②: no (narrowing) (a platform object stops declaring an action whose door exists only in cloud; owes a `CONTRACT_REVIEW_TIER` review on the narrowing face before the queue, and the `repo:cloud#2` seat's pointer before landing) + + `sys_organization` declared a `change_slug` action ("Change Slug") at `record_header`, gated on `multiOrgEnabled`, whose target, `/api/v1/cloud/organizations/{id}/change-slug`, no package in this repository serves. So on every open-source composition with multi-organization on, the organization record page offered Change Slug in its `⋯` overflow, and the click answered `404 ENDPOINT_NOT_FOUND`. Measured on a booted multi-organization showcase. + + The action now belongs to the composition that serves its route. That composition declares `change_slug` as a top-level action bound by `objectName: 'sys_organization'`, and the registry attaches such an action to the served object, whichever package registers first. + + **BREAKING — what moves for consumers.** A declared action leaves a published platform object, shipped as `minor` under the launch-window convention for breaking changes (pre mode was exited at `c46b5f14cf`, so the launch-window guard is armed and a breaking change ships as `minor`). + + - FROM: `GET /api/v1/meta/object/sys_organization` served `change_slug` on every composition, and the four generated locale bundles carried its `label`, `description` and `successMessage`. + - TO: the object serves it only where a composition binds it. The open-source record header offers Invite User, Set Active and Rename Organization inline, and Leave Organization in the overflow; each of them is a better-auth door this composition mounts. + - Fix: cloud declares its own `change_slug`, the same declaration bound by `objectName: 'sys_organization'`, with its own translations. An open-source composition changes an organization's slug through `update_organization` (better-auth's `organization/update`, list row), as before. + + **Unchanged.** `rename_organization` (name and logo, no slug) and `update_organization` (name, slug and logo, list row only) keep their declarations, gates and order. No stored row moves. + + +- b12e16a: feat(service-settings)!: the settings audit trail's global rung moves to the new deployment-level `sys_platform_setting_audit` + + Clause-②: yes (narrowing) + + + + **BREAKING** for a reader of the settings audit trail, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D7; ruling record 6257510973, Q4 A). This is the expand half of the change: it adds the new write shape, and the 18.0 rows stay where they are and readable. No row an 18.0 install wrote is moved, deleted or re-stamped. + + - **Before.** A write of a global-scope settings key appended its history row to `sys_setting_audit`, stamped with the Default Organization under `single`. Any principal the settings read door let read the namespace could list it there. + - **Now.** That row goes to the new `sys_platform_setting_audit`. It has the same row shape, `scope` is always `global`, and there is no organization column (`systemFields: { tenant: false }`). Reading it through the generic data API requires the `manage_platform_settings` capability, as `sys_platform_setting` does, and each namespace's `readPermission` applies there too. Tenant and user rows stay in `sys_setting_audit`, stamped as before. + - **18.0 rows.** Global-rung rows an 18.0 install wrote stay in `sys_setting_audit`, stamped, until the upgrade ceremony moves them. The global rung's history is both tables: the sibling, plus `sys_setting_audit` rows at `scope = global`. + + **The remedy.** If you list or filter the global rung's settings history (`sys_setting_audit` with `scope = global`), read `sys_platform_setting_audit` as well, with a principal that holds `manage_platform_settings`. Select the 18.0 rows by `scope`, never by a missing organization: they carry the Default Organization. Nothing changes for a reader of tenant or user rows. +- fbafb85: feat(plugin-email,plugin-auth): identity mail — verification, password reset, magic link, the email-change notice, an account invitation — is recorded in `sys_identity_email`, a deployment-level sibling of `sys_email` with no organization column + + Clause-②: yes (widening) + + ADR-0131 D7, ruling of record 6257510973 (letter Q2 A). Expand-first: this release adds the new log and its write path, and keeps reading every row and job an 18.0 install left behind. It moves and deletes nothing. + + - **The new object.** `sys_identity_email` (`@objectstack/platform-objects`, registered by `EmailServicePlugin`) has `sys_email`'s fields, so the row shape is the same, and `systemFields: { tenant: false }`, so it has no organization column. It is append-only, readable over `get` / `list`, and gated by `manage_platform_settings` (like `sys_job`), because its rows carry password-reset and sign-in links. + - **The contract.** `SendEmailInput` and `SendTemplateInput` (`@objectstack/spec/contracts`) gain `identityMail?: boolean`. With `true`, the mail service writes the row to `sys_identity_email`. Without it, the row goes to `sys_email`, as before. `identityMail: true` together with an `organizationId` is refused as `VALIDATION_FAILED` and recorded as a rejected row in `sys_identity_email`. The organization is not written. + - **One pipeline, both logs.** The queue worker, the outbox drain hook, the boot outbox sweep, attachment reclamation and the internal-header readback read a row from the log it lives in. A delivery or reclaim job for a `sys_identity_email` row carries `object: 'sys_identity_email'` beside `rowId`. A job with no `object` reads `sys_email`, which covers every job an 18.0 process queued. A job naming anything else is refused at `error` and touches no row. `EmailPersistence.insert` / `update` / `readHeadersJson` take the log as an optional last argument. A custom implementation that ignores it keeps every row in its one store. `@objectstack/plugin-email` exports `TENANT_EMAIL_OBJECT`, `IDENTITY_EMAIL_OBJECT`, `EMAIL_LOG_OBJECTS`, `isEmailLogObject` and `EmailLogObject`. + - **plugin-auth.** The five identity sends say `identityMail: true` and name no organization, under every posture. Before, they carried the Default Organization under `single` and nothing under a wall. The organization invitation (`auth.invitation`) is tenant mail. It still carries its organization and still lands in `sys_email`. + - **What a reader of `sys_email` sees.** + - **Before.** Identity mail was a `sys_email` row: stamped with the Default Organization under `single`, organization-less under a wall. + - **Now.** New identity mail is a `sys_identity_email` row. Rows already in `sys_email` stay there and are still delivered, swept and reclaimed. + - **Remedy.** A report, export or query that looks for verification or reset mail reads both logs. For example: `ql.find('sys_identity_email', { where: { related_object: 'sys_user', related_id: userId } })` beside the same query on `sys_email`. + - **Inventory.** `os migrate organization-ownership`'s inventory lists `sys_identity_email` as deployment-level (column-drop fate, nothing to attribute). Moving the identity rows already in `sys_email` is an 18.x upgrade-ceremony step, not part of this release. +- 67b669e: The security catalog's activation door switches a position or permission set the environment authored, and a ledger write it refuses answers with the status its code means + + Clause-②: yes (widening) + + - **The door accepts environment-authored items** (`@objectstack/runtime`). `POST /api/v1/security/_activation/:type/:name` switched a position or permission set off and on only when a package shipped it. FROM: an item saved through the metadata door (`PUT /api/v1/meta/position/:name`, `PUT /api/v1/meta/permission/:name`) was refused `503` with `{ code: 'VALIDATION_FAILED', message: 'Package is required' }`, and nothing was written. TO: it is switched like a packaged one, `200`, and the resolver honours the row: a switched-off position stops granting its permission sets, and a switched-off set stops granting by every path. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared name `404`, a type with no switch or an audience anchor `400`, the last administrator `403`). + - **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, a hook's refusal keeps its own pair (the last-admin guard's `403 PERMISSION_DENIED`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. + - **The ledger row of an item no package ships carries no package** (`@objectstack/platform-objects`, `@objectstack/core`). `sys_metadata_activation.package_id` is no longer required. It is empty exactly when no package ships the item. It is not part of the row identity (`metadata_type`, `name`), and no reader decides anything from it. The column was already nullable on disk (ADR-0113: `required` is a write-time check), so there is nothing to migrate. + - **One spelling for "no package"** (`@objectstack/core`). `MetadataActivationRow.packageId` is optional. FROM: `packageId: string`, with `''` the only way to say "no package". TO: omit `packageId` for an item no package ships, and the store writes no value. `ObjectStoreMetadataActivationStore.setActive` refuses `packageId: ''` with a `TypeError` before it reads or writes anything, and `list()` returns `packageId` only when the row names a package. If your code passes `''`, omit the key instead. + - **The flow row follows the shared row** (`@objectstack/service-automation`). `FlowActivationRow.packageId` is optional to match. Every flow row still names its package, because the flow toggle refuses a flow no package ships before it writes. + - **The action activation door's answer for an action no package ships changes; the action is still refused.** `POST /api/v1/actions/_activation/:object/:action` still passes `''` as the package of such an action. The write is still refused and still writes nothing, but the store's `TypeError` refuses it now, with the store's sentence. FROM: `503 VALIDATION_FAILED`. TO: `503 SERVICE_UNAVAILABLE`. An environment-authored action could not be switched on `main` before this change and cannot after it. +- 66949b7: feat(plugin-security)!: the four security-catalog row objects are retired — `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` (ADR-0131 D3) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Positions, permission sets and capabilities live in the security catalog — the environment registry, fed by package declarations and by environment metadata saved through `/api/v1/meta` (ADR-0131 D3). The four row objects that mirrored them are no longer registered, so their data and REST routes and their Setup object pages are gone. No table is dropped and no row is deleted. + + FROM → TO: + + - `GET|POST|PATCH|DELETE /api/v1/data/sys_permission_set` → `GET|PUT|DELETE /api/v1/meta/permission/:name`; to copy a set, `POST /api/v1/security/permission-sets/:name/clone` with `{ name, label }`. + - `… /api/v1/data/sys_position` → `/api/v1/meta/position/:name`. A position's permission sets are its definition's `permissionSets`. + - `… /api/v1/data/sys_position_permission_set` → the position definition's `permissionSets` (`PUT /api/v1/meta/position/:name`). + - `… /api/v1/data/sys_capability` → read-only `GET /api/v1/meta/capability`; a package declares a capability with `defineCapability` (`stack.capabilities`). + - Setup → Access Control → Positions / Capabilities / Permission Sets now open Setup's catalog lists (`/apps/setup/metadata/?scope=environment`) instead of the object pages; the `sys_position` record page (`SysPositionDetailPage`) is removed. + - An object permission on one of the four names in a permission set grants nothing and can be deleted. + + What else changes: + + - `sys_user_permission_set.permission_set_id` is a plain text column now, not a lookup: same column, same stored ids, same indexes. It is not written and grants nothing; a grant names its set by `permission_set`. + - A permission set saved through the metadata door is written to no row: the `permission` mutation projector, the `sys_permission_set` and `sys_position` data-door write-throughs and the row-only position backfill are no longer registered. + - On a fresh `single`-posture database the first human is promoted to platform administrator without reading any catalog row. An unscoped grant written before `sys_user_permission_set.permission_set` existed (it names no set) still withholds the promotion until the organization-ownership migration names it. + - Uninstalling a package still revokes every grant naming a permission set the package declared, by the names the uninstalling door captures before the withdrawal (`declared`, `captureUninstallDeclared`), and now by those names alone: the cleanup reads and removes no `sys_permission_set` or `sys_position_permission_set` row, so the `sets` and `positionBindings` counts on its outcome are always 0. A grant written before `sys_user_permission_set.permission_set` existed names no set and is not reached; the organization-ownership migration names it. + - On a `group` or `isolated` deployment the data-door half of the walled catalog refusal goes with the objects: a data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability` answers `404 OBJECT_NOT_FOUND` for every caller instead of `403 PERMISSION_DENIED` below the platform operator. The refusal at the `/meta` write doors, the permission-set clone door and the `/packages` write doors is unchanged. + - `capability-reference-unknown` no longer counts a `sys_capability` seed row as a declared capability — no runtime reader consults one. Declare the capability with `defineCapability`. + - On an existing database `os migrate plan`'s unmanaged-table sweep now lists the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` tables: they keep their rows and no object declares them any more. The sweep only reports; nothing is dropped, and the tables stay for the organization-ownership migration. + + Removed exports, no replacement: + + - `@objectstack/plugin-security`: the object definitions `SysPosition`, `SysPermissionSet`, `SysPositionPermissionSet`, `SysCapability` (from `./objects`); `securityObjects` lists the remaining three. + - `@objectstack/platform-objects/pages`: `SysPositionDetailPage`. + - `@objectstack/spec`: `SystemObjectName.POSITION`, `SystemObjectName.PERMISSION_SET`; the four names leave `PLATFORM_OBJECTS_BY_PACKAGE['plugin-security']` and `PLATFORM_PROVIDED_OBJECT_NAMES`. + - `@objectstack/core`: `isRowActive` and `ActivatableRow` — a catalog row's `active` column decides nothing; deactivation is the activation ledger's (`sys_metadata_activation`). + - `@objectstack/objectql`: the published `PLATFORM_OBJECT_TENANCY` record loses its `sys_permission_set` key (the other three names were never in it). A removed key of an exported constant is a narrowing, so `@objectstack/objectql` takes `minor` under the launch-window convention for breaking changes. +- 117d34d: feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: `sys_view_definition` retires as inert (ADR-0131 D13) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N` like every other stage. + + `sys_view_definition` was declared for runtime-authored shared and personal views (ADR-0017) and never used for them: no framework code writes or reads its rows, and the Studio console's view doors write the ADR-0005 `view` overlay in `sys_metadata` through the metadata API. ADR-0131 D13 retires it. A runtime-authored view is a `view` metadata item, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`); nothing about that path changes. + + **What moves for consumers.** + + - **The object.** FROM `import { SysViewDefinitionObject } from '@objectstack/metadata-core'` (or from `@objectstack/platform-objects`, or its `/metadata` subpath) TO nothing: delete the import. Neither `MetadataPlugin` nor the metadata protocol assembly registers the object any more, so the generic data door no longer serves it. + - **The migration exports** of `@objectstack/metadata-protocol`. FROM `ensureViewDefinitionActiveIndex`, `resolveIndexExec`, `buildActiveIndexSql`, `VIEW_DEFINITION_TABLE`, `VIEW_ACTIVE_INDEX_NAME`, `VIEW_ACTIVE_PROBE_INDEX_NAME`, `VIEW_ACTIVE_INDEX_COLUMNS`, `EnsureViewIndexLogger`, `EnsureViewIndexStatus` and `EnsureViewIndexResult` TO nothing: delete the import. `classifyIndexFailure` and the `IndexExec` type are still exported, unchanged, from the shared index-migration module. + - **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['metadata-core']` (`@objectstack/spec/system`) lists four objects, and `isPlatformProvidedObjectName` answers `false` for `sys_view_definition`. A stack that names it (a lookup target, a flow trigger, a permission entry, a platform-global declaration) is now flagged as naming an unknown object: remove the reference. + - **`os migrate duplicates`.** `runtimeIndexPreflight` carries three entries (the two `sys_metadata` overlay indexes and `sys_setting`'s row identity), with no `idx_sys_view_def_active` entry. A serving boot no longer runs the active-row index migration for the table, and no longer logs its `sys_view_definition active-row index` lines. + - **Translations.** The platform translation bundles no longer carry `sys_view_definition` keys. + + **Existing databases.** Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_view_definition`, with any rows a caller wrote through the generic data door; nothing reads them. `os migrate apply --allow-destructive` does not drop it either, because it reconciles declared objects only (measured on one database: it dropped an orphaned column of a declared table and left this table and its row in place). On a project with a host config, `os migrate plan` lists it among the platform-prefixed tables nothing declares. Export any row worth keeping; dropping the table is the operator's call, by hand: `DROP TABLE sys_view_definition`. +- 1920cf3: feat(platform-objects,service-automation,service-realtime)!: seven deployment-level platform tables lose their injected organization column, and reading them needs `manage_platform_settings` (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` hold deployment-level state. No writer attributes a row of any of them to an organization: every write is a system-context write whose row names none, and nothing writes `sys_presence` through ObjectQL at all. So the injected `organization_id` column only ever held NULL. ADR-0131 D7 takes it off: each object now declares `systemFields: { tenant: false }`. + + With no column there is no tenant wall, so these tables are governed by object permission. Each also declares `requiredPermissions: ['manage_platform_settings']`. Without that gate, a walled deployment's `organization_admin`, whose grant carries the superuser bits on every object, would read every other organization's job errors, queued payloads, dispatch keys and migration traces. + + **What moves for consumers.** + + - **The column.** `organization_id` is no longer a field of these seven objects. A filter, list-view column, report grouping, formula or seed key naming it on one of them is now an unknown field. Delete the reference: no organization owns a row of these tables. + - **Who reads, on a walled posture** (`group` or `isolated`). Before: the wall compared the NULL column to the caller's organization, so every reader got zero rows, platform administrators included (unless the deployment declared the table platform-global, which stood the wall down). Now: a principal holding `manage_platform_settings` (platform administrators hold it) lists every row; anyone else is refused `403 PERMISSION_DENIED`. + - **Who reads, on the `single` posture.** Before: any principal with a read grant on the object read every row, an organization administrator included. Now: only a principal holding `manage_platform_settings` reads; an organization administrator who is not a platform administrator is refused `403 PERMISSION_DENIED`. Grant the capability to an operator who needs these tables. + + **Unchanged.** Every platform writer and reader of these tables uses a system context, which no capability gate applies to, so job scheduling, the queue, flow dispatch, migration flags and the migration journal behave as before. The physical unique indexes are unchanged: none of these objects declares an organization-scoped one. + + **Existing databases.** Schema sync only adds, so the physical `organization_id` column stays on each existing table (with its index, where the deployment indexed it), and the boot drift report names it orphaned. By the writer census it holds only NULL, so dropping it loses nothing: `os migrate apply --allow-destructive` drops it, the remedy the drift report names. +- c52bfb4: feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less `sys_platform_setting`, and `sys_setting.scope` no longer declares `global` (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + A settings value for a key declared at `global` scope is a deployment-wide value: the mail transport, the SMS, storage, AI and knowledge providers, auth policy, the lifecycle retention defaults. It used to be a `scope: 'global'` row of `sys_setting`, a tenant-scoped table, where the injected `organization_id` column only ever held NULL and a walled posture hid the row from every reader. ADR-0131 D7 moves the rung out: it is now stored in a new platform object, `sys_platform_setting`. + + - **`sys_platform_setting`** (registered by the settings service, beside `sys_setting`): one row per `(namespace, key)` for the deployment, with the `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns of a settings row, and no `scope`, `user_id` or `organization_id` (`systemFields: { tenant: false }`). It is governed by object permission: a generic data read needs `manage_platform_settings` (`requiredPermissions`), which platform administrators hold. Writes go only through the settings door. + - **`SettingsService`** writes a global-scope key to `sys_platform_setting` and reads the cascade's global rung from there alone. Its `sys_setting` reads exclude `scope = 'global'`, so there is one source per rung. The cascade order, the lock semantics, `SpecifierScope` and the `source: 'global'` resolution value are unchanged. A global-scope change's `config_change` audit row names `sys_platform_setting` (`CONFIG_CHANGE_GLOBAL_OBJECT_NAME`); tenant- and user-scope changes still name `sys_setting`. + - **`sys_setting.scope`** no longer declares the `global` option: no write produces such a row. `sys_setting_audit.scope` keeps it, because a global-scope change is still audited there. + - **`os secret orphans` / `os secret rewrap`**: the settings family of the `sys_secret` reference union now reads both `sys_setting.value_enc` and `sys_platform_setting.value_enc`. Without that, every credential held at the global rung would read as unreferenced and be swept. An unreadable `sys_platform_setting` gaps the family, which refuses deletion. + + **What moves for consumers.** + + - **Existing databases — nothing moves automatically** (ADR-0131 D14). A `sys_setting` row at `scope = 'global'` is no longer read; until the v18 upgrade ceremony moves it, that key answers from its next rung or the manifest default. The ceremony moves each such row to `sys_platform_setting` by namespace and key, its `value_enc` handle included. An encrypted value needs no re-encryption: the ciphertext's associated data binds the settings scope, namespace and key, never the holding object or an organization. + - **Authored references.** A filter, list-view column or seed that names `scope = 'global'` on `sys_setting` matches nothing: point it at `sys_platform_setting`, which has no `scope` column. + - **Generic data reads of the global values.** Read `sys_platform_setting`; it needs `manage_platform_settings`. The settings door (`/api/settings/:namespace`) is unchanged and keeps applying each manifest's own read and write capability. + - **Kernels that register the settings objects by hand.** Register `SysPlatformSetting` (`@objectstack/platform-objects/system`) beside `SysSetting`; `SettingsServicePlugin` already does. The service reads both on every resolution, so a kernel missing one fails the read loudly rather than answering a cascade with a rung missing. +- 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. +- 77efc66: feat(platform-objects)!: Setup's organization, team, business-unit and user records carry no Discussion feed: `sys_organization`, `sys_team`, `sys_business_unit` and `sys_user` declare `enable.feeds: false`, so a comment create on one of their records answers `403 FEEDS_DISABLED` + + Clause-②: no (narrowing: the comment create door refuses on four platform objects that accept it today) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Setup's identity records are directory entries, not discussion surfaces. The four objects declared no `enable.feeds`, so they took the default (`true`), and the console drew "Discussion (0)" with a comment composer on every organization, team, business unit and user record. + + What changes: + + - **The record page.** The console reads the flag (`enable.feeds !== false`), so it no longer draws the Discussion feed or its composer on these four objects' records. The record's activity history is unchanged. + - **The data API.** A `sys_comment` create whose `thread_id` names one of these objects (`sys_user:USER_ID`, `sys_organization:ORG_ID`, `sys_team:TEAM_ID` or `sys_business_unit:UNIT_ID`) answers `403 FEEDS_DISABLED` and writes nothing, through REST, the SDK and every other caller of the data engine. An update that moves an existing comment into one of these threads is refused the same way. + - **Comments already stored** on these records are kept. They stay readable through the data API, and an edit that does not change `thread_id`, such as a body edit, still saves. + + Unchanged: every other platform object and every application object keeps its own `enable.feeds` setting, and an object that declares none still accepts comments. + + Remedy: stop creating comments on these four objects' records. A discussion about a user, a team or an organization belongs on a record of an object that keeps its feed on. +- e246f06: feat(plugin-auth,platform-objects,core)!: an API key an administrator's forced password reset revoked is restored by a platform administrator only; its owner creates a new key (#23993) + + **BREAKING** (an accept-set narrowing at the `sys_api_key` write door), shipped as `minor` under the launch-window convention for breaking changes. For an operator: a key revoked by `POST /api/v1/auth/admin/set-user-password` (the forced reset, which revokes the user's keys by default) can no longer be restored by its owner; a platform administrator restores it with the Restore API Key action, or the owner creates a new key. For an integrator: `PATCH /api/v1/data/sys_api_key/{id}` with `{ revoked: false }` on such a key now answers `403 PERMISSION_DENIED` for its owner, where it answered `200`; and `sys_api_key` gains a readonly `revoked_reason` column. + + Clause-②: yes (narrowing) + + What widens: `sys_api_key` declares one more field, `revoked_reason`. What narrows: the owner's restore of a key whose `revoked_reason` is `admin_password_reset` is refused, where it was admitted. + + + + #23287 ruling C′ makes the forced reset revoke the user's API keys by default, for a suspected compromise: a key that outlives the reset is the hole the reset was meant to close. Its changeset names who acts afterwards: a platform administrator restores such a key, or the owner mints a replacement. The owner could nevertheless restore it through the same door as any revoked key, because the row recorded no reason. Now it does. + + - **`sys_api_key.revoked_reason` (`@objectstack/platform-objects`).** A readonly `select` whose one value is `admin_password_reset`, set when the forced reset revoked the key and cleared when the key is restored. Empty for a key revoked from the API Keys list. Translated in `zh-CN`, `ja-JP` and `es-ES`. + - **The stamp (`@objectstack/plugin-auth`).** The forced reset's revocation writes `revoked_reason: admin_password_reset` beside `revoked: true`, the one place the reason is set. A key already revoked before the reset is not written, so it keeps no reason. + - **The restore guard (`@objectstack/plugin-auth`).** One `beforeUpdate` hook on `sys_api_key`, registered after the ADR-0092 D2 identity write guard. A user-context restore of a key carrying the reason is refused `403 PERMISSION_DENIED` unless the caller has platform-administrator standing (the ADR-0068 D2 rung); the refusal names both roads. Every restore it admits, the administrator's included, clears the reason. Revoking an already-revoked key leaves the reason in place, and the column is not on the D2 whitelist, so no data-API write can clear it. On a multi-row update every row is judged by its own stored state. System-context writes (the mint, the last-used stamp, the forced reset) are never refused. + - **The key door's message (`@objectstack/core`).** `API_KEY_REVOKED` for a key the forced reset revoked now says only a platform administrator can restore it; any other revoked key keeps today's sentence. Code, status and every carried field are unchanged. + + What changes for you: + + - **Refused now:** the owner restoring a key the forced reset revoked: `403 PERMISSION_DENIED`, naming the platform administrator and the new-key road. The administrator's restore works as before and clears the reason. + - **Unchanged:** revoking and restoring a key from the API Keys list, by its owner or an administrator; a forced reset with `revokeSessionsAndKeys: false`, which revokes nothing. + - **Keys revoked by a forced reset before this release** carry no reason, so their owner can still restore them. Such keys exist only where `18.0.0-next.2` or a `main` build since 2026-10-14 ran a forced reset; nothing backfills them, because the reset's audit row records counts, not key ids. +- 1173017: `sys_organization` now declares a `rename_organization` action ("Rename Organization") at `record_header` and `list_item`, so an organization record page can rename the organization. It collects `name` (required) and `logo`, both pre-filled from the record, and no `slug`. It calls better-auth's `/api/v1/auth/organization/update` exactly as `update_organization` does (the record's id as `organizationId`, the fields under `data`), and it is gated on `multiOrgEnabled` like the other organization actions. Because the request carries no slug, the organization keeps its slug. It is declared before `change_slug`, so on the record header it takes the third inline slot and `change_slug` moves into the `⋯` overflow menu. + + `update_organization` (name, slug and logo, list row only) and `change_slug` are unchanged. + + `@objectstack/spec` books the new action in `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs`, beside `update_organization`. + + Clause-②: yes +- f573976: `sys_organization`'s organisation-admin actions are now offered only to the membership grades whose requests the door accepts. Before, they were gated on `multiOrgEnabled` alone, so a plain member saw them, and better-auth refused every click with 403: + + - `update_organization` ("Edit Organization", list row) and `rename_organization` ("Rename Organization", record header and list row) declare `requiresMembershipReach: 'update_organization'`: owner and admin. + - `delete_organization` ("Delete Organization", list row) declares `requiresMembershipReach: 'delete_organization'`: the owner alone. + + The `multiOrgEnabled` gate stays as the last term of each predicate. `invite_user` is unchanged, and `set_active_organization`, `leave_organization`, `create_organization` and `change_slug` stay as they were. + + Clause-②: yes (widening) +- 2ec6827: feat(metadata-protocol): the draft save records `draftChanges` — which surface, actor and instant last changed each key path of a pending draft — and the strict draft read serves it + + Clause-②: yes (widening) + + - **Recorded by the draft save.** `saveMetaItem({ mode: 'draft', surface })` compares the body it writes with the body it replaces, inside the write's own transaction: the draft row, else (the draft's first save) the active row its publish would replace, else nothing, which records `[]`, the whole body. A path is the object keys from the body root to the deepest changed key; an array is one leaf, with no index. Each changed path is stamped `{ by: surface ?? 'api', actor, at }`, where `actor` is the value the save's history event carries (or `null`) and `at` is the write's instant as `toISOString()`. + - **Kept until publish or discard.** No two entries share a path: a save replaces every entry at or under each path it changes, so the longest prefix answers for a key. A save that changes no key path leaves the entries as they were. Publish and discard delete the draft row, and the entries with it, so the next draft starts clean. A `migrate-stored` rewrite and an active save record nothing. + - **Served on the strict draft read only.** `getMetaItem({ state: 'draft' })` answering a stored draft row carries `draftChanges` beside `item`. It is absent on the active read, the cached branch, the `?preview=draft` read, and a draft row whose saves recorded none (one saved before this release) — never `[]`. + - **Stored on the row.** `sys_metadata` gains a nullable `draft_changes` column (`@objectstack/metadata-core`'s `SysMetadataObject`), never inside the stored body. Schema sync adds it to an existing table on boot with no manual step (measured on SQLite and the memory driver). + - **Translated labels.** `@objectstack/platform-objects`' translation bundles carry the new column's label and help text in every shipped locale. + - **An unknown `surface` is refused.** In-process, a value outside `copilot | studio | api` is refused `400 INVALID_REQUEST` before anything is written. +- 8311650: The user page's "Permission Sets" list shows each assignment's validity window, `valid_from` and `valid_until` (ADR-0091 D1/D2) + + The resolver drops a `sys_user_permission_set` assignment outside its half-open `[valid_from, valid_until)` window at every evaluation, so a list that showed only the set presented an expired or not-yet-active assignment as a live grant. The two bounds now sit beside the set. The list's Add affordance still writes the user and the set only; the window is set by editing the assignment row. + +### Patch Changes + +- 1e8b628: feat(spec,metadata-protocol)!: the `/meta` door no longer authors sharing rules — `sharing_rule` is a code-only metadata type; Setup lists declared sharing rules + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D6, regime C). + + A sharing rule has two sources: a package declares it (the stack's `sharingRules`, switched off through the activation ledger), or an organization authors its own (`sys_sharing_rule`). A runtime metadata item was a third, and it bypassed the same-name guard and the organization wall. The narrowings, FROM → TO: + + - `PUT /api/v1/meta/sharing_rule/:name`, with a name nothing declares, in publish or `?mode=draft` — FROM `200`, with an environment-wide item stored and served by `GET /api/v1/sharing/rules`, TO `403 NOT_CREATABLE`, nothing stored. With a declared rule's name — TO `403 NOT_OVERRIDABLE`. + - `GET /api/v1/meta/types` — FROM `sharing_rule` advertised `allowRuntimeCreate: true`, TO `allowRuntimeCreate: false` and `allowEnvironmentOverlay: false`. + + **The fix:** author an organization rule, or clone the declared one. `POST /api/v1/sharing/rules` (Setup > Sharing Rules) creates an organization rule. To customize a declared rule, copy it to an organization rule under a new name, then switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/:name`, body `{ "enabled": false }`. A package declares its rules in the stack's `sharingRules`, as before. An item stored through the old door is still served until you remove it with `DELETE /api/v1/meta/sharing_rule/:name`. On an environment kernel, removing it needs `OS_METADATA_WRITABLE=sharing_rule`. That hatch is an explicit operator opt-in, not an authoring route: as for any code-only type, it reopens every write on the type, including creates of new names, and a sharing rule grants access. Unset it after the removal. + + Also in this change: + + - **`@objectstack/spec`.** `MetadataTypeSchema` (so the `MetadataType` union) and `DEFAULT_METADATA_TYPE_REGISTRY` gain `sharing_rule`. Its schema binding (`SharingRuleSchema`) moves from the non-kind collections into the kind map. `META_URL_TO_SINGULAR` gains the `sharing_rules` spelling, so `/api/v1/meta/sharing_rules` now folds to `sharing_rule`; before, that plural was not a recognised spelling. + - **`@objectstack/metadata-protocol`.** The refusal names the two sanctioned paths, not a source glob. `sharing_rule` gains its ADR-0126 Regime C row (clone, and the ledger switch). + - **`@objectstack/plugin-sharing`.** Setup's Access Control group gains **Declared Sharing Rules** (`nav_declared_sharing_rules`). It opens `/apps/setup/metadata/sharing_rule?scope=environment` beside **Sharing Rules**, which lists the organization's own rules. + - **`@objectstack/platform-objects`.** That entry's label, in `en`, `zh-CN`, `ja-JP` and `es-ES`. +- 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`. +- 0253192: Grants name their permission set, with no `sys_permission_set` row read (ADR-0131 D4) + + - `@objectstack/plugin-auth`: the self-registration grant names the declared set alone. Admission asks the security catalog and the activation ledger whether the set grants, and the settlement writes `{ user_id, permission_set }` with no row read, so a deployment with no `sys_permission_set` row for the set still grants it. The last-administrator guard reads which set a grant holds from its name only; writing `permission_set_id` moves no standing. + - `@objectstack/verify`: the RLS probe persona's grant names the registered probe set alone; it no longer needs, or refuses for want of, a boot-seeded `sys_permission_set` row. + - `@objectstack/platform-objects`: Setup's user page grants a permission set by name: the Add picker stores the set's machine name (`valueField: 'name'`, `linkField: 'permission_set'`), and the list shows the name column. The picker still lists `sys_permission_set` rows; listing the security catalog itself is a console change. +- 48cca4a: Setup's user page grants a position or a permission set by picking its name from the security catalog (ADR-0131 D4) + + Clause-②: no + + - `@objectstack/plugin-security`: `sys_user_position.position` declares `widget: 'position-ref'` and `sys_user_permission_set.permission_set` declares `widget: 'permission-ref'`, so the grant form offers a picker over the positions and permission sets the catalog serves, and stores the chosen name. A console without those widgets keeps the text input. `sys_user_permission_set.permission_set_id`, the legacy id that grants nothing, is now `hidden` and no longer appears on the grant form; the column stays until a later release drops its data. + - `@objectstack/platform-objects`: the user page's Positions and Permission Sets lists no longer carry the "Assign position" / "Grant permission set" buttons. Those buttons listed `sys_position` / `sys_permission_set` rows, which a deployment need not hold. Grant with the list's **New**, which opens the grant form; removing a grant is unchanged. +- bfc15d2: feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4) + + Clause-②: yes + + + + **BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes. + + **FROM → TO.** A position's permission sets were `sys_position_permission_set` rows. They are now declared in `permissionSets` on the position's definition; junction rows no longer grant. + + ```ts + definePosition({ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }); + ``` + + The one-line fix for an app that bound sets to its positions with a boot-time binder: name the sets in `permissionSets` on each position. + + **`@objectstack/spec`.** `PositionSchema` accepts `permissionSets`: a list of permission-set names (lowercase snake_case). Before, the key was refused with a pointer to the junction. Each name is resolved in the environment catalog when a holder's grants are resolved. A name the catalog does not hold confers nothing. + + **`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`): + + - A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. + - A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing — including a row inserted directly (a script or a harness, outside the product's doors). + - **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. + - The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition. + - `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. + - Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. + - An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. + - `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot. + + **`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it). + + **`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A ledger write that switches `admin_full_access` off is refused only when it would leave no administrator who can sign in, and switching it back on is never refused. The guard no longer judges `sys_permission_set` writes, because the resolver no longer reads that row. + + **`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES. + + **`@objectstack/plugin-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row. + + **What stops granting at upgrade, and what to do.** + + - **A deactivation made in Setup.** The Deactivate action on a position or a permission set writes the row's `active` column, which no longer switches anything off. ⚠️ Until the upgrade ceremony converts the rows (`convertDeactivatedCatalogRows`), a position or set deactivated that way **grants again**. No enable/disable door accepts the `permission` or `position` type yet, so a new deactivation has no in-product path. To revoke now, remove the assignments or the binding. + + - **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code. + - **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today. + - **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate organization-ownership --apply`) under `single` only, where its `authored-conversion` step applies `convertPositionBindingRows`. Under a wall (`group`, `isolated`) the ceremony converts no binding (ADR-0131 D3): its `rewrite` step refuses each binding whose position's definition does not name the bound sets, and the ceremony stops there until the operator acts and re-runs it. A position no catalog declares is declared with those sets in `permissionSets` through one door only: the deployment's code, or the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator). A declared position the metadata door can edit takes the missing sets through that door. A definition a package ships is sealed (ADR-0131 D6), and no door on the database adds sets to it. A name that organizations bound differently is reported `conflicting` and is never merged. +- 8c5aa50: feat(plugin-email,plugin-auth)!: an organization can no longer create or edit an email template row; the template provenance stamp and the auth SMS template seed retire + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing and three retired exports), shipped as `minor` under the launch-window convention for breaking changes. It carries ADR-0131 D6 and the maintainer's ruling C on ADR-0131 §6 Q1: email templates are not overridden per organization. + + **What stops being accepted.** + + - **The `sys_email_template` organization door is closed.** An organization's create and update of a template row are refused with `403 PERMISSION_DENIED`, and the message names the closed door: every engine `insert` / `update` whose context names a caller and is not system-elevated. That covers `POST` / `PATCH /api/v1/data/sys_email_template`, the Studio record editor, batch and import routes, and scripts and flows that run as a user or a service principal. System-context writes still pass: the built-in seed, the declared-template boot sweep, the live projection of a Studio `email_template` save into its row, and the v18 migration ceremony's promotion. Delete is not part of this door. + - **The template provenance stamp is retired.** It marked a package- or platform-seeded row `customized: true` when a non-system caller updated it. With the door closed no such update reaches the engine's write, so nothing marks a row any more. Rows already marked keep their mark and are still never overwritten by the boot seeders; they are the population the v18 migration ceremony promotes to environment-level Studio templates. + - **Retired exports of `@objectstack/plugin-email`:** `bindEmailTemplateProvenanceStamp`, `unbindEmailTemplateProvenanceStamp` and `EMAIL_TEMPLATE_PROVENANCE_PACKAGE`. They have no replacement. `EmailServicePlugin` closes the door itself, and there is no stamp left to bind, so a direct call is deleted, not rewritten. + - **The auth SMS template seed is retired.** A boot with phone sign-in on no longer writes the built-in OTP and invitation texts into `sys_notification_template` as rows. It was internal to `@objectstack/plugin-auth` and exported nothing. + + **What renders unchanged.** + + - Every email template renders as before: the template loader still reads `sys_email_template`, and a Studio edit of an `email_template` still reaches the mail at once and survives a restart. + - Every auth SMS text renders byte for byte as the seeded store rendered it, for every built-in text and every recipient locale. Each locale rung renders an operator's active row when one exists, and the built-in text where no row exists at that locale. A deactivated or blank row passes its rung on, as it did before. + - A `sys_notification_template` row an operator already has still wins, and no existing row is touched. + + **What changes for you.** To change what an email template sends, edit the `email_template` in Studio. A script or integration that wrote `sys_email_template` rows through the data API now receives `403 PERMISSION_DENIED`. `@objectstack/platform-objects` corrects the `is_system` and `customized` field help on `sys_email_template`, which said an organization may edit a row. +- 77a94d8: "Add Member" is offered only to a platform administrator, the one standing its endpoint admits. + + Clause-②: no + + - `sys_member`'s `add_member` toolbar action now declares `visible: 'current_user.isPlatformAdmin == true'`. `requiresFeature: 'organization'` composes onto it at parse time, so the served predicate reads `(current_user.isPlatformAdmin == true) && features.organization != false`. + - Its endpoint, `POST /api/v1/auth/organization/add-member`, has always admitted a platform administrator alone (ADR-0068) and answered every other caller, org owners and admins included, with 403 `PERMISSION_DENIED`. Before this change the button was still shown to every member of the organization. + - ⛔ Nothing you author changes. The endpoint and the callers it admits are unchanged, and no key, export or parameter is added. The action's label is unchanged. +- 879bd38: The user, OAuth-application and SSO-provider actions whose endpoint admits only a platform administrator are now offered only to a platform administrator. + + Clause-②: no + + - These thirteen actions now declare `visible: 'current_user.isPlatformAdmin == true'`, composed with their existing terms: + - `sys_user`: `ban_user`, `unban_user`, `unlock_user`, `create_user`, `set_user_password`, `impersonate_user` and `set_user_manager`; + - `sys_oauth_application`: `disable_oauth_application` and `enable_oauth_application`; + - `sys_sso_provider`: `register_sso_provider`, `register_saml_provider`, `request_domain_verification` and `verify_domain`. + - Where an action also carries `requiresFeature`, the feature gate composes onto it at parse time. For example, `ban_user` now serves `(current_user.isPlatformAdmin == true) && features.admin == true`. + - Each endpoint (`/api/v1/auth/admin/*`) has always admitted a platform administrator alone (ADR-0068) and answered every other caller, org owners and admins included, with 403 `PERMISSION_DENIED`. Before this change the buttons were still shown to those callers. + - `create_oauth_application`, `rotate_client_secret`, `delete_oauth_application` and `delete_sso_provider` are unchanged: their endpoints authorize the signed-in user or the record's owner, not the platform administrator. + - ⛔ Nothing you author changes. The endpoints and the callers they admit are unchanged, and no key, export or parameter is added. The actions' labels are unchanged. +- 1c563af: Setup's identity pages open on the tenant-wide list, not on the administrator's own rows. Before this, Setup → API Keys, Sessions, OAuth Applications, Identity Links and User Preferences opened each object's first declared list view, which was the caller-scoped "My …" view (`user_id = {current_user_id}`), so an administrator saw only their own keys, sessions, applications, links and preferences. + + Clause-②: no + + - On `sys_api_key`, `sys_session`, `sys_oauth_application`, `sys_account`, `sys_user_preference` and `sys_user`, the unscoped "All" view (`all_keys`, `all_sessions`, `all_apps`, `all_links`, `all_preferences`, `all_users`) is now declared first, and the caller-scoped view (`mine`, `me`) second. A route that names no view, such as a record page's object breadcrumb or the object switcher, now opens the "All" view. No view is added, removed or changed. + - The Setup entries `nav_api_keys`, `nav_sessions`, `nav_oauth_apps`, `nav_accounts` and `nav_user_preferences` now name that view with `viewName`, as `nav_users` already did. The Account app's Linked Accounts entry (`nav_account_linked`) now names `mine`, like the other Account entries, so neither app depends on the declared order. + - The "My …" views are still tabs on each page. The declared order decides which view opens, not which rows a caller may read: row-level security still scopes a member's rows. + - The generated translation bundles follow the new view order. No translated text changed. + - ⛔ No schema, parse, export or accept-set change. +- 51290bc: Form help and refusals an author reads no longer carry service-interface names, ruling dates or another product's ids + + Clause-②: no + + Wording only: no schema, key, type, export or error-code change. + + - The notify node's Template help (the `NotifyConfigSchema.template` describe and the Studio + inspector's copy in `@objectstack/service-automation`) names the deployment's default locale in + product words instead of `II18nService.getDefaultLocale()`, and drops its ruling date. + - `MANIFEST_ID_EXAMPLES` is now `com.acme.crm` and `org.example.help-desk` (was `com.steedos.crm` + and `org.apache.superset`). The package-id refusal opens with the headline + "Invalid package id 'VALUE'." and names the key in the sentence after it, so the headline alone + carries no JSON path; the rule, the examples and the suggestion follow unchanged in substance. A + caller that matched the old "on KEY. Expected reverse-domain notation" wording matches the + headline, or compares against `manifestIdRefusal()` by reference, instead. + - Ruling dates leave the describes Studio renders as form help: field `required` and `multiple`, + form-view field and section `visibleWhen`, section `collapsible` / `collapsed`, the redirect + arm's `submitBehavior.url`, and page `kind` / `source` (the ADR citations stay). They also + leave the refusals for padded grouping field names, `submitBehavior.url`, `features.*` in a + form-view predicate, and the four filter comparand refusals (null ordering comparand, + `{ $field }` in a list position, null list member, blank `$between` bound). Each sentence still + states the rule, why it exists and the repair. + - The email-template form's Identity section help says how senders address a template instead of + naming `IEmailService.sendTemplate`, in all four shipped locales. + - The action `description` help no longer ends in a dangling dash left behind by an earlier + strip: "(one dialog, not two —)" now reads "(one dialog, not two)". +- 4e4111c: The `sys_email` field help for Headers (JSON) and Status no longer names the `IEmailService.send` service interface (#22093) + + Clause-②: no + + Studio shows these descriptions as field help in the object forms. They now say what the field holds and when it is set, in product words: Headers (JSON) holds the custom headers supplied with the message when it was sent, and Status is `queued` when the message is submitted for sending, before the first delivery attempt. Every other fact the help states is unchanged. The `en` bundle is regenerated from the source, and the `zh-CN`, `ja-JP` and `es-ES` translations of the two leaves are re-translated in place. Wording only: no field, option, key or type changes. +- 0b7ba4d: fix(platform-objects): Invite User explains itself in its dialog and names the invitee in its success message + + Clause-②: no + + - **The parameter dialog.** `invite_user` on `sys_user`, `sys_invitation` and `sys_member` declares a `description`: "Invite someone by email address. They join this organization with the chosen role when they accept the invitation." The console shows it as the dialog's subtitle, where a generic "Please provide the required information to continue." showed before. + - **The success toast.** `successMessage` is now `Invitation sent to ${result.email}`. `POST /api/v1/auth/organization/invite-member` answers the invitation row, and its top-level `email` is the invitee's address as stored (lowercased), so the toast reads "Invitation sent to ada@example.com" where it read "Invitation sent". + - **Translations.** The zh-CN, ja-JP and es-ES bundles carry the new description and a success message that keeps the `${result.email}` token, so every locale names the invitee. +- 746637e: fix(service-automation)!: retire the `RunProvenanceContext` type; `RunDataContext` names only what `resolveRunDataContext` returns + + Clause-②: no (narrowing) + + + + **BREAKING** for TypeScript code that imports from `@objectstack/service-automation`: one type export is removed and one type narrows. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes in any package. + + **What is removed.** The `RunProvenanceContext` type export, the `{ flowRunId }`-only "provenance" envelope, and its arm of `RunDataContext`. No code in this repository builds or reads one. Since ADR-0096 D5, the data engine's security middleware refuses a context that carries no principal and is not a system context (`403 PERMISSION_DENIED`, for every verb). So the type described a context the engine refuses, while its docstring said such a context was "indistinguishable from passing no context at all" and was let through. + + **What `RunDataContext` is now.** Exactly what `resolveRunDataContext` returns: `isSystem: true` for a `runAs: 'system'` run, and the triggering user's principal for a `runAs: 'user'` run. A run that resolves no principal is refused before any data operation (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`), as before. + + **If your code named the type.** There is no replacement, because the context it described is refused. A custom data node builds its context with `resolveRunDataContext(context)`, typed `RunDataContext`. + + **`@objectstack/plugin-security`, `@objectstack/runtime`, `@objectstack/service-analytics`, `@objectstack/platform-objects`:** comment and docstring text only. Shipped comments that said the security middleware hands through, skips or falls open for a context with no principal now say that it used to, and that ADR-0096 D5 refuses such a context. Nothing these packages do changes. +- a00cf99: Studio's object form now offers the `approvalsVisibleToReaders` row in its capabilities panel, with its label and help text in the metadata-form translation catalogs for `en`, `zh-CN`, `ja-JP` and `es-ES`. + + Clause-②: no +- e223152: feat(spec)!: `PageSchema` refuses a page that authors both `slots.details` and `slots.tabs`; the `sys_user` record page carries its details grid as its first tab + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** The slot map read as seven independent slots, and two of them are not. On a slotted record page `details` replaces the body of the Details tab, that tab lives inside the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console's default-page synthesizer therefore reads `tabs` and never reads `details` when both are authored. A page authoring both passed `PageSchema.parse`, `objectstack validate` and the metadata save door, and its details body (its `sections`, its `hideFields`) silently never applied. The platform's own `sys_user` record page was one: its Identity and Audit sections never showed, and the ban columns it hides were never hidden by it. + + **What is refused.** A page whose `slots` map carries both a `details` and a `tabs` key, at `slots.details`, with a `custom` issue that names both slots and the fix. Either slot may be one component or an array; an empty `details: []` is refused like a full one, and the page's `kind` does not matter. That covers `PageSchema`, `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `os validate` and the metadata save door (`422 INVALID_METADATA`). The check is exported as `checkPageSlotPair`, for a mirror built from `PageSchema.shape` to re-attach. + + **What is still accepted, byte for byte.** A page authoring only `details`, only `tabs`, or neither. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `slots: { details: { type: 'record:details', … }, tabs: { type: 'page:tabs', properties: { items: [ … ] } } }` | `slots: { tabs: { type: 'page:tabs', properties: { items: [ { label: 'Details', children: [ { type: 'record:details', … } ] }, … ] } } }` | + | both slots, wanting the synthesized tabs with your details body | `slots: { details: { type: 'record:details', … } }`, with no `tabs` slot | + + **The one-line fix: move the `record:details` component into the `tabs` items (the first item's `children`, by convention) and delete `slots.details`.** Its `sections` and `hideFields` move unchanged. The page then shows the details body it always declared, which is a visible change on every page that authored the pair. + + **Who is affected, measured.** In this repository, only the `sys_user` record page (`sys_user_detail` in `@objectstack/platform-objects`) authored both slots; no example app page does. hotcrm's one slotted page authors `header` and `discussion` only. Deployed metadata was not measured. A page row already stored with the pair is replayed unchanged at load, so it renders as before (the authored tabs, without the details body); its read diagnostics name the pair, and saving it again is refused until the details body moves. + + ### The kit + + - **The refusal.** `checkPageSlotPair`, attached to `PageSchema` by identifier beside its three other object-level checks. No new error code. + - **The producer.** `sys_user_detail` moves its `record:details`, with its `sections` and `hideFields` unchanged, in as the first `tabs` item. + - **The ledger.** The D3 semantic entry `page-slots-details-beside-tabs-refused` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: which tab item carries the details body, and under which label, is the author's decision. +- e5899a6: `fileAccessDelegate` and the refused file marker now describe what the delegate decides on a record read, beside the download + + Clause-②: no + + Wording only: no key, type, schema shape or runtime behaviour changes. A record read now follows the download door's field-owned verdict for a file the record owns, so the texts an author reads understated what a declared delegate decides. + + - **`fileAccessDelegate`.** This covers the object schema's description and TSDoc, the object form's help text in `en`, `zh-CN`, `ja-JP` and `es-ES`, and the generated reference pages. The named service authorizes downloads of files owned by the object's media fields. It also decides whether a reader who may not read `sys_file` sees those files' name, size and type in a record read of the object. It is asked once per owning record on each such read, so an implementation runs on reads, not only on downloads. A reader who may read `sys_file` never reaches it on a record read. It fails closed on both paths. + - **`FileRefusedValueSchema` (`{ id, metadataRefused: true }`).** The TSDoc and the `metadataRefused` description now give two cases for a reader who may not read `sys_file`: the record being read does not own the file, or the download verdict on the owning record did not allow it. A file the record owns and the verdict allows is served as the full file object. + - **`IFileAccessDelegate`.** The interface's TSDoc and its `authorizeFileRead` doc now name both questions one verdict answers: the download, and whether a reader refused `sys_file` sees the file's name, size and type in a record read (asked once per owning record per such read). The warning now covers metadata too: a permissive implementation leaks the metadata as well as the bytes. + + **For authors.** Nothing to change in metadata. An existing delegate now also decides a refused reader's file metadata on every read. It should keep applying the same rule its service uses to read the record. +- e865300: A formula field can declare a currency result: `returnType: 'currency'`, with its currency in its own `currencyConfig` + + Clause-②: yes (widening) + + - **What is new in `@objectstack/spec`.** `FieldSchema.returnType` accepts `'currency'` beside `'number'`, `'text'`, `'boolean'` and `'date'`. A `currency` result is an amount of money. Its value is a bare number, as a currency field's is. Its currency is the formula field's own `currencyConfig`, the same `CurrencyConfigSchema` a currency field carries, with the same defaults and the same refusals: no `currencyConfig` means dynamic mode (the tenant default currency), and `{ currencyMode: 'fixed', defaultCurrency: 'USD' }` fixes it. Nothing is read at display time from the fields the expression references: the currency is a declaration on the formula. + - **Readers.** The filter doors judge a formula returning `currency` as the currency field it reads like: a text operator on it is refused (`INVALID_FILTER`), and the number door judges its comparands. A formula is still not title-eligible unless it returns `text`, and is still refused for `sum` / `avg` / `min` / `max` (it has no stored column). + - **Designer forms.** The field designer and the object designer's field grid both offer Currency as a return type. The field designer shows its Currency Config row for a formula whose return type is Currency, as it does for a currency field. + - **What is new in `@objectstack/formula`.** `inferFormulaReturn(expression, fields)` returns the declaration authoring stamps onto a formula field, judged over the host object's declared field map: `{ returnType: 'currency' }` when the expression is provably an amount of money, plus `currencyConfig: { currencyMode: 'fixed', defaultCurrency }` when its source amounts are in one fixed currency. "Provably money" is unit checking over the expression: amounts in one currency added, subtracted, scaled by a number or a percent, divided by a number, rounded, or picked by `min` / `max` / `coalesce` / a ternary. Two different currencies, an amount multiplied by an amount, or an amount plus a plain quantity are not money. For everything it cannot prove to be money it answers exactly what `inferExpressionType` answers, so a numeric formula stays `number`. Its types `FormulaReturnDeclaration` and `FormulaSourceField` are exported with it. `inferExpressionType` is unchanged. + - **`@objectstack/platform-objects`.** The designer help text for the return type and the currency config rows names the currency result, in all four locales. + - **Nothing to migrate.** Every value accepted before is accepted now. To declare a formula over money as money, declare `returnType: 'currency'` and copy the source currency field's `currencyConfig` onto the formula, or run `inferFormulaReturn` and write what it returns. +- 31b5a5f: The Setup "Create User" and "Set Password" dialogs no longer offer a "Generate Temporary Password" box the server ignores once a password is typed + + Clause-②: no + + The `sys_user` actions `create_user` and `set_user_password` showed two controls for one decision: a "Generate Temporary Password" box (ticked by default on Create User) and a password field labelled "leave empty to generate". With a password typed, the box did nothing, yet it was sent ticked, so its state was false whenever the field was filled. + + The password field's label is now the only rule. `generatePassword` is no longer a dialog param on either action. It is sent as a constant `true` in each action's `bodyExtra`, and the endpoint's existing rule gives the label's behaviour: + + - **A typed password** is the one applied, and nothing is generated. + - **An empty password** generates a temporary password, shown once in the result dialog. On Set Password this is new: the box was unticked by default there, so an empty field answered `400` unless the admin ticked it. + + The endpoints `POST /api/v1/auth/admin/create-user` and `POST /api/v1/auth/admin/set-user-password` are unchanged. A caller that sends neither a password nor `generatePassword: true` still gets `400`. +- e5687cb: An `email` field can declare that it identifies a correspondent for inbound mail: `mailAssociation` + + Clause-②: yes (widening) + + - **`mailAssociation: boolean`** (optional, no default) on a field definition. TRUE marks this email field as identifying a message correspondent: when a mailbox service ingests a message, it associates the message with every record of the object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Absent or FALSE: the field takes no part in matching. + - **The platform learns which fields carry a correspondent's address, never what a contact is.** Every object that flags a field is treated alike; no app code and no resolver service is involved. + - **The door that judges it.** `FieldSchema` refuses the key on any type other than `email`, naming the field's type and the fix (change the type to `email`, or drop the key). Every metadata that parsed before parses unchanged. + - **Who reads it.** Nothing yet: the mailbox service's ingest is the reader, and it ships separately. Until then `objectstack validate` and `objectstack lint` warn an author who sets it (`liveness-planned-property`) that no mailbox service reads it yet. + - **Where to author it.** The field designer offers it on email fields as "Match Inbound Mail". + - **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies `mailAssociation` as `presentation`: the mailbox service reads it, never `createColumn`, so a declaration on a builtin column name is not reported as discarded storage. No behaviour changes. + - **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` classifies `mailAssociation` as `keep`: the FLS scrub of a served object definition passes the flag through unchanged, because it names no field. + - **`@objectstack/platform-objects`.** The field designer's new row carries its label and help text in the metadata-form translation bundles for en, zh-CN, ja-JP and es-ES. +- ec7f7f8: feat(spec)!: retire an email template's `category` and `isSystem` — the grouping and the built-in flag nothing read (#22876) + + **BREAKING** — `category` and `isSystem` on an email template (`EmailTemplateDefinitionSchema`, the `email_template` metadata type) are now refused at parse. Nothing read either key once templates stopped being copied into `sys_email_template` rows: the Studio listing never offered the filter facet `category` promised, and no door refused deleting a template that set `isSystem`. Delete both keys. A template is found by its `name`, so group templates with a dotted namespace prefix (`auth.password_reset`, `crm.welcome`). A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. + + Clause-②: no (narrowing) + + Measured before removal: no reader of either key in `@objectstack/plugin-email`, `@objectstack/service-messaging`, `@objectstack/plugin-auth`, the metadata protocol, ObjectQL, the runtime or the REST layer, and none in the Studio console at its pinned commit beyond a fallback form input, which the console's own companion change removes. Both keys carried a schema default (`category: 'custom'`, `isSystem: false`), so every parsed template carried them and the liveness advisory fired on every template, with no way for an author to clear it. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `category: 'auth'` (or `'notification'`, `'workflow'`, `'marketing'`, `'custom'`) | nothing — delete the key; group by the `name` prefix | + | `isSystem: true` | nothing — delete the key | + | `isSystem: false` | nothing — delete the key | + | `import { EmailTemplateDefinitionCategorySchema } from '@objectstack/spec/system'` | nothing — the enum is removed with its key | + | `type EmailTemplateDefinitionCategory` | nothing — the type is removed with its key | + + **The one-line fix:** delete `category` and `isSystem` from every email template. + + **What an author who still writes either sees.** `tsc` fails at the authoring site (`EmailTemplateDefinition` types both keys as retired), and the parse — `defineEmailTemplateDefinition()`, `defineStack({ emailTemplates })`, `PUT /api/v1/meta/email_template/:name` — refuses each at its own path with the prescription: + + > `email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). 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. + + > `email_template.isSystem` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. 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 + + - **Two `retiredKey()` tombstones** on `EmailTemplateDefinitionSchema`, a `strictObject` — so the refusal carries the prescription rather than a bare unknown-key report, and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `system/EmailTemplateDefinition:category`, `system/EmailTemplateDefinition:isSystem`. The `category` enum, `EmailTemplateDefinitionCategorySchema`, had no other consumer and leaves with it: `RETIRED_DEFS_BY_MAJOR[18]`: `system/EmailTemplateDefinitionCategory`. + - **The D2 conversion `email-template-category-is-system-removed`** (protocol 18, retired from the load path) deletes both keys from every `emailTemplates[]` entry, one notice per key, as a lossless delete. Because both keys carried a default, every built artifact and every stored `email_template` row written under 17.x carries them; both load through the rehydration seams, which replay this conversion. The retired-default residue stage is not adopted: the replayed conversion heals those populations, and an authored source that still writes either key meets the prescription. + - **The D3 entry `email-template-category-is-system-retired`** asks the author whether anything they built read either key — a report or integration that filtered templates by `category`, or one that treated `isSystem: true` as protection against deletion. Nothing ever enforced that. + - **Studio:** the email-template form drops the `category` select and the `isSystem` checkbox. + - **Ledger:** both rows stay `dead`, re-noted as tombstones (a tombstone keeps the key in the walked shape). + - **No deprecation window**, per the project's startup-stage posture. + + **`@objectstack/plugin-email`:** the six built-in auth templates (in every locale) no longer write `category: 'auth'` or `isSystem: true`. They render and send exactly as before. + + **`@objectstack/platform-objects`:** the metadata-form translation bundles drop the two retired form inputs' labels (`email_template.fields.category`, `email_template.fields.isSystem`). The `sys_email_template` object and its `category` / `is_system` columns are unchanged. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + +- 3071d3e: A host's translation bundle for a platform key now overrides the platform's text, whichever loads first + + Clause-②: no + + `PlatformObjectsPlugin` loaded its translation bundles (the Setup App, the `sys_*` objects, `metadataForms.*`) on `kernel:ready`, and the i18n service merges each load over the last. A bundle a host loaded earlier, during its own `init()` or `start()` (every `defineStack({ translations })` app is one), was overwritten for every key the platform also ships. Measured on both i18n providers: a host zh-CN bundle that set `objects.sys_organization.label` to 「工作区」 still served 「组织」. The docs already said such a bundle "would override the platform's". + + The platform bundles now load UNDER what the service already holds for each locale. + + - **A host's key wins in either order.** A bundle loaded before the platform's is laid back on top of it, and a bundle loaded after it wins as before. + - **Everything the host does not ship still serves the platform's text.** That covers sibling keys of a host key (a host field label leaves the platform's `help` for that field in place), and every locale the host does not ship. + - **Studio-authored translations are unaffected.** The bundles now load in `start()`, as soon as an i18n service exists, which is before the authored overlay is synced at `kernel:ready`. Deleting an authored override of a platform key brings back the platform text, as before. A kernel whose i18n service only appears later in Phase 2 (an `AppPlugin` registering the in-memory fallback on a lean kernel) gets the bundles on `kernel:ready` instead. + - **A provider without `getTranslations`** still gets the platform bundles, loaded over what is there as before, and the boot warns once that host bundles loaded earlier do not override it. + + No platform text changes: zh-CN `sys_organization` is still 「组织」. A host that wants different text ships it in its own bundle. The other platform packages' bundles share no key with this package's, and no example app's bundle repeats a platform key, so nothing else changes what it serves. +- 5f37d36: feat(spec)!: at most one `isDefault: true` record page per object per deployment — a second is refused at `defineStack` / `os validate`, at package registration and at metadata save, naming both pages (#23015) + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + + `PageSchema.isDefault` now states the rule (maintainer ruling A on #23015): at most one `isDefault: true` record page may exist for an object in a deployment. The record view opens that page with no tie-break, so with two defaults for one object the page that opened used to be whichever plugin initialised first — an accident no author wrote. A record page is `type: 'record'` (the default when `type` is left out) bound to an `object`; `isDefault` on any other page is outside the rule. + + **What is refused, and where.** Every door that can add a second default refuses it, naming both pages: + + - **`defineStack`** (and so `objectstack validate`, `objectstack build` and a source boot) — two default record pages for one object inside one stack, `STACK_CROSS_REFERENCE_INVALID` (422), one finding per object naming the object, every page and its position (`stack.pages[0]`). **`composeStacks`** refuses the pair across the stacks it composes, in the same envelope; under `manifest: 'preserve'` one page name in two stacks is two packages' pages. + - **Package registration** (`@objectstack/objectql`'s `SchemaRegistry`, at `installPackage` ahead of every mutation and at the `registerItem` seam) — a package whose default record page meets another package's default for the same object, an environment's stored default, or a second default in its own manifest, `DEFAULT_RECORD_PAGE_CONFLICT` (422, exported as `DEFAULT_RECORD_PAGE_CONFLICT_CODE`). Two packages' pages under one name are two pages and are refused too. `OS_METADATA_COLLISION=warn` does not soften it. + - **Metadata save** (`@objectstack/metadata-protocol`'s `saveMetaItem`, so `PUT /api/v1/meta/page/:name`) — a page saved as the default record page of an object a stored page or a packaged page already holds, `DEFAULT_RECORD_PAGE_CONFLICT` (422), in draft and publish mode, before anything is written. + + **Unchanged.** A save under the existing default's own name is that page (an ADR-0005 overlay by name), not a second default. One default per object, a second record page that is not a default, and two objects with one default each are accepted. The same package registering again — a reload, or an upgrade that moves its default to another of its pages — is one holder. A save under the name of a page a package ships is still answered by the packaged-base refusal (`NOT_OVERRIDABLE`, 403), as before. A stored page reaching the registry from `sys_metadata` (hydration, write-through) is not refused again. + + ## FROM → TO + + | door | FROM | TO | + |:--|:--|:--| + | `defineStack` / `objectstack validate` | `pages: [{ name: 'account_a', type: 'record', object: 'crm_account', isDefault: true }, { name: 'account_b', type: 'record', object: 'crm_account', isDefault: true }]` accepted; the boot kept both and the record view opened whichever registered first | refused, `STACK_CROSS_REFERENCE_INVALID` 422 — set `isDefault: false` on all but one | + | `composeStacks` | two stacks each shipping a default record page for one object composed clean | refused in the cross-reference envelope, naming both stacks' pages | + | package registration | a second package (or a plugin's `manifest.register`) shipping a default record page for an object another package holds registered; init order decided the page | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, naming both pages; keep one default, or compose the deployment without the package that ships the other | + | metadata save | `PUT /api/v1/meta/page/my_page` with `isDefault: true` for an object that already has a default record page stored or shipped saved | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, naming both pages; save it with `isDefault: false`, or first demote the stored default | + + **The one-line fix: keep `isDefault: true` on the one record page per object that should open the record view, and set `isDefault: false` on every other.** To replace a page a package ships, compose the deployment without that package's page; shipping a second default is not an override path. + + No D2 conversion rewrites it: which page stays the default is the author's call. Its D3 record is the semantic entry `page-default-record-page-one-per-object-refused` (protocol 18). + + **`@objectstack/spec`.** `defaultRecordPageObject(page)` (exported from `@objectstack/spec/ui`) is the one reading of "which object is this page the default record page of" that the three doors share. `PageSchema.isDefault` gains its `.describe()`. `DEFAULT_RECORD_PAGE_CONFLICT` joins the error-code ledger under `@objectstack/objectql` and `@objectstack/metadata-protocol`. + + **`@objectstack/objectql`.** The registration refusal, `SchemaRegistry.defaultRecordPageHoldersOf(object)` (every page holding the object's default — the reading the save door meets with the stored pages) and the `DEFAULT_RECORD_PAGE_CONFLICT_CODE` constant. + + **`@objectstack/metadata-protocol`.** The save-door refusal. + + **`@objectstack/platform-objects`.** The page form's `isDefault` help text, which said "Set as default page for this page type" — a rule nothing enforced — now states the one-default-per-object rule, in all four locales. + + **Who is affected, measured.** In this repository every object with a default record page has exactly one: `sys_organization` and `sys_user` (`@objectstack/plugin-auth`), `sys_position` (`@objectstack/plugin-security`), `sys_approval_request` (`@objectstack/plugin-approvals`), and `showcase_project`, `showcase_task` and `showcase_preference` (`examples/app-showcase`). A deployment that also composes a package shipping its own default record page for one of those objects is refused at boot until one of the two pages stops being a default. +- 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`. + + +- 8776119: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + feat(spec): `referenceDeletedWhen` beside `referenceVia` marks a polymorphic pointer's target as platform-deleted + + Clause-②: yes + + `FieldSchema` gains one optional key beside `referenceVia`: `referenceDeletedWhen: { field, value }`. On rows where the sibling `select` field `field` holds the option `value`, the record the pointer names was deleted by the platform's own assertion, and a renderer draws a tombstone from that option's own label instead of a link. Example: `record_id: Field.text({ referenceVia: 'object_name', referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' } })`. + + Any other shape is refused at parse, naming the field and the option: + + - the key on a field that declares no `referenceVia` (`FieldSchema`), or an unknown key inside it; + - a `field` that is the declaring field itself, is not declared on the object, is not a `select`, is a `multiple: true` select, or declares no inline `options` (a `picklist`-backed select); a `value` that is not one of the sibling's option values (`ObjectSchema`, at every door: `ObjectSchema.create()`, `defineStack`, raw `.parse()` and the metadata save door). + + Nothing that parsed before is refused: the key is new. Its liveness row is `planned` until the renderer that reads it ships. + + The other packages classify the new key where every field key is classified: + + - **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` deletes a served field's `referenceDeletedWhen` when its `field` names a field the caller cannot read (field-level security); `value` is an option value and never a field name. + - **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies it `presentation`: no DDL reads it. + - **`@objectstack/platform-objects`.** The field form's new `referenceDeletedWhen` row carries its labels and help text in zh-CN, ja-JP and es-ES. +- 30ce56e: Clause-②: no + + Setup pages describe their records in plain admin sentences. Business Units now reads "Your + company structure — divisions, departments, regions and offices — used to scope data and + reports.", Notification Events "Every notification the platform sent, one row per event.", + Notification Preferences "Which topics and channels each user receives; admins set the + defaults.", Notification Templates "The subject and body each notification topic sends, per + channel and language." and HTTP Deliveries "Outgoing HTTP calls made by webhooks and flows, one + row per call, with its delivery status." Field help on Users, Organizations, Invitations and SSO + Providers, and the metadata-storage and notification-delivery object descriptions, no longer + carry design-record numbers; those references stay in the code comments. The zh-CN, ja-JP and + es-ES translations follow. Wording only: no field, permission or behaviour changes. +- 2e0e451: feat(platform-objects): Setup's menu no longer lists "User Preferences" or "Identity Links". You reach both from the user they belong to: open a user in Setup › Users, and the user's page has a "Preferences" tab and its existing "Linked Accounts" tab. + + Clause-②: no + + Both lists held rows that belong to one user each: identity links are written when a user signs in with a provider, and preferences are what the console remembers as a user works (recent items, favourites, list settings). A menu entry listing them for every user at once was not a page an administrator works from. + + - **Where they are now.** On a user's page, "Preferences" lists that user's preference rows (key, created, updated) and "Linked Accounts" lists their identity links, with the same Unlink action. New, Edit and Delete on the Preferences tab follow the same rules as the old list did. Search finds the user, and the tabs are on the user's page. + - **What did not change.** The two objects, their list views and their addresses are unchanged, so a bookmarked list still opens, and a preference row's breadcrumb still leads to the full list. + - **Nothing to migrate.** No data moves and no setting changes. +- 1ecb08a: Clause-②: no + + The `description` of four metadata types in `DEFAULT_METADATA_TYPE_REGISTRY`, which Setup shows under the + type's title, is now one plain sentence for an administrator instead of ADR vocabulary: + + - `api`: "A stable HTTP URL that other systems call to run a flow or work with records in the app" + - `doc`: "A help page written in Markdown that a package ships or you write yourself" + - `book`: "A table of contents that groups help pages into ordered sections" + - `capability`: "A permission a package defines; you grant it through permission sets" + + The ADR references (ADR-0121, ADR-0046, ADR-0046 §6, ADR-0066 D1) moved to the comments beside the + registry rows. The generated metadata-forms translations carry the new sentences in en, zh-CN, ja-JP + and es-ES. No key, type or accept/refuse result changes. +- adad73f: feat(spec)!: a select option declares its lifecycle `kind` (`open` / `won` / `lost`), and every option of an object's `stageField` must declare it + + Clause-②: yes (narrowing) + + `SelectOptionSchema` gains one optional key, `kind`, a closed vocabulary of three lifecycle categories that means the same on every object: `open` (a step on the way), `won` (the successful end: a deal's Closed Won, a lead's Converted, a task's Done, a case's Resolved) and `lost` (the unsuccessful end: Closed Lost, Unqualified, Cancelled). It is the spelling `record:path` `stages[].terminal` already takes, so the data layer and the page say one thing one way. Example: `{ label: 'Closed Lost', value: 'closed_lost', kind: 'lost' }`. The exported type `SelectOptionKind` names the three values. + + - On a plain single-value `select` / `radio` the key is optional, and an absent kind reads as `open`. Nothing is written in at parse: an option without `kind` parses without it. + - On any other field's options (`multiselect`, `checkboxes`, `tags`, a `multiple: true` select, a lookup's static options) `FieldSchema` refuses it at `options..kind`: a lifecycle is one value per record. + - A shared `picklist`'s options accept it (the same option shape). A form view's option list does not (`FormSelectOptionSchema`): it is refused there as an unknown key whose message points at the object field's own option. + + **BREAKING (authoring):** when an object's `stageField` names a field, every option of that field must declare `kind`. `ObjectSchema` refuses the object otherwise, at every door that parses one (`ObjectSchema.create()`, `defineStack`, `os validate`, `os build`, the metadata save door), with one issue per option at `fields..options..kind` naming the field and the option. There is no default under `stageField`. Also refused, at `stageField`: a stage field that takes its options from a `picklist` (the object cannot see them to check), and a stage field carrying options that holds no single option code per record (`multiselect`, a `multiple: true` select). Unchanged: `stageField: false`, a `stageField` naming no declared field, and one naming a field with no options. + + Migration: add `kind` to each option of the stage field, marking each terminal state `won` or `lost` and every other state `open`: + + - `status: Field.select({ options: [{ label: 'New', value: 'new' }, { label: 'Converted', value: 'converted' }, { label: 'Unqualified', value: 'unqualified' }] })` with `stageField: 'status'` → the same options with `kind: 'open'`, `kind: 'won'` and `kind: 'lost'`. + - A picklist-backed stage field → declare its options inline on the field. + - An object with no linear lifecycle → `stageField: false`. + + No conversion makes this edit, because which option ends a record's life successfully is the author's to say. `os migrate meta` lists the D3 entry `stage-field-option-kind-required` with the prescription. + + The Studio object designer's options repeater offers `kind`; the field form's options repeater lists it. + + The other packages follow the new key: + + - **`@objectstack/platform-objects`.** The metadata-form translation bundles (`./metadata-translations`) carry the options repeater's new `kind` row, with its label and help text in zh-CN, ja-JP and es-ES. + - **`@objectstack/metadata-core`.** The published contract fixture `FLS_CONTRACT_OBJECT` (`@objectstack/metadata-core/testing`) names `salary_grade` as its `stageField`, so its two options now declare `kind: 'open'`, and the fixture meets the stage-field rule: `ObjectSchema` raises no issue at `stageField` or under `fields.salary_grade.options`. The fixture is not otherwise a spec-valid object (its `script` actions carry no `body` or `target`), and this change leaves that as it was. + + +- 8f4260f: Setup's Invitations, API Keys, OAuth Applications and Notification Preferences, Subscriptions and Templates lists say what belongs on them when they are empty, and API Keys gains a Create Key button (#23852) + + Clause-②: no + + **What an admin saw.** An empty Invitations, API Keys or OAuth Applications list read "No identity records — these records are created by the authentication provider … not added by hand here", which is false for all three. The three notification lists read the generic "Nothing here yet". API Keys had no way to create a key at all. + + **What changes.** + + - Every list view of `sys_invitation`, `sys_api_key` and `sys_oauth_application` declares its own `emptyState` (a title and one sentence), and a filtered view names itself: Invitations opens on "No pending invitations", the Revoked keys view reads "No revoked keys". + - `sys_notification_preference`, `sys_notification_subscription` and `sys_notification_template` each ship one list view, `all`, declared only for its `emptyState`: it sets no columns (`columns: []`, so the grid keeps the columns it derived before), no filter, no sort and no page size. + - `sys_api_key` gains `create_api_key` ("Create Key"), a primary list-toolbar action on every API Keys list, Setup's and the Account app's. It calls the existing `POST /api/v1/keys` route with the name you give it; the key is created for you, in the organization you are working in, and the raw key is shown once in a dialog you close after copying it. Nothing about the route changes, and the data API still does not create keys. + - The new copy ships in `en`, `zh-CN`, `ja-JP` and `es-ES`. + + Unchanged: every field, index, permission and the `revoke_api_key` / `restore_api_key` row actions. +- d0fe050: Clause-②: no + + Six help texts an administrator reads in Setup no longer carry ADR numbers. On the Permission Sets edit form, + `isDefault`, `managedBy` and `packageId` are rewritten as plain sentences that say what the field does, and + `adminScope` drops its "(ADR-0090 D12)" prefix. On the Positions edit form, `delegatable` drops "(ADR-0091 D3)". + The view form's End-user controls description drops "(ADR-0047, Airtable Interface parity)". + + The ADR references moved to the comments beside the form rows. The generated metadata-forms translations carry + the new wording in en, zh-CN, ja-JP and es-ES. No key, type or accept/refuse result changes. +- f7af839: Clause-②: no + + Setup's Diagnostics group has one audit entry, "Audit Log", and it opens the audit browser + + - **What changed.** `@objectstack/plugin-audit` contributed two Setup entries onto the same rows: "Audit Logs" (the `sys_audit_log` record list) and "Audit Log Browser" (the console's `audit:log` page). It now contributes one, "Audit Log", which opens the browser. The browser filters by action, object, actor and date, offers the record list's views (Writes, Auth, Record Views, …) as presets, and links to the record list ("Open as list"). Those two parts of the page ship in the console this release pins. + - **Ids.** The entry keeps the id `nav_audit_logs`, so a sidebar pin a user set on "Audit Logs" stays on the new entry. The id `nav_audit_log_browser` is no longer served; a pin set on it no longer shows, and the user can pin "Audit Log" instead. + - **Labels.** `@objectstack/platform-objects` serves the entry's label in the four shipped locales (Audit Log, 审计日志, 監査ログ, Registro de auditoría), and drops the "Audit Log Browser" label. + - **Unchanged.** The `sys_audit_log` object, its list views, its routes and its data API answer as before. The record list is no longer an entry in Setup's menu, so the ⌘K palette's record search in Setup no longer searches audit rows. +- bbdfc05: feat(spec)!: retire the permission set's authored `managedBy` — a provenance key nothing read (#23891) + + **BREAKING** — an author can no longer write `managedBy` (`'package' | 'platform' | 'user'`) on a permission set (`PermissionSetSchema`, ADR-0086 D3): it is refused at parse. Nothing ever read the authored key. No seeder, package upgrade or lock consulted it, so `package` never made a set upgrade-owned, and `platform` or `user` never kept an upgrade off a set. Delete the key. A set's provenance is the package that ships it, decided from that package's artifact and recorded as `packageId`; a set created in this environment carries neither. The `sys_permission_set.managed_by` column is a different surface and is untouched, as is `ObjectSchema.managedBy`. + + Clause-②: yes (narrowing) + + Measured before removal, at `6d1660dc3b`: no shipped permission set authored the key (the example apps, `plugin-security`'s default sets, and HotCRM 4.0.1's seven sets at `1d7148bf2d`: zero, against seven `objects:` controls). Its one writer was Setup's Permission Sets form row, which wrote it into stored rows. The liveness ledger had ruled it `dead` on 2026-10-11. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `definePermissionSet({ …, managedBy: 'package' })` | nothing: delete the key; a package-shipped set is the package's because the package ships it (`packageId`) | + | `managedBy: 'platform'` or `managedBy: 'user'` | nothing: delete the key; a set created in this environment carries neither `managedBy` nor `packageId` | + | relying on `managedBy` to keep a package upgrade off a set | clone the packaged set and edit the clone (the packaged-set lock refuses in-place edits) | + + **The one-line fix:** delete `managedBy` from every permission set. + + **What an author who still writes it sees.** `tsc` fails at the authoring site (`PermissionSet` types the key as retired), and the parse (`definePermissionSet()`, `defineStack({ permissions })`, `PUT /api/v1/meta/permission/:name`) refuses it at its own path with the prescription: + + > `permission.managedBy` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0086 D3) — nothing ever read the authored key: no seeder, package upgrade or lock consulted it, so `package` never made a set upgrade-owned and `platform` or `user` never shielded one from an upgrade. Delete the key; a set's provenance is the package that ships it, decided from that package's artifact and recorded as `packageId`, and a set created in this environment carries neither. 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. + + **What does not stop on it.** A stored `permission` row that Setup's form wrote, and an installed package's built artifact, still load: the rehydration seams (`applyConversionsToStoredItem`, `applyArtifactForwardConversions`) replay the D2 conversion below and strip the key, with a notice naming the set. Only the authoring, save and publish doors refuse it. + + ## The retirement kit + + - **A `retiredKey()` tombstone** on `PermissionSetSchema`, a `strictObject`, so the refusal carries its prescription and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `security/PermissionSet:managedBy`. + - **The D2 conversion `permission-set-managed-by-removed`** (protocol 18, retired from the load path) deletes the key from every `permissions[]` item, whatever it holds, one notice per set. + - **The D3 entry `permission-set-managed-by-retired`** records the judgement the strip cannot make: any runbook that relied on the value to keep an upgrade off a set needs another path. + - **`@objectstack/platform-objects`:** the Permission Sets form's `managedBy` row, its help text and its three option labels (Package / Platform / User) leave Setup in all four locales. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED** beyond HotCRM 4.0.1. `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. Studio's provenance badge reads the value as one of three inputs; it retires in an objectui follow-up. + + +- a237d7f: feat(cloud-connection)!: Setup's "Installed Apps" entry opens Setup › Packages filtered to marketplace installs, and the separate Installed Apps page is no longer shipped + + Clause-②: no (narrowing) + + + + - `MARKETPLACE_INSTALLED_UI_BUNDLE.pages` -> delete (the bundle carries no page; its entry is read from `navigationContributions`) + + Setup › Packages is now the one list of what is installed: every row says where the package came from (Source: code, marketplace or authored), and the rows whose Source is marketplace carry the actions the Installed Apps page had, Details and Uninstall. + + - **What changes.** The "Installed Apps" entry in Setup's Apps group, which the install-local plugin (`MarketplaceInstallLocalPlugin`) contributes, now opens Packages with the Source filter set to marketplace. Its label and icon are unchanged. In `MARKETPLACE_INSTALLED_UI_BUNDLE` the entry `nav_marketplace_installed` is a `component` entry on `developer:packages` with `params: { source: 'marketplace' }`, which the console opens as `/apps/setup/component/developer/packages?source=marketplace`. + - **What is no longer shipped.** The bundle no longer carries the `marketplace_installed` page, so `GET /api/v1/meta/page` stops listing it and `MARKETPLACE_INSTALLED_UI_BUNDLE` has no `pages` key. In the console that page only redirected to the filtered Packages list. The `pages.marketplace_installed` entries leave the Setup translation bundle in `@objectstack/platform-objects` (en, zh-CN, ja-JP, es-ES). The Setup nav label `nav_marketplace_installed` keeps its translations. + - **Unchanged.** The entry still appears only when the install-local plugin is mounted, so a runtime without it shows no Installed Apps entry. "Browse Marketplace" and its route are not touched. The marketplace's own package page still carries Update and the sample-data Re-seed / Purge menu, and Details on a Packages row opens it. + + What to do. Nothing in a normal deployment. If you linked to the `marketplace_installed` page directly, link to Setup › Packages with `?source=marketplace` instead. +- d731b89: fix(identity): the Invite User dialog offers only the roles the caller may grant, read from the same table the invitation role cap enforces + + Clause-②: yes (widening) + + **What was wrong.** A `delegated_admin` who opened Invite User was offered Owner, Admin and Delegated Admin. The server's invitation role cap refuses all three for that issuer. The `role` param inherited one static option list from the field, and nothing told the client which roles this caller may grant. + + **What is new in `@objectstack/spec` (`identity`).** One table, `INVITATION_ROLE_GRANTORS`, says which issuer grades may invite as each membership role: + + | role | issuers that may grant it | + |---|---| + | `owner` | `owner` | + | `admin`, `delegated_admin` | `owner`, `admin` | + | `member` | any issuer (`INVITATION_ROLE_ANY_ISSUER`) | + + New exports beside it: `invitationRoleGrantors(role)` (a role the closed vocabulary does not list takes the `owner`, `admin` row), `invitationRoleGrantPredicate(role)` (the CEL gate, one `'' in current_user.positions` term per grade, `undefined` for an any-issuer row), `INVITATION_ROLE_OPTIONS` (the role picker's options, each carrying its gate as `visibleWhen`), and the types `InvitationRoleGrantors` and `InvitationRoleOption`. + + **`@objectstack/plugin-auth`.** The invitation role cap now reads that table to decide which invitations it admits. It refuses exactly what it refused before, with the same refusal text. A pin compares it with the previous implementation on 1,517 request and issuer pairs. + + **`@objectstack/platform-objects`.** The `role` param of the three `invite_user` actions (`sys_user`, `sys_member`, `sys_invitation`) declares `INVITATION_ROLE_OPTIONS` itself instead of inheriting the field's list. The option values and English labels are unchanged. A `delegated_admin` is now offered Member only, an admin Admin, Delegated Admin and Member, and an owner all four. The per-option gate is a UI courtesy; the server cap remains the authority. Because the options are declared on the param, their labels now translate under `_actions.invite_user.params.role.options.`, and the bundles carry those keys in every locale. +- b0a1df0: `sys_organization` now declares an `invite_user` action at `record_header`, so an organization record page can offer "Invite User" as its primary header button. It calls `/api/v1/auth/organization/invite-member` with the record's id as `organizationId`, asks for `email` and `role` (the role picker offers only the roles the caller may grant), and is gated like the other three invite mirrors: the `organization` capability and the owner / admin / delegated_admin grades. Its `order: -1` puts it first in the header. + + `leave_organization` keeps `variant: 'danger'` and now declares `order: 1`, so it sorts last among the header actions and folds into the `⋯` overflow menu instead of taking the third inline slot. Its confirmation now names what is lost: the organization's environments and members (en, zh-CN, ja-JP and es-ES). + + `@objectstack/spec` books the new action in `PUBLIC_AUTH_FEATURES.organization.gatedInputs`, beside the other three invite mirrors. + + Clause-②: no +- a19b2a7: Clause-②: no + + fix(platform-objects): the System Overview dashboard's six metric tiles declare `layout.h: 1`, one row tall, where they declared `2` + + The `system_overview` dashboard the Setup app ships (`SystemOverviewDashboard`) authored every one of its six `type: 'metric'` tiles (Total Users, Organizations, Active Sessions, Packages Installed, Login Events, Config Changes) at `layout.h: 2`. The console draws a widget over the grid rows its `h` names, each row floors at 5rem and grows to its content, and a metric card fills its box, so each tile drew a box about twice the height of its content: the title and the number in the top half, the lower half empty. The platform draws what the metadata declares, so the authored size is what is corrected. + + - **FROM:** the six metric tiles declare `layout: { …, h: 2 }`. + - **TO:** the six metric tiles declare `layout: { …, h: 1 }`; each now draws at content height. + - **Unchanged:** every `x`, `y` and `w`, the chart and table widgets (`h: 4`), every widget id, filter and binding, and every export. + - **An org that already published an overlay of `system_overview`** is served that overlay as stored (ADR-0126 Regime O), so it keeps the tile heights it was saved with. +- 99e9d29: feat(plugin-auth): an OAuth consent binds the consenting user's active organization, the access token carries it as `organization_id`, and `verifyMcpAccessToken` returns it as `organizationId` + + Clause-②: yes (widening) + + - **What changed.** When a user consents to an OAuth client, the embedded authorization server records the consenting session's active organization as the consent's `reference_id`. The value rides the authorization code onto the refresh family it starts (`sys_oauth_refresh_token.reference_id`), and every access token minted from either carries it as the `organization_id` claim. It is written through the installed `@better-auth/oauth-provider`'s own seams (`postLogin.consentReferenceId` and `customAccessTokenClaims`); no new configuration key or environment variable. + - **The session's organization, never the request's.** The value is read from the consenting session only. Nothing in the authorize request, the consent body or the client's registration can choose it. + - **No organization, no claim.** A user with no active organization at consent gets a consent with no reference, and tokens with no `organization_id`. There is no guessed or default organization; such a token's principal resolves with global grants only. + - **A refresh keeps the consent's organization.** A refresh token mints access tokens in the organization its consent bound, even after the user switches the browser session to another organization. Authorizing again while another organization is active asks for a second consent, for that organization. + - **Existing consents re-confirm once.** A consent stored before this change has no reference, so the next authorization made while the user has an active organization shows the consent page again, once. Until the user re-confirms, a refresh family started before this change keeps minting tokens with no `organization_id`, so they carry global grants only. + - **`AuthManager.verifyMcpAccessToken`.** Its result type gains `organizationId?: string`, matching `IAuthService.verifyMcpAccessToken` in `@objectstack/spec/contracts`, and it returns the token's `organization_id` claim there. A token whose `organization_id` is present but is not a non-empty string is refused, as every other malformed token is: this server never mints one. The organization is a record, not an authorization. The caller vets it on every request by the rule that vets a session's active organization (`vetOrganizationClaim`, `@objectstack/core`). + - **`@objectstack/platform-objects`.** The `reference_id` field description on `sys_oauth_consent`, `sys_oauth_refresh_token` and `sys_oauth_access_token` now names the bound organization, instead of "Caller-supplied correlation identifier". Prose only; no field, type or schema change. +- 8baeda5: fix(platform-objects): `sys_member`'s `transfer_ownership` row action says the selected member becomes an owner and the caller keeps their role + + The action sends better-auth's `POST /organization/update-member-role` with `role: 'owner'`. That endpoint writes the selected member's role and nothing else, so the caller stays an owner and the organization ends with two. The action's confirm used to promise "You will be demoted to admin and lose owner-only privileges"; nobody was demoted. + + The copy now says what happens, in all four locales: + + - label: FROM "Transfer Ownership" TO "Make Owner"; + - confirm: the selected member becomes an owner with every owner privilege, the caller stays an owner, and to step down the caller uses Change Role on their own membership; + - success message: FROM "Ownership transferred" TO "Member is now an owner". + + To hand ownership over, run Make Owner on the new owner's row, then Change Role on your own row to `admin`. better-auth refuses that second step only while you are the organization's last owner (`YOU_CANNOT_LEAVE_THE_ORGANIZATION_WITHOUT_AN_OWNER`). + + The action's `name`, `target`, request body, parameters, `requiresMembershipReach` and `variant` are unchanged, so a page or test that names `transfer_ownership` keeps working. A test that expected the caller to be `admin` after the action expected a demotion the endpoint never performed: it now reads `owner`, and reads `admin` only after the caller's own Change Role. + + Clause-②: no (provisional: copy only; if the measurement forces another shape, the dev reports before building it; owes the seat's review of record at `TIER_DEFAULT` before the queue) +- cf540a7: Clause-②: no + + fix(platform-objects): `sys_metadata_activation`'s description and its `package_id` field description say "authored in this system" + + - **What changed.** The activation ledger's object description and the description of its `package_id` field described an item no package ships as "authored in this environment". Both now say "authored in this system". Inside the running system, user-facing copy names the system the user is in as "this system". + - **Unchanged.** The object's fields, their types and every stored value. +- 4d12b23: Clause-②: no (copy only: no accept set or public surface moves; owes the seat's review of record at `TIER_DEFAULT` before the queue) + + fix(platform-objects): SSO provider, OAuth application and Connect-an-Agent copy says "this system" + + - **What changed.** Inside the running system, user-facing copy names the system the user is in as "this system". The `sys_sso_provider` object description ("External SSO identity providers (OIDC / SAML) this system federates login to"), the `clientId` help text of its Register SSO Provider action ("OAuth client ID issued by the IdP for this system."), and the OAuth applications grid's empty state ("Register an application to let its clients sign users in with this system.") now say so. The `en` copy of the Connect-an-Agent page subtitle follows the page's new text. + - **Translations.** The zh-CN, ja-JP and es-ES values of the same strings are re-translated, and the recorded source hash of the Connect-an-Agent subtitle moves to the new English text in all three, so those locales keep serving their translation rather than falling back to English. + - **Unchanged.** Every object, field, action and page name, and all behaviour. +- ff48407: Clause-②: no + + fix(spec): published copy that names the running system says "this system", or drops the noun, not "this environment" + + - **What changed.** Inside the running system, user-facing copy names the system the reader is in as "this system", or drops the noun. A permission set made here, not shipped by a package, is "authored in this system". + - The permission-set `packageId` refusal on the save, restore and publish doors now reads "a set written into no package is authored in this system, which an absent `packageId` states". + - The permission set's `packageId` help text in Setup ends "Leave empty for a set authored in this system." The English Setup forms bundle of `@objectstack/platform-objects` is regenerated from it. + - The `permission.managedBy` tombstone prescription, and the `permission-set-managed-by-retired` protocol-18 upgrade-guide entry, say "a set authored in this system carries neither". + - The `AudienceConfig.posture` and `AuthConfig.audience` describes say "who may self-register", with no noun. Their TSDoc says "who may become a user of this system's apps", the sentence the Setup Authentication group already reads. + - `AiModelsResponse.models` describes "Models this system offers". The `IMetadataService.listDiagnosed` TSDoc example reads "this system contains N items". + - **Kept "environment", where the word names a deployment or configuration scope.** `SeedLoaderConfig.env` ("Only load datasets matching this environment", the `prod | dev | test` tier), the `package-uninstall-environment-wide` upgrade-guide entry (the ADR-0131 environment-wide scope, as against an organization), and the environment artifact's consent-record TSDoc. + - **Unchanged.** Every error code, status, issue path, accepted value and export. Only the human-readable text moves. +- b7a688e: Clause-②: no + + fix(client,platform-objects): the `ai.models()` TSDoc and the translated permission-set `packageId` help say "this system", as the spec now does + + - **What changed.** Inside the running system, published copy names the system the reader is in as "this system", and a permission set made here, not shipped by a package, is "authored in this system". `@objectstack/spec` already says both (`AiModelsResponse.models`, the permission set's `packageId` help text); this follows it in two more places. + - The TSDoc of `client.ai.models()` now reads "Models this system offers in the chat model picker (cloud ADR-0028)". The citation is qualified as `cloud ADR-0028`: the AI service that serves this list is a cloud package, and this repository's ADR-0028 is about metadata naming. + - The zh-CN, ja-JP and es-ES values of the permission set's `packageId` help text in the Setup forms bundle now say 本系统 / このシステム / este sistema where they said 本环境 / この環境 / este entorno, matching the English text "Leave empty for a set authored in this system." + - **Unchanged.** Every method, route, type, form field, key and behaviour. No translation key is added or removed. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [077b75f] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0 + - @objectstack/metadata-core@18.0.0 + ## 18.0.0-next.5 ### Minor Changes diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index 0a2c83afe1d..aaf2a3d2b76 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Core platform object schemas for ObjectStack — identity, security, audit, tenant, and metadata objects", "main": "dist/index.js", diff --git a/packages/plugins/embedder-openai/CHANGELOG.md b/packages/plugins/embedder-openai/CHANGELOG.md index ae8016a4106..9cf012e07cc 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,344 @@ # @objectstack/embedder-openai +## 18.0.0 + +### Patch Changes + +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index 628f52efd6d..e9734915dfc 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "OpenAI-compatible embedder for ObjectStack — works against OpenAI, 阿里通义 DashScope, 智谱 BigModel, 硅基流动 SiliconFlow, 火山引擎 Doubao, MiniMax, Ollama, and any drop-in OpenAI-shape endpoint.", "main": "dist/index.js", diff --git a/packages/plugins/knowledge-memory/CHANGELOG.md b/packages/plugins/knowledge-memory/CHANGELOG.md index e8efec65e83..8bae0886527 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,381 @@ # @objectstack/knowledge-memory +## 18.0.0 + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/service-knowledge@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index 53710f796fe..37f32c6c263 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "In-memory knowledge adapter for ObjectStack (dev / test reference implementation).", "main": "dist/index.js", diff --git a/packages/plugins/knowledge-ragflow/CHANGELOG.md b/packages/plugins/knowledge-ragflow/CHANGELOG.md index d508198d44c..d5fa45f97de 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,381 @@ # @objectstack/knowledge-ragflow +## 18.0.0 + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/service-knowledge@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index 471d9cd5fe5..d151c7bebfe 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "RAGFlow knowledge adapter for ObjectStack — production-grade RAG via the Apache 2.0 RAGFlow REST API.", "main": "dist/index.js", diff --git a/packages/plugins/organizations/CHANGELOG.md b/packages/plugins/organizations/CHANGELOG.md index 1a9bc9ef48c..602b9069eb8 100644 --- a/packages/plugins/organizations/CHANGELOG.md +++ b/packages/plugins/organizations/CHANGELOG.md @@ -1,5 +1,481 @@ # @objectstack/organizations +## 18.0.0 + +### Patch Changes + +- 41d0d40: feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING** on a deployment whose `org-scoping` service declares `platformGlobalObjects`. Nothing changes on any other deployment. + + ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." Until now the declaration stood the organization wall down for the object while the object kept its `organization_id` column, so the SQL driver went on scoping a read by the caller's organization that the wall no longer scoped. + + - **The plan** (`@objectstack/spec`): `resolveInjectedSystemColumns(def, deployment?)` takes the deployment's validated `platformGlobalObjects` as an optional second argument. An object it names is planned with no `organization_id`, and nothing else in its plan moves. With no second argument, or an empty list, the plan is the same as before. Author-time callers (the linter, the import mapper, the tenancy census) have no deployment and pass none. + - **The registry** (`@objectstack/objectql`): `ObjectQLPlugin` reads the declaration at the top of `start()`, before the first schema sync, and installs it with the new `SchemaRegistry.setDeploymentPlatformGlobalObjects()`. A declared object is registered with no `organization_id`, no tenant index and `systemFields: { tenant: false }`, and its table is created without the column. An object registered earlier, inside another plugin's `init()`, is re-planned at that moment, before its table exists. The `/meta` read exits serve the same shape. On the way back in, the write path removes the recorded `tenant: false`, so a Studio save stores the body the author wrote. The plugin logs the declared list once at boot. + - **The order** (ADR-0116): every `init()` completes before any `start()`, so a provider that registers `org-scoping` in its `init()` has registered by the time the engine reads it. `OrganizationsPlugin` now declares `providesServices: ['org-scoping']` (`@objectstack/organizations`). A provider that registers the service later, with a different declaration than the one the columns were planned from, fails the boot at `kernel:ready` with an error that names both lists and the fix. + - **The stand-down is retired** (`@objectstack/plugin-security`): `getObjectSecurityMeta` no longer reads `platformGlobalObjects`. A declared object reaches the security layer as its own `systemFields.tenant: false`, the same way every deployment-level object does: Layer 0 composes no organization predicate on it, a wildcard `organization_id` policy does not apply to it, and the ADR-0123 D2 no-active-organization write refusal does not fire on it. The `[security] deployment declares N platform-global object(s)` boot line is gone; the engine logs the list instead. + - **One reader** (`@objectstack/core`): `readDeploymentOrgScopingEntitlement` moved from plugin-security into `@objectstack/core` and is exported there, with its rules unchanged. An absent key declares nothing. A malformed key is refused whole: no object is declared, and the consumer of that key warns once. The engine warns for a malformed `platformGlobalObjects`, and plugin-security for a malformed `suppressUnboundedOrgAdminGrant`. `suppressUnboundedOrgAdminGrant` and its behaviour are unchanged. + + **What moves for consumers.** + + - **On the declaring deployment**, a declared object has no `organization_id`. FROM an authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on that object, TO the same reference with that field removed. A write that still names it is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. The object is governed by object permission, not by the organization wall: a reader the object permission admits reads every row, where the SQL driver used to narrow a read to the caller's organization and the rows with no organization. + - **An object that declares its own `organization_id`** keeps that column, because it is the author's and not the platform's, and the organization wall keeps scoping it. The engine warns once at boot and names the object. Remove the authored field, or drop the object from the declaration. + - **Author-time tools** have no deployment, so they still list `organization_id` among a declared object's columns. Only the declaring deployment differs, and it refuses the name at runtime. + - **Existing databases: nothing moves automatically** (ADR-0131 D14). Schema sync is additive, so on a declaring deployment the physical `organization_id` column stays and the boot drift report names it orphaned. The operator removes it once the rows need nothing from it. No boot step reads or writes it. +- e49aaec: The organization seed ownership claim no longer fires app hooks or record-change flows + + Clause-②: no + + When an organization's owner is established (for example, `ensureDefaultOrganization` binding the platform admin as the default organization's owner), `claimOrgSeedOwnership` hands that organization's owner-less seeded rows to the owner. That write ran under a bare system context, so every claimed row went through the full write pipeline: hooks bound from app metadata fired, record-change flows ran, and the approvals and notifications those flows start went out for a bookkeeping write. The single-tenant claim, `claimSeedOwnership` in `@objectstack/plugin-security`, already wrote without automation; the organization claim did not. + + The claim's write now runs with `{ isSystem: true, skipAutomations: true }`, the same context the single-tenant claim uses. + + - **App hooks no longer fire for the organization seed ownership claim.** No hook bound from metadata runs on the claim's owner change, and no record-change flow is dispatched for it, so the claim opens no approval and sends no notification. + - **Still runs.** Hooks that plugins register in code still run on every claimed row: the audit writer, capability gates and the sharing projection. Every claimed row's `owner_id` is the organization owner, as before. + - **The read is unchanged.** The claim's per-object read is still a plain system read. + - **If an app relied on it.** An app hook or flow that reacted to the claim's owner change no longer sees it, just as it never saw the seed's own writes. +- 275ad42: The organization seed ownership claim now owns every owner-less row, not only the first 10,000 of each object + + Clause-②: no + + `claimOrgSeedOwnership` hands an organization's owner-less seeded rows to the organization's owner. It read at most 10,000 owner-less ids per object and updated them one at a time, and nothing read again. The default-organization bootstrap runs the claim once (the ADR-0093 D7 once-gate), so on an object with more than 10,000 owner-less rows in one organization the rest stayed owner-less for good. Measured on a real engine over the SQLite WASM driver: 10,500 owner-less rows, one pass, 10,000 owned and 500 left. + + The claim now re-owns each object's owner-less rows with one predicate write (`owner_id = owner` where `organization_id = org` and `owner_id` is null), the shape the single-tenant claim `claimSeedOwnership` in `@objectstack/plugin-security` already uses. + + - **Every owner-less row is owned after one pass.** When an object has more rows than one write may fire per-row hooks over (the engine's per-row hook ceiling, 10,000), the engine refuses the write and nothing is written. The claim then re-owns one page of 5,000 rows and tries the whole remainder again, until no owner-less row is left. + - **The returned count is every row claimed.** `count` in each `{ object, count }` entry is the sum of the affected-row counts of every write the claim made for that object. + - **Same scope and the same write context.** Only this organization's rows with no owner are written, a page write included. Every write still runs with `{ isSystem: true, skipAutomations: true }`: no hook bound from app metadata and no record-change flow runs, and hooks that plugins register in code (audit, capability gates, the sharing projection) still run once per claimed row. + - **The claim reads only when it pages.** Below the ceiling it issues no read: the write's own predicate selects the rows. The page read, when there is one, is a plain system read. + - **A failure now costs the write, not one row.** A hook or validation rule that refuses a predicate write refuses all of it, so that object's rows (or that page) stay owner-less and the claim logs a warning naming the object and how many rows it had already claimed. Other objects are still claimed. +- 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 [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [aa71c4d] +- Updated dependencies [19bb55e] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [e7e6544] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [4935c66] +- Updated dependencies [c6fe02d] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [6ff6ed6] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [0ee2d15] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [8a995b8] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [f66c440] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [e148ca9] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [f9ebb7d] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [3490bf5] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [6f4c518] +- Updated dependencies [87d0619] +- Updated dependencies [917b1a0] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [404fc47] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [0be162c] +- Updated dependencies [02a691a] +- Updated dependencies [e4ec6ec] +- Updated dependencies [fc1a55a] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [e611e2b] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [0cfd0c4] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [03ac4ec] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [ab3ba2c] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [f792290] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [8a88901] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [9040945] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [83dfbc0] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [51ad8e4] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [9524666] +- Updated dependencies [d65826f] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [44a5011] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [e22defb] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/plugin-auth@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/plugins/organizations/package.json b/packages/plugins/organizations/package.json index 07b613587f4..aa2a05bc35e 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.5", + "version": "18.0.0", "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 e8cb62a5e8a..a4c120c30ca 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,850 @@ # @objectstack/plugin-approvals +## 18.0.0 + +### Minor Changes + +- 1b40780: feat(automation)!: an approval decision resumes a flow as the person who decided + + Clause-②: yes (narrowing). The narrowing: under `runAs: 'user'`, a post-decision node that the submitter's rights admitted and the decider's do not is now refused (ruling A item 3). The widening: three published interfaces each gain one optional member, `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`) and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`). No schema shape moves: `flow.runAs` keeps its two values and only its describe changes. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A resumed flow run kept its trigger user for its whole life. In an approval flow that meant the nodes after the manager's decision wrote as the rep who submitted: the record's Last Modified By (`updated_by`) and the audit log named someone who did not act, and under `runAs: 'user'` the writes ran under the rep's grants. + + `flow.runAs: 'user'` now means the human whose act is running the current segment: the trigger user until a human decision, the decider after it. `runAs: 'system'` flows keep system privileges; after the decision their attribution user is the decider. + + - **Now, `runAs: 'user'`:** after an approval decision (approve or reject), the nodes that follow run with the decider's identity and permissions together. Their positions, permission sets and organization peers are resolved for them through the same resolver that seeds the trigger user's run, never copied from the trigger user. `updated_by` and `sys_audit_log` name the decider for those writes. The nodes before the decision keep the submitter. A second decision moves the segment to its own decider, a later pause keeps the identity the segment had, and the run's history still names the user who triggered it. + - **Now, `runAs: 'system'`:** after an approval decision, the flow keeps system privileges and its nodes run as `{ isSystem: true, userId: DECIDER }`, the shape the approvals verdict mirror already writes with. No permission is resolved and elevation is unchanged, so a read-only column still lands. Their writes, `sys_audit_log`, and `ctx.user` in the hooks those writes fire name the decider; the nodes before the decision keep the submitter. + - **Refused now:** under `runAs: 'user'`, a node after the decision that the decider may not perform (an object or field they may not edit, a record outside their reach) is refused, the run stops at that node as a repairable strand, and the decision door answers `RESUME_FAILED` (`repairable: true`) naming the run, the node and the refusal, as for any other failed post-decision node. Before, the same node ran with the submitter's rights. A repair replay (`continueRestoredRun`) continues as the decider too. + - **Unchanged:** a flow with no human decision node; an SLA auto-decision, which names no human, so the run keeps the identity it had (on both postures); a subflow parent's own nodes; and the send-back, resubmit and recall doors. + + `@objectstack/plugin-approvals` hands the decider to the engine on the service-stamped resume signal: the person the verdict mirror already writes as. The engine honours a decider only on a signal that carries the owning service's `RESUME_AUTHORITY_SERVICE` marker, so a resume built from an HTTP body cannot name one. The published types gain one optional member each: `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`), and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`). + + What changes for you: if a `runAs: 'user'` flow's nodes after an approval decision must write something the approver may not, such as a read-only field or a field the approver's permission set marks non-editable, the flow needs `runAs: 'system'`; its writes then keep system privileges and are attributed to the approver. A hook that reads `ctx.user` on a write a `runAs: 'system'` approval flow makes after the decision now sees the decider instead of the submitter. A run that was suspended before this release and is decided after it continues as the decider. +- 5495331: feat(plugin-approvals): serve the approval request's record page, and declare the thread reply as `approval_comment` + + Clause-②: no + + **The request's record page is now metadata this plugin serves.** `ApprovalsServicePlugin` registers `sys_approval_request_detail` through its manifest `pages`: a slotted record page, default for every `sys_approval_request` record, that the metadata API serves like any other page. Its slots: + + - `actions`: one `record:approval_decision` node (the decision panel: the request's decision progress and its declared decision actions); + - `highlights`: the object's `highlightFields` without `record_id`, so `record_id` stays in the details grid, where the console draws the target record of the `record_id` / `object_name` pointer pair. The object's own `highlightFields` is unchanged; + - `tabs`: Details (`record:details`) and Timeline (`sys_approval_action` as a related list on `request_id`, newest first); + - `discussion: []`: no generic discussion thread. The request's thread is its timeline. + + The page label is translated in the plugin's bundle as `pages.sys_approval_request_detail.label` (en, zh-CN, ja-JP, es-ES). The page stays module-internal: nothing new is exported from the package entry. + + **`approval_comment` declares the thread reply.** `sys_approval_request` now declares a ninth action, `approval_comment` ("Reply"). It posts `comment` (a required textarea) and `attachments` (optional, several files) to `POST /api/v1/approvals/requests/{id}/comment`, the route the console drawer posted to by hand. The service stores the reply as a `sys_approval_action` row with action `comment`, which the Timeline lists. Its `visible` gate is the route's own admission: a pending approver (`record.viewer.can_act`), or the submitter while the request is `pending`. It has no admin-override arm, because the comment route admits none. +- a00cf99: feat(plugin-approvals): the record-reader approval tier honours an object's own `enable.approvalsVisibleToReaders` declaration + + Clause-②: yes (widening) + + - **Before this,** the read-only record-reader tier could be switched on only through the `recordReaderVisibleObjects` constructor option. A config-driven app, whose host builds the plugin with no options, had no way to reach it. + - **Now** the tier is on for an object when either source turns it on: the host's `recordReaderVisibleObjects`, or the object's own `enable.approvalsVisibleToReaders: true`. With neither, visibility is exactly as before. + - **Read where it is used.** The service reads the declaration from the object's live registered definition on each read that names a record, not once at start. An object registered after boot (an installed package, a Studio edit, a dev reload) takes effect at once. Removing the flag turns the tier off at once, with no restart. + - **Fails closed.** An engine that cannot answer an object definition, an unknown object, or any value other than `true` leaves the tier off. Default OFF costs one in-memory registry lookup on a read that names a record. The business record is never probed for an object that declares nothing. + - **Unchanged.** The rule itself: who counts as a reader, the read-only boundary, the request tables it covers on both doors, and the constructor option. +- 3d0eeef: feat(plugin-approvals): `ApprovalService.handleActionPage` serves the ADR-0043 action page from a web-standard `Request` (#22578) + + Clause-②: yes + + - **What is new.** `ApprovalService` implements the optional `IApprovalService.handleActionPage(request)` member: the session-less page an approver reaches from the approve or reject link in an e-mail or IM message, from a `Request` to a `Response`, with no raw app, no Hono context and no `http.server`. `GET` renders the confirm page from the `token` query parameter and never decides. `POST` redeems the `token` field of the form body as the approver the token binds, and answers the result page. A dead link (unknown, expired, consumed, decided or reassigned) is told on the page with `200`. Any other method answers `405` with `Allow: GET, POST`. + - **The same pages, the same checks.** The member answers what the plugin's self-hosted pages answer for the same token and method: the same status, `Content-Type: text/html; charset=utf-8` and bytes. It goes through the same `peekActionToken` / `redeemActionToken` and the same page renderers, and both doors share one token store, so a link used at one is dead at the other. The token is the only credential: no session, cookie or `Authorization` header is read. + - **Its one caller** is the runtime HTTP dispatcher's `/approvals/act` domain (#22576), which reaches a hosted tenant kernel that owns no socket. + - **The self-hosted mount is unchanged.** Where `http.server` exposes a raw app, the plugin mounts `GET` and `POST /api/v1/approvals/act` exactly as before, and the path is never mounted twice. Where there is none, the plugin now logs one `info` line at boot saying the pages are served through the dispatcher's `/approvals/act` domain; it was silent. +- e8c6666: feat(plugin-approvals): opening an approval step tells each resolved approver (`approval.requested`) + + Clause-②: yes (widening) + + - **What is new.** When an approval node opens a request, every concrete approver on the slate it opened on gets one notification on the new topic `approval.requested`: title "Approval requested", body "A decision on OBJECT/RECORD is waiting on you.", linked to that request in the Approvals Inbox, with the person whose run opened the step as its actor. It goes through the installed `messaging` service exactly as `approval.reminder`, `approval.reassigned`, `approval.escalated` and `approval.returned` do, so the same channels and delivery preferences apply. Before this, the opening was the one lifecycle step that published nothing: an approver learned that a step was waiting only by opening the inbox. + - **Who is told.** A named user, every holder of a staffed position or member of a team, and, when `onEmptyApprovers: 'fallback'` replaced an empty slate, the declared `fallbackApprovers`. Each person is told once, even when the slate lists them under two groups. + - **Who is not.** A `type:value` slot no one holds (`position:NAME` with no holders) names no person, so a step that resolves to nobody notifies no one, and `onEmptyApprovers` decides that case: `admin_rescue` opens on the literal slot and tells nobody, `auto_approve` and `fail` open no request. An out-of-office delegate gets one message, the existing `approval.ooo_substituted`, not a second one for the opening. Without a `messaging` service nothing is delivered, as before. + - **Not included.** The opening carries no one-tap Approve / Reject links. Those links are still minted only by a reminder. + - **If your flow already tells the approvers itself, remove that.** The approvals guide used to say that opening a request notifies nobody, and suggested a `notify` node next to the approval node as the workaround. A flow that followed that advice now tells the same approvers twice. Remove the `notify` node, or narrow its recipients to people the approval does not address. Keep `notify` nodes that tell the submitter or a watcher, or that report the decision. No flow in this repository's examples, `objectstack-ai/hotcrm` or `objectstack-ai/hotclm` has that workaround: every `notify` node in their approval flows runs after the decision. +- 86da194: fix(plugin-approvals)!: `sys_approval_token`, the action-link tokens, is no longer exposed through the automatic API + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing on a runtime door), shipped as `minor` under the launch-window convention for breaking changes. + + The object's own declaration already said its tokens are minted and consumed by the approval engine, never through the data API. It now says so in the form every door enforces, so the generic doors serve token rows to no caller, as the approvals door (`/api/v1/approvals/*`) serves them to nobody. + + **What narrows, from → to.** + + - The declaration: `enable.apiMethods: ['get', 'list']` becomes `enable.apiEnabled: false` with `enable.apiMethods: []`. `apiEnabled: false` is the automatic API's off switch, judged first for every operation; `apiMethods: []` is the declared fully-closed whitelist, so the whitelist no longer advertises the read methods either (an absent whitelist would read as fully open). + - The REST data routes (list, query, by id, export, and every write): a caller granted read on the object was served its rows, which name the request, the identity a link is bound to, the action, the expiry and the consumption state, and so was an administrator. Every caller, an administrator included, now receives `404 OBJECT_API_DISABLED`. The dispatcher and the MCP data tools judge the same switch first. + - Without a grant, the answer moves from `403 PERMISSION_DENIED` to `404 OBJECT_API_DISABLED`: the exposure gate is judged before the permission check. + - The cross-object search (`GET /api/v1/search`) no longer sweeps the object, for any caller. + + **Unchanged.** The approval engine's own token path, which runs as the system: a reminder still mints one approve and one reject link per concrete pending approver, the link's confirm page still validates without consuming, one POST still decides as the bound approver, and a replay is still refused as already used. The digest is still stored, indexed and withheld from every generic exit (`internal: true`). + + **What changes for you.** Nothing in the platform, the console or Setup reads this object through a generic door, so no shipped surface changes. A script or integration that read token rows through the data API now receives `404 OBJECT_API_DISABLED`. There is no replacement read, by design: a link's state is what its confirm page answers, and a decision taken through a link is recorded in the request's action history on the approvals door. +- 896a434: fix(plugin-approvals,plugin-audit)!: a lookup target's title is read only when the TARGET object's declared exposure serves `get` — the approvals inbox's `payload_display` and the activity summary (#22738) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The data routes judge an object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`), and the data door's `$expand` and the dataset door's labels already ask it of a lookup's TARGET. Two more reads follow a lookup to its target's title under a system context and never asked it. + + **FROM.** For an administrator and a member alike: + + - **Approvals inbox** (`@objectstack/plugin-approvals`): `GET /api/v1/approvals/requests` and `GET /api/v1/approvals/requests/:id` carried, in `payload_display`, the title of a snapshot lookup's target whose declaration refuses `get`. + - **Activity summary** (`@objectstack/plugin-audit`): an update's tracked-change summary, and a fired milestone's summary, named such a target's records by title in the `sys_activity` row served by `GET /api/v1/data/sys_activity`. + + **TO.** Each title read first asks the decision of the TARGET object, for `get` (turning an id into the record it names, the read `GET /api/v1/data/:target/:id` performs): + + - A refused target is not read. The inbox carries no `payload_display` entry for that key, so the snapshot's stored id stands, which is what a deleted target already answers. The activity summary names the record by its stored id, which is what an unresolvable reference already answers. + - A declaration that cannot be read withholds the title too (fail-closed, at `warn`). + - Only activity rows written from now on change. A written row is a snapshot and is never rewritten, and no other activity column changes. + + A target declaring `apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `get` (for example `['list']`) is withheld; a target with no `enable` block, or a whitelist that grants `get`, is served exactly as before. The decision takes no caller. + + **Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` and `examples/`: ten objects refuse `get` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (control: 88 lookups into `sys_user`). So no shipped object's served title changes. Deployed and cloud-held object definitions were NOT MEASURED. +- 69d4218: feat(plugin-approvals): an approval step whose approvers resolve to no one opens on the organization's administrators under the default `onEmptyApprovers: 'admin_rescue'`, as ordinary approvers in their inbox + + Clause-②: yes + + **FROM → TO: who receives an `admin_rescue` request.** A node whose approvers resolve to no concrete person (an unstaffed position, an empty team or department, an unset manager, an expression that yields no one) under the default `onEmptyApprovers: 'admin_rescue'`: + + - **FROM:** the request opened on a slate that named no one, such as the literal `position:sales_manager`. It was in no one's "My Pending", the bell counted 0, no one was told, and a `lockRecord: true` record stayed locked until a platform or tenant admin found it under **All** and decided it through the privileged override. + - **TO:** the request opens on the request organization's administrators: the people who hold the organization-admin capability (`organization_admin` or `organization_admin_no_bypass`, ADR-0095 D3) in that organization. That is the same authority that lets a tenant admin override a request, resolved to the people who hold it, through the same grant resolution every request runs. An organization's owners and admins receive the capability automatically. + + They are ordinary approvers. The request is listed and counted for each of them under their own `approverId`, each is told once by `approval.requested`, out-of-office delegation applies to them, and a decision by one of them is an ordinary approval or rejection, recorded with `via_override: false`. The node's `behavior` reads them as one group. A platform administrator is not added for being one; one who also holds the organization's capability, such as its owner, is on the slate for that. + + The request says why. Its timeline carries a note, for example "Routed to this organization's administrators because the approver slate named no one: position 'sales_manager' has no holder.", and its node-config snapshot keeps the abandoned slate and the reason under `__resolvedFrom` (`admin_rescue:abandoned`, `admin_rescue:reason`). The abandoned slate is replaced, not kept beside the administrators, so a holder staffed into the position after the request opened does not receive it; an administrator can reassign it to them. + + **Unchanged.** A slate that names a person; `fallback`, `auto_approve` and `fail`; a `fallback` whose own list resolves to no one still degrades to `admin_rescue`, which now opens on the administrators. The privileged override stays for what the administrators' slate cannot reach: a request whose named approvers have all left after it opened, and an organization with no administrator (or a request with no organization), where the request still opens on the slate that names no one and the server logs a warning. + + **To name the person who should decide instead of the administrators,** declare `onEmptyApprovers: 'fallback'` with a `fallbackApprovers` list. + + **`@objectstack/spec`:** the `onEmptyApprovers` description says that `admin_rescue` opens on the organization's administrators and that `fallback` names who decides instead. + + **`@objectstack/lint`:** `approval-approvers-may-resolve-empty` no longer says the request "waits forever": it says the request opens on the organization's administrators, its remedy names `onEmptyApprovers: 'fallback'` as the way to name who decides, and its `os explain` text says the same. +- 24e064f: fix(objectql,service-messaging,service-automation,plugin-approvals)!: a notification carries the organization of what it is about, and one no organization owns is refused instead of stored with `organization_id` NULL + + Clause-②: no (narrowing) — notification rows gain the organization they should already carry; what narrows is an emit that names no organization on an install where none is derivable, which the engine now refuses where it used to store the event row with `organization_id` NULL. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Every notification emitted from a system-context run — a scheduled flow, a record change written by a system context, an approval node — was stored in `sys_notification` with `organization_id` NULL, so reports and cleanup that filter by organization did not see it. Each emit also logged a `[notify] no organization in scope` warning. On an install holding several organizations, the deliveries of the same emit were refused while the flow step reported success, so nothing reached the inbox. + + **Who owns a notification now:** + + - **The run's organization.** A run that carries one emits exactly as before. + - **Else the record it is about.** A `notify` node in a run with no organization reads the organization of the record the notification is about. That is the declared click-through record (`sourceObject` + `sourceId`), else the run's trigger record. It is read from the stored row by id, never from the record snapshot the run carries. + - **Approvals.** Every approval notification carries its request's organization: `approval.requested`, reminders, comments, send-backs, reassignments, escalations and out-of-office notices. + - **Else the owner the engine derives for a system write** (ADR-0131 D9). `sys_notification` is now in the platform-object tenancy ledger, so an emit that names no organization gets the install's sole organization under the `single` posture. + - **The rows below follow the event.** `emit()` stamps the stored event row's organization on its delivery, inbox row and receipt, on both the inline and the outbox path. + + **Refused now:** where no organization is derivable (a walled posture, or a `single` install holding more than one organization), an emit that names none is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. Nothing is written for it. A `notify` step that hits the refusal fails with `notify failed: ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED: …` instead of succeeding. `MessagingService.emit()` rejects with the engine's error for any other producer. + + **What to do when it refuses:** give the notification an owner. Name the record it is about on the `notify` node (`sourceObject` + `sourceId`), declare the scheduled flow's acting organization (`organization` on its start node), or pass `organizationId` to `emit()` from your own code. + + The `[notify] no organization in scope` warning is gone, because the NULL row it predicted is no longer written. Rows already stored with `organization_id` NULL are not rewritten here. The v18 organization-ownership ceremony attributes them from their source record. +- b74434c: Clause-②: yes (widening) + + feat(plugin-approvals): the approval lists show engine-written display labels, which are the step and flow labels, whom a pending request waits on, the record an action is about, and the slot it was taken as (#23889) + + The Requests and Action History lists are generic list views. They show stored columns only, so admins used to read node ids (`manager_review`), `flow:` api names, slot addresses and raw user ids there. The approval engine now writes display columns beside the machine ones, and the list views show those columns. Nothing is removed or renamed. The machine columns (`process_name`, `current_step`, `pending_approvers`, `step_name`, `acted_as`) keep their values, and every query and decision still reads them. + + **`sys_approval_request`**, written by `ApprovalService`: + + - `process_label`: the flow's label, and `current_step_label`: the Approval node's label. Both are snapshots taken when the request opens, from the labels the flow engine already hands to `openNodeRequest`. Renaming the flow later does not rewrite them. + - `pending_approver_labels`: whom the request waits on, one label per pending slot, joined with `, `. A slot that names a person (a user id, or an email an account carries) gets that account's name. Any other slot (a `position:` or `team:` literal) shows its address. The column is rewritten whenever the slate is written (open, a partial tally, reassign, SLA reassign) and cleared with the slate on every terminal write. + - The list views: `process_label` replaces `process_name` in every view. `current_step_label` replaces `current_step` in `all_requests`, `my_pending` and `submitted_by_me`. `pending_approver_labels` is added to `all_requests` and `my_pending`. + + **`sys_approval_action`**: each new row copies these columns from its request: + + - `step_label`: the request's step label. + - `object_name` + `record_id`: the request's record pointer (`record_id` declares `referenceVia: 'object_name'`), so a list can name the record. + - `acted_as_label`: `acted_as` labelled the same way as the waiting-on column. It is null when `acted_as` is null, and also while a person slot's name could not be read (see below). + - The list views: `step_label` replaces `step_name` in `recent`, `all_actions` and `by_actor`. `recent` and `all_actions` add the record pointer and show `acted_as_label` in place of `acted_as`. + + The inbox door's `process_label` / `step_label` now read the new columns first. They fall back to the config snapshot for rows opened earlier, so what they serve is unchanged. + + **Read security.** Every new value is either a column of the request the row's readers can already read, or a name the approvals door already serves those readers for that request: `pending_approver_names`, and the timeline's names. The names come from the same resolver, under the same system read. The generic data door serves both objects only to the callers the approvals door would serve. + + **Rows written before this release are filled at boot.** A new boot-time pass runs on `kernel:ready`, after the action-slot repair. It fills the new columns on rows that hold null. Each request is re-read just before it is written, and the write names only the columns that are still null on that re-read row. Whom a request waits on is written only while the request is still pending on the slate its names were read for. A value a live writer stored, such as the boot sweep or another node, is therefore kept. The only window left is the single engine call between the re-read and the write. Every row it fills keeps its own `updated_at`: the write passes the stored value back under `preserveAudit`, so the request lists, which sort on `updated_at`, keep their order. The action-slot repair's writes keep it the same way. Where `plugin-audit` is composed, the first boot after the upgrade writes one system entry in the audit log and one in the record's activity timeline for each row it fills. A second boot writes nothing. + + - On every request, the flow and step labels come from the chain the approvals door serves: the authored labels the config snapshot carries, else the humanized machine name. The list and the inbox therefore show the same label for the same row, and no request is left blank. + - On a pending request, whom it waits on is filled. + - On every action, the step label and the record pointer are copied from its request, and its acting-as label is filled. An action whose request no longer exists keeps a null pointer. + - The action-slot repair now labels every `acted_as` it writes. + + **Two details.** + + - An empty authored label (the spec allows `label: ''`) is stored as the humanized machine name, never `''`. + - A name that cannot be read because the `sys_user` read failed is stored as null, never as the raw slot address. The failure is logged once at `warn`, and the boot pass fills it on the next boot. A slot that names no person still shows its address. + +### Patch Changes + +- 666283a: **Shipped in 18.0.0-next.5**, whose CHANGELOG missed this entry; listed here one prerelease late. + + fix(plugin-approvals): the approval request page's highlights strip no longer shows the target object's API name + + Clause-②: no + + - **The strip draws no Object chip.** `sys_approval_request_detail` no longer lists `object_name` on its highlights strip, so a request about an announcement no longer shows `showcase_announcement` there. `object_name` holds the target object's API name, and `sys_approval_request` declares no field that holds that object's label. + - **The page still names the target object, by its label.** The Details tab draws the `record_id` / `object_name` pointer pair as a card of the target record. The card heads the record with its object's label (`Announcement`), translated into the reader's locale and read from the object's definition when the page draws, so a relabelled object shows its new label. Where the reader may not read the target record, or the platform deleted it, the card says so and the page names no object. + - **The object's `highlightFields` drops `object_name` too.** The page's Details tab is a bare `record:details` node, which draws the object's `highlightFields` minus the strip. A change to the strip alone would have moved `object_name` into that tab as a row showing the API name. + - **Unchanged:** `object_name` stays a declared, required field: the approvals machinery queries on it, and it is the type half of the pointer pair. The request list views still show their Object column. + + A patch: one entry fewer in one platform object's field list and in its page. No export, type, accepted value, served key or stored column changes. +- 92ce3af: fix(plugin-approvals, plugin-audit, plugin-sharing): list views no longer show the target object's API name as an Object column + + Clause-②: no + + - **The Object column leaves these lists.** These list views showed `object_name`, the target object's API name (such as `showcase_announcement`), as plain text beside the `record_id` record pointer. They no longer list it: + - `sys_approval_request`: `all_requests`, `my_pending`, `submitted_by_me` and `completed`; + - `sys_approval_action`: `recent` and `all_actions`; + - `sys_audit_log`: `recent`, `writes_only`, `record_views` and `all_events`; + - `sys_record_share`: all six views; + - `sys_share_link`: all four views. + - **The Record column names the target record.** `record_id` is the id half of the `record_id` / `object_name` pointer pair. The console draws it as the target record, with its object's icon and the record's name, and fetches `object_name` for that cell without a column. This relies on a console that fetches a pointer's type half for its cell, as the console bundled with this release does; an older console draws the raw record id in that column. + - **Unchanged:** `object_name` stays a declared field on all five objects, and the pointer pair, its indexes and every query on it are untouched. `sys_audit_log`'s `config_changes` view keeps its Object column, because its rows carry no `record_id`. + + A patch: entries removed from list view `columns` on shipped platform objects. No export, type, accepted value, served key or stored column changes. +- b61353f: fix(plugin-approvals): an approval's status write-back runs its automation as the person who acted, in the record's organisation + + Clause-②: no + + - **What changes.** The write that mirrors a request's status onto the record's `approvalStatusField` (submit, approve, reject, recall, send back, the action link) now names the organisation of the record it writes, and no longer carries an empty set of positions and permission sets. A `runAs: 'user'` hook on that object, and a `runAs: 'user'` record-change flow fired by that write, now run with the acting person's own request envelope in the record's organisation: their positions, their permission sets and their organisation's members, resolved exactly as their own request in that organisation resolves them. + - **Before.** Such a hook ran on the member baseline and saw only the acting person in `sys_user`. Such a flow resolved the person's grants with no organisation, so it saw only the person as a peer and did not run with what the record's organisation grants them. + - **Every acting person, one organisation.** An approver drawn from another organisation's directory also acts in the record's organisation. The organisation is read from the record when the status is written, so a record moved to another organisation while its approval was pending is written, and cascades, in its new organisation. + - **Unchanged.** The write stays elevated (`isSystem`), so it still lands on a locked record and on a read-only status field. A status written by a sweep with no person behind it (the dead-run release) stays user-less, so a `runAs: 'user'` automation it fires is still refused. `ctx.session.organizationId` in a hook on the write now carries the organisation, and `ctx.session.positions` is absent instead of an empty list. + + A patch: no export, type, accepted value or stored column changes. +- 65a8eeb: fix(plugin-approvals): a reminder's notification no longer carries one-tap approve / reject links, so no stored notification row holds a raw action token + + Clause-②: no + + - **What changed.** `remind()` still sends each concrete pending approver their own `approval.reminder` notification, with the same title, body and deep link to the request in the Approvals Inbox. It no longer mints ADR-0043 action tokens for that notification, and the payload it hands to `messaging` has no `actions` list. Messaging stores that payload, on the `sys_notification` event row and on each `sys_notification_delivery` row, so the links were kept in the database as usable links. ADR-0043's storage rule (`sys_approval_token` keeps SHA-256 digests only) exists so that the database holds none. + - **What a recipient sees.** Nothing changes. A reminder names no channel, so it is delivered to the in-app inbox, and the inbox row never carried the links. + - **Unchanged.** The action page (`GET` and `POST /api/v1/approvals/act`), `issueActionTokens`, `peekActionToken` and `redeemActionToken` behave exactly as before, and a redeemed token still decides as the approver it binds. The approval notification payload type no longer accepts links, and `issueActionTokens` now states that a raw token may travel only in a message that is sent and not kept. + - **Rows written before this fix.** A reminder sent by an earlier build stored its links. Each of those tokens stops working at its 72-hour expiry, or earlier: once it is used, once its request is decided or recalled, or once its slot is reassigned. The rows themselves age out under the notification tables' retention, 90 days at most. +- bfc1539: fix(plugin-approvals): the approval status mirror writes only a value the field declares, so a recalled record whose status field is a select goes back to where it stood before it entered approval + + Clause-②: no + + **The rule:** when `approvalStatusField` is an option field (a `select`) whose options do not declare the status, a recall or the dead-run sweep writes the value the field held when the flow started (the request's submit snapshot), else the field's declared default, else empty, and every other transition writes nothing and logs a `warn` naming the object, field and value. + + - **Before.** Each status was written verbatim. The engine checks a `readonly` field's value for shape only, and the spec advises a readonly status field, so a select that did not declare `recalled` ended up holding it after a recall. An app whose submit gate admits only the select's own values (for example `not_required` or empty) then never re-entered approval. On an editable select the engine refused the undeclared value, and the field kept reading `pending` with no request pending. + - **Now.** A recalled record, or one released by the dead-run sweep, reads its submit-time value again, so the app's own start condition decides whether it re-enters approval. A gate that admits that value re-admits the record at once, because the restore is itself an update. + - **Send-back keeps the field.** A select that does not declare `returned` keeps reading `pending` through the revise window. The run is still alive there (ADR-0044), and a restored pre-approval value would re-admit the start condition and open a second, parallel approval of the same record. + - **Unchanged.** A status the select declares is written as before. A field with no declared value set (text, or any type that is not an option type) gets every status verbatim, as before. The write's context is unchanged. +- f0022c4: Setup → Approvals → Requests opens on every approval request, not on the requests pending on the administrator. Before this, the entry named no view, and `sys_approval_request` declared the caller-scoped "My Pending" view (`pending_approvers contains {current_user_id}`) first, so the console opened it. + + Clause-②: no + + - `sys_approval_request` now declares its unscoped "All" view (`all_requests`) first. "My Pending", "I Submitted" and "Completed" follow it in their previous order, still as tabs. A route that names no view, such as a record page's object breadcrumb or the object switcher, now opens "All". No view is added, removed or changed. + - The Setup entry `nav_approval_requests` now names `all_requests` with `viewName`, so it does not depend on the declared order. The Account app's Approvals entry opens the Approvals Inbox component and reads neither. + - The declared order decides which view opens, not which rows a caller may read. + - The generated translation bundles follow the new view order. No translated text changed. + - ⛔ No schema, parse, export or accept-set change. +- 0b7ba4d: fix(plugin-approvals): Approve asks its decision question in the dialog, as Reject does + + Clause-②: no + + `approval_approve` on `sys_approval_request` declares a `description`: "Approve this request? Your approval is recorded, and the request moves on once this step has the approvals it requires." The console shows it as the parameter dialog's subtitle, where a generic "Please provide the required information to continue." showed before; `approval_reject` beside it already declared its question this way. The zh-CN, ja-JP and es-ES bundles carry a translation of it. +- 3403be8: fix(plugin-approvals): `sys_approval_request` declares its per-caller `viewer` block under `attachedOnRead`, so the shared validator accepts the object's own action predicates (#22211, #22387) + + `sys_approval_request` ships eight action `visible` predicates that read `record.viewer.can_act`, `record.viewer.can_override` or `record.viewer.is_submitter`. `viewer` is the block the approvals service attaches to every request it serves through `listRequests` and `getRequest`, computed from the caller. The object never declared it, so `os build` / `os validate` refused all eight with ``unknown field `viewer` on `sys_approval_request` ``, and the object save door's authoring gate refused a save of the shipped body the same way. + + The object now declares `attachedOnRead: { viewer: { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' } }`. The eight predicates pass the build and the save door's gate. A misspelt leaf (`record.viewer.can_actt`) is still refused, and the refusal names the three declared leaves. + + Unchanged: who sees which decision button. `viewer` is computed as before and served on the same two reads only; the generic data door, a record-change flow's `record` and a flow action's subject row do not carry it. The served object definition gains the `attachedOnRead` key. The exported `SysApprovalRequest` type resolves to the same type. +- b98e984: fix(plugin-approvals): a `manager` approver whose record owner has no manager adds no slot to the request, instead of the literal `manager:undefined` + + Clause-②: no + + An approval request whose user-resolving approver resolves to nobody now opens on an empty `pending_approvers`, and the node's `onEmptyApprovers` policy applies, as `content/docs/automation/approvals.mdx` documents ("An entry that resolves to nobody is not an error: the request opens with an empty `pending_approvers`"). This covers `{ type: 'manager' }` when the record's owner has no `sys_user.manager_id`, or the manager is outside the request's organization, and `{ type: 'field' }` when there is no record to read. + + Before this fix the expansion stored a literal `manager:` slot: `manager:undefined` when the approver authors no `value`, which is the usual case. Both read doors (`GET /api/v1/approvals/requests` and `GET /api/v1/data/sys_approval_request`) served that slot. Nobody can ever act under it: a caller acts only under their user id, their own account's email, or the `position:` address of a position they hold. + + What changes, and for whom: + + - **A slate made only of such rungs** opens with `pending_approvers: []` under the default `onEmptyApprovers: 'admin_rescue'`, where it held `["manager:undefined"]` before. `fail`, `auto_approve` and `fallback` behave as before, because those policies already treated a slate of literals as empty. + - **A slate with other approvers** (for example `[{ type: 'manager' }, { type: 'user', value: 'u9' }]`) is carried by the others. Under `unanimous`, `quorum` or `per_group`, the dead literal used to be an unapprovable slot: it held the request open after every real approver had approved, until an admin overrode it. Now the real approvers' approvals finish the step. This is the "second approver entry that cannot resolve empty" escape that the docs name for an unset manager. + - **Group approvers are unchanged.** An unstaffed `position` still opens on its `position:` literal, which a holder staffed into the position later decides. `team`, `department` and `org_membership_level` still fall back to their `type:value` literal. + - The `manager` rung still logs a warning when it resolves to nobody. The warning now says that the rung adds no slot, and names the subject user. + + Stored requests are not rewritten. A pending request that already holds a `manager:…` or `field:…` slot keeps it, and it stays recoverable as before: a platform or tenant admin can approve, reject, reassign or recall it. To list such requests, filter `sys_approval_request` on `status = pending` and on `pending_approvers` containing `manager:` or `field:`. +- 76bc1e0: fix(plugin-approvals): the generic data door serves `sys_approval_request` rows by the approvals door's visibility + + Clause-②: no + + A read of `sys_approval_request` through the generic data door (`find`, `findOne`, `count` and `aggregate`, so the REST data routes, the export route, the MCP data tool and every other engine read) now returns only the requests the approvals door (`/api/v1/approvals/*`) serves the same caller: + + - the requests the caller submitted, is a current approver of, or has acted on; + - every request, for an administrator holding the approval override; + - read-only, on an object named in `recordReaderVisibleObjects`, the requests of a record the caller can read, on a read that names that record (`object_name` and `record_id`, or the request's id). + + Before this, a deployment that granted read on the object served every request of the organization on that door, including the payload snapshots of records the caller cannot open. + + - **One rule for both doors.** The gate asks the approvals service for its own visibility (the same rule `listRequests` and `getRequest` apply), through an in-package handle: the published `ApprovalService` type is unchanged. It is not the activity stream's parent-record gate, which admits every reader of the record: the record-reader tier stays off unless the deployment names the object. + - **Absence, not refusal.** A request the caller may not see answers a by-id read exactly as a missing one does, `404 RECORD_NOT_FOUND`. A list's `total` and a grouped count are narrowed like its rows. A failed visibility answer denies the read and logs a warning. + - **Unchanged.** The approvals door, an administrator's view (Setup → Approvals → Requests), and the approval engine's own reads. A deployment that grants no read on the object still answers `403`. + - The record-reader tier no longer anchors on `sys_approval_request` itself, even when `recordReaderVisibleObjects` names it. +- 83b8b80: fix(plugin-approvals): the generic data door serves `sys_approval_action` and `sys_approval_approver` rows by their request's visibility + + Clause-②: no + + A read of a request's two child tables through the generic data door (`find`, `findOne`, `count` and `aggregate`, so the REST data routes, the export route, the MCP data tool and every other engine read) now returns only the rows whose request the approvals door (`/api/v1/approvals/*`) serves the same caller: + + - `sys_approval_action`, the decision log (actor, decision, comment text); + - `sys_approval_approver`, the pending-approver index. + + Before this, a deployment that granted read on either object served every row of the organization on that door, including the decision log and comment text of requests the caller does not take part in. + + - **One rule for both doors, and for all three tables.** The gates ask the approvals service the same question the `sys_approval_request` gate asks, keyed by the row's `request_id`: the request's submitter, its current approvers and those who acted on it see its rows; an administrator holding the approval override sees every row; and, read-only, on an object named in `recordReaderVisibleObjects`, a reader of the record sees its request's rows on a read that names the request (`request_id`) or the row (its id). The published `ApprovalService` type is unchanged. + - **The record page's Timeline keeps its readers.** A request's Timeline (a list of `sys_approval_action` keyed on `request_id`) serves the rows `listActions` serves the same caller; a row read by id is served exactly when a decision attachment on it would be (`authorizeFileRead`, which now shares the gate's read of the row's request). + - **Absence, not refusal.** A row the caller may not see answers a by-id read exactly as a missing one does, `404 RECORD_NOT_FOUND`. A list's `total` and a grouped count are narrowed like its rows. A failed visibility answer denies the read and logs a warning. + - **Unchanged.** The approvals door, an administrator's view, and the approval engine's own reads. A deployment that grants no read on either object still answers `403`. + - The record-reader tier no longer anchors on a row of either table, even when `recordReaderVisibleObjects` names it, as it already never anchored on `sys_approval_request`. +- 920bc12: fix: the approval request actions' success toasts name the request, and Explain access names the principal as a person + + Clause-②: no + + Server-authored strings that the console renders verbatim now name what and who they are about. + + - `@objectstack/plugin-approvals`: every action on `sys_approval_request` had a bare-verb `successMessage` (`Approved.`, `Rejected.`, `Reassigned.` and so on). Each now names the request through `${result.request.record_title}`. Each action's route answers `{ request, … }` with the read-back request and its display enrichment, so `record_title` is the title the inbox shows: the target record's display field, else its payload snapshot. The new copies: + - Approve and Reject use the inbox row's inline wording: `Approved "TITLE"` and `Rejected "TITLE"`. + - The others read `Reassigned "TITLE"`, `Sent "TITLE" back for revision`, `Information requested on "TITLE"`, `Reminder sent for "TITLE"`, `Recalled "TITLE"`, `Resubmitted "TITLE"` and `Reply posted on "TITLE"`. + + The zh-CN, ja-JP and es-ES values of the same keys carry the same token. No key, route or response shape changes. + - `@objectstack/plugin-security`: the explain engine's principal layer said `Principal USER_ID holds position(s) …`. It now names the person first and the id second, for example `Principal Ada Lovelace (ada@example.com; id usr_1) holds position(s) …`. Three other users in the same layer are named the same way: + - an on-behalf-of delegator; + - the user an ADR-0091 D3 delegated position came from, in the clause; + - that same user in the position's contributor attribution (`delegation from …`). + + Each user row is read with the asker's own context, so row-level and field-level security apply. An asker whose read returns no row gets the bare id, as before, and a field its read masks is left out. As a result, two askers can now be shown different names for the same user. The decision's `principal` object and every verdict are unchanged. +- 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. +- 74b4362: fix(plugin-approvals): `sys_approval_request.record_id` declares that the record it names was deleted when `cancel_reason` is `record_deleted` + + Clause-②: no + + - **The pointer carries its own tombstone predicate.** `record_id` declares `referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' }` beside its `referenceVia: 'object_name'`. A request the platform cancelled because its record was deleted (`status: cancelled`, `cancel_reason: record_deleted`) therefore says, in the served object definition, that the pointer names a record that no longer exists. A renderer draws that option's label (`Related record deleted`) in place of a link, and reads nothing. + - **Nothing is accepted or answered differently.** The sibling is the existing `cancel_reason` select, which already holds `record_deleted`, so the spec's parse-time refusals of the key are satisfied by the shipped object. No export, type, route, stored value or request answer moves. + + A patch: one key on one platform object's served field definition. +- 099cc9d: Clause-②: no + + The approval, security, automation and sharing objects describe their records in plain admin + sentences. Approval Delegations now reads "While someone is away, approvals assigned to them + personally go to their delegate.", Record Shares "Records shared beyond their owner, one row per + grant.", User Positions "Which positions each user holds, one row per assignment.", Audience + Binding Suggestions "A package's suggestion to give one of its permission sets to everyone who + signs in, and the decision taken on it." and Automation Runs "Paused automation runs waiting to + resume, and the recent history of finished runs." Field help on Approval Requests, Approval + Delegations, User Positions, User Permission Sets and Audience Binding Suggestions, the + descriptions of the `organization_admin_no_bypass`, `mcp_agent_data_read` and + `mcp_agent_data_write` permission sets, the flow-dispatch ledger's description and the + reserved-position refusal no longer carry design-record numbers or runtime vocabulary; those + references stay in the code comments. The zh-CN, ja-JP and es-ES translations follow. Wording + only: no field, permission or behaviour changes. +- 44fe210: Clause-②: no + + fix(plugin-approvals): every approval notification body names its record by the object's label and the record id + + - **What changed.** The reminder, SLA-breach (to approvers and to the submitter), escalation, hand-off, revision-limit, send-back (with no note) and both out-of-office notification bodies named the record as `/`, for example `crm_deal/D_123`. They now name it the way `approval.requested` already did: the object's label and the record id, for example `Deal D_123`. An object whose schema declares no label is named by its API name and the id, for example `crm_memo D_123`. + - **No title.** No body carries the record's title, from the live record or from the submit snapshot. The body is stored and may be e-mailed, and it has no single reader whose field access it could be narrowed for. + - **Unchanged.** Each sentence's other wording, every topic, title, `actionUrl`, action link and audience, and the `DUPLICATE_REQUEST` refusal text. +- b75129f: Clause-②: no + + fix(plugin-approvals): an approval request's record title reads the flow's label, not `flow:NAME` + + - **`sys_approval_request.display_title` reads `process_label`.** The title is now the flow's label and the record id (`Discount Sign-off · opp_45`), the same label the request lists show. A row with no label stored falls back to `process_name` (`flow:deal_desk · opp_42`), so the title never reads `null · …`. Every surface that draws the request's title through `nameField` shows it: the record page's heading and breadcrumb, and the request cell of the action history list. + - **`titleFormat` still names `{process_name}`.** A placeholder template cannot fall back, and `process_label` can be null after the boot fill (a row it could not label, or a failed fill run). A renderer that reads `titleFormat` ahead of `nameField`, such as the global search palette, still shows the machine source. + + A patch: one formula on one platform object's computed title. No export, type, accepted value or stored column changes. +- 544b8d7: Clause-②: no + + The two out-of-office approval notifications name people by their display name instead of their raw user id. The delegate's `approval.ooo_substituted` body used to read `You are covering an approval on Deal D_123 while u_ooo is out of office.`, and now names the absent approver: `… while Olive Away is out of office.` The absent approver's `approval.ooo_skipped` body used to read `… was routed to u_bob while you are out of office.`, and now names the delegate: `… was routed to Bob …`. + + The names come from the same user-name lookup the approvals inbox uses for pending approvers and the timeline, read once per opening for every person the out-of-office routing touched. An account row with no name is named by its email, as the inbox names it. A person with no readable name (no account row for the id, or a failed read) is still named by the user id, as before. The `ooo_substitute` entry on the request's audit trail is unchanged and still records the two user ids (`u_ooo → u_bob — reason`). +- e15897c: fix(plugin-approvals): the approval request page's highlights strip names the flow by its label, not `flow:NAME` + + Clause-②: no + + - **The strip's first chip is the flow's label.** `sys_approval_request_detail` lists `process_label` (the "Flow" field) where it listed `process_name`, so a request opened by the Deal Desk flow reads `Deal Desk` there instead of `flow:deal_desk`. The request lists already showed this label. + - **The object's `highlightFields` makes the same swap** (`process_label` in place of `process_name`, `record_id` kept). The page's Details tab is a bare `record:details` node, which draws the object's `highlightFields` minus the strip. A change to the strip alone would have moved `process_name` into that tab. Changed together, the tab still shows only the target record card. Other surfaces that read the object's list show the flow's label too: a request's record preview card, Studio's object settings, and the default columns of a list, kanban, related list or lookup picker that declares none. No first-party request list uses the default columns: each request list view declares its own. + - **When no label is available.** The console reads a request through the approvals get, which serves `process_label` as the stored label, else the flow label in the request's node-config snapshot, else the humanized machine name (`flow:deal_desk` → `Deal Desk`). A request the boot backfill has not labelled still shows a Flow chip. Only when all three are empty does the field have no value: the strip then draws no Flow chip, and the record title still names `process_name`. + + A patch: one entry in one platform object's field list and the same entry in its page. No export, type, accepted value or stored column changes. +- 5da2e88: fix(plugin-approvals): the approval request page's highlights strip names the current step by its label, not the node id + + Clause-②: no + + - **The approvals get serves `current_step_label`.** `ApprovalRequestRow` declares it as the step's label under the `sys_approval_request` column's own name. Every door that serves a request now serves it with exactly `step_label`'s value: the get, the list, and the request that a decision, recall, send-back, resubmit, reassign, remind, info request or comment echoes back. `step_label` stays, with the same value, for the inbox and the emailed action-link pages. `current_step` stays the Approval node's machine id. + - **The strip's step chip is the step's label.** `sys_approval_request_detail` lists `current_step_label` (the "Approval Step" field) where it listed `current_step`, so a request waiting on the `approve_step` node reads `Manager Review` there instead of `approve_step`. The request lists already showed this label. + - **The object's `highlightFields` makes the same swap.** The page's Details tab is a bare `record:details` node, which draws the object's `highlightFields` minus the strip. A change to the strip alone would have moved `current_step` into that tab. + - **When no label is stored.** The get serves the stored label, else the node label in the request's node-config snapshot, else the humanized node id (`approve_step` → `Approve Step`). A request the boot backfill has not labelled still shows a step chip. A request with no step serves neither field, and the strip draws no step chip. + + A patch: one served key that the contract already declares, and one entry in one platform object's field list and in its page. No export, type, accepted value or stored column changes. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [4840720] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [077b75f] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/formula@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index c72e22ba9c0..62eb492e15f 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Multi-step approval engine for ObjectStack — sys_approval_process + sys_approval_request + sys_approval_action + IApprovalService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-audit/CHANGELOG.md b/packages/plugins/plugin-audit/CHANGELOG.md index e3465271fc2..7ca426edfdd 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,888 @@ # @objectstack/plugin-audit +## 18.0.0 + +### Minor Changes + +- 7c7741d: The `sys_attachment` parent gate and the `sys_comment` moderation gate give the answer a by-id update of the parent record gets + + Clause-②: no (narrowing) + + + + - Every limb that asks whether the caller may edit the parent record (attaching a file, editing or removing another user's attachment or comment, moving an attachment to another record, and `assertMayAttach`) now admits only where a by-id update of that parent admits. After record sharing and the master-detail check, the gate asks the security service's judgement of that update: its record-grained `explain` verdict for `update` on the parent, `allowed` and `record.visible` both. + - The step only adds refusals, with each limb's existing envelope (`ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE`, or the not-visible `PERMISSION_DENIED` for a caller who cannot read the parent). A store fault keeps its own status, and an attach naming a parent object the registry does not hold answers what a by-id update of it answers (`404 OBJECT_NOT_FOUND`). + - Posting a comment is unchanged: it stays a read rule on the parent. +- 6729e10: feat(plugin-audit,plugin-security)!: the compliance ledger `sys_audit_log` loses its injected organization column; the organization a row is about stays in `tenant_id`, and an organization reader is scoped on it by a platform row policy (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + + Some ledger rows are about deployment-level actions no organization owns: a change of platform-administrator standing at boot, a change to a global setting, plugin-auth's administrative user writes. An injected organization column made the tenant wall the ledger's anchor, so under a walled posture those rows were hidden from every reader, platform administrators included. ADR-0131 D7 takes the column off: the ledger is governed by object permission, and the organization a row is about is the plain attribution field `tenant_id`, which the tenant-field resolver does not claim. + + - **`sys_audit_log`** (`@objectstack/plugin-audit`) declares `systemFields: { tenant: false }`, so the registry injects no `organization_id` and a new table is provisioned without it. `tenant_id` (a lookup to `sys_organization`) is unchanged and is the only organization column. The record mirror, the record-view writer and the sign-in writer stamp it as before and no longer stamp `organization_id`. A write that still names `organization_id` on the ledger is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. + - **The read scope** (`@objectstack/plugin-security`, the shipped permission sets): a platform row policy, `sys_audit_log_org` (`tenant_id == current_user.organization_id`), in `organization_admin` (and its no-bypass variant), `member_default` and `viewer_readonly`. `organization_admin` also names `sys_audit_log` explicitly, read only and without `viewAllRecords` / `modifyAllRecords`: its wildcard's superuser bypass would otherwise skip the policy on an object with no tenant column. Under an organization wall an organization administrator or viewer reads the rows about its active organization; a platform administrator (`admin_full_access`) reads every row, the rows about no organization included. Under `single` the policy is stripped by provenance (ADR-0105 D3), as every platform tenant policy is. + - **`config_change` rows** (`@objectstack/service-settings`): a GLOBAL-scope settings change is about no organization, so its ledger row carries no `tenant_id`, whatever organization the writing session had active. Tenant- and user-scope changes keep the writer's organization. The settings writer and plugin-security's platform-admin standing writer no longer probe for or stamp `organization_id`. + - **Retention** (`@objectstack/objectql`): a tenant-scope `lifecycle.retention_overrides` window on `sys_audit_log` partitions the reaper's and the archiver's passes on `tenant_id`, and the global pass keeps the rows with no `tenant_id`. + - **`view_all_audit_log`**: its description (`@objectstack/spec/security`) now names the row scope it does not lift; the capability still lifts only the parent-record read gate. + + **What moves for consumers.** + + - **Authored references.** A filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` names `tenant_id` instead; the two held the same value on every row a writer wrote. + - **Who reads what, under a wall.** A platform administrator now also reads the rows about no organization, global settings changes included. An organization administrator reads exactly its active organization's rows. Under the `group` posture an organization reader is scoped to the ACTIVE organization's rows, not the union of its memberships. A permission set an application ships that grants `viewAllRecords` on the ledger (directly or through a wildcard) skips the row policy, as it skips Layer 1 on every object the wall does not cover. + - **Who reads what, under `single`.** The row policy is stripped under `single` (ADR-0105 D3), as every platform tenant policy is. So a deployment that holds more than one organization under `single` serves every organization's ledger rows to every ledger reader. The remedy is a walled posture. + - **Existing databases — nothing moves automatically** (ADR-0131 D14). Schema sync is additive: the physical `organization_id` column stays and the boot drift report names it orphaned. The v18 upgrade ceremony confirms its values equal `tenant_id` and drops it (`os migrate apply --allow-destructive`), reporting any row where they differ. +- 3599fef: `AuditPluginOptions` gains `getLocale`, an optional host locale resolver asked before the settings-derived locale, so a multi-organization host can write each organization's activity summaries in its language + + Clause-②: yes (widening) + + - **The option.** `new AuditPlugin({ getLocale })`, where `getLocale` is `(tenantId?: string, userId?: string) => string | undefined | Promise`. It picks the language of `sys_activity.summary` and of the @mention notification title the plugin emits, and is called with the write's organization and user (for an @mention title, the mentioned recipient). + - **Precedence: host first, settings second.** An answer that is a well-formed BCP-47 tag is used, in its canonical form (`zh-cn` → `zh-CN`). `undefined`, `null` or a blank string falls back to the settings-derived `localization.locale` (ADR-0053) the plugin used before this option. So do a malformed answer (`zh_CN`, a non-string) and a throw or rejected promise; each of those two faults is logged once at `warn`, and neither fails the audited write. There is no other locale source. + - **Without the option, nothing changes.** The settings-derived locale stays the only source, as before. + - The locale only picks the message catalog, and a summary is rendered once, when its row is written. Rows already written keep their text. +- ce3d0ad: fix(service-storage,plugin-audit)!: the `sys_attachment` and `sys_comment` parent gates judge a `controlled_by_parent` parent through its master, so a member can no longer attach files to, or delete other people's files or comments from, a child record they may not edit + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. It follows ADR-0055. + + **What stops being accepted.** + + - A `controlled_by_parent` record takes its access from its MASTER. Record sharing does not enforce on it, so the sharing service's edit verdict abstains on every such record, and both gates used to take that abstention as permission. A member holding the `sys_attachment` create or delete bit, or the `sys_comment` delete bit, could therefore attach files to, and delete other people's files and comments from, every master-detail child record of the organization. That included records whose own `PATCH` refuses the member, and records the member cannot read. + - Both gates now ask the sharing service's tri-state `checkEdit`. `allow` admits and `deny` refuses, as before. On an abstention they ask the security service's master-detail write check (`ISecurityService.checkControlledByParentWrite`), which is the answer a by-id update of the parent gets. Its `allow` and `not_applicable` admit. Its `deny` refuses, and so does an `unresolvable` outcome: a broken master-detail declaration, a record that does not exist, or an empty master reference. A datasource fault propagates with its `503`. + - The refusals keep today's envelopes. An attach or a re-point answers `403 ATTACHMENT_PARENT_ACCESS`. A delete of another user's file answers `403 ATTACHMENT_DELETE_DENIED`, and an update of it answers `403 RECORD_NOT_ACCESSIBLE`. A delete or update of another user's comment answers `403 RECORD_NOT_ACCESSIBLE`. A caller who cannot read the parent still gets the not-visible `403 PERMISSION_DENIED`. + + **What is unchanged.** An uploader still deletes their own file, and an author still deletes their own comment. Posting a comment still needs only read access to the record. A parent that is not `controlled_by_parent` is judged exactly as before, and a `public_read_write` parent still admits every member. A kernel whose security service does not serve the master-detail write check composes no master check, and there the gates admit on the abstention as before, which is also what the parent's own update gets. + + **What changes for you.** A member who was attaching or moderating on a child record through this gap now gets the refusal above. To give them that access, grant them edit access on the MASTER record: ownership of it, an `edit`-level record share, the `modifyAllRecords` bypass on the master object, or an app-authored `update` row-level security policy on the master that admits the row. Then their `PATCH` of the child and their attachment and moderation rights on it agree. The port types `AttachmentSharingLike` and `CommentSharingLike` now pick `checkEdit` rather than `canEdit`. The registered sharing service serves both, so a caller passing it is unaffected. `installAttachmentAccessHooks` and `installCommentAccessHooks` take an optional trailing resolver for the security service (new port types `AttachmentSecurityLike` and `CommentSecurityLike`). The two plugins pass it themselves. +- 29a163f: `sys_comment_reaction`: an emoji reaction to a comment is now the reacting user's own record, one row per comment, emoji and user + + Clause-②: yes (widening) + + - **The object.** `@objectstack/plugin-audit` registers `sys_comment_reaction` beside `sys_comment`. A row holds `comment_id` (the reacted comment's id), `emoji` (the character sequence, at most 64 characters) and `user_id` (the user who reacted). The data API offers `get`, `list`, `create`, `delete` and `bulk` on it, so `createMany`, `deleteMany` and `/batch` creates and deletes work too. It offers no `update`, so `updateMany` stays refused: a reaction is removed and made again, not edited. Every row of a batch meets the same rules as a single write. + - **Who may read it.** A reaction is readable exactly when its comment is. Every read of the object (`find`, `findOne`, `count`, `aggregate`, and so the list total) is narrowed to the rows whose comment the caller can read, decided by a read of those comments under the caller's own context. A reaction whose comment was deleted is read by nobody. + - **Who may write it.** A caller reacts only to a comment it can read; on any other comment, including one that does not exist, the create is refused with `403 RECORD_NOT_ACCESSIBLE`. `user_id` is set from the signed-in user, and a value the client sends is replaced; a session with no user is refused. A member deletes their own reaction, and not another member's, by the platform's own-record rule (`owner_only_deletes`). The author-only edit rule on `sys_comment` is unchanged: reacting no longer writes the comment's row. + - **One row per reaction.** `(comment_id, emoji, user_id)` is unique within an organization. Two members reacting at once write two rows. The same reaction a second time is refused, and the data API answers `409 UNIQUE_VIOLATION`. + - **Reading reactions for a feed.** Read the reactions of the comments on screen in one request, `comment_id` `$in` those ids, and group the rows by comment and emoji. Nothing is stored or computed on the comment row. + - **Granting it.** The object's create, read and delete bits come from a permission set, as `sys_comment`'s do. The `everyone`-bound baseline (`member_default`) grants nothing on it. + - **`@objectstack/spec`: the platform object registry lists it.** `PLATFORM_OBJECTS_BY_PACKAGE['plugin-audit']` now includes `sys_comment_reaction`, and so do `PLATFORM_PROVIDED_OBJECT_NAMES` and `isPlatformProvidedObjectName`. A stack that references the object is no longer reported as naming an unknown platform object. This is the only change in the package. + - **`sys_comment.reactions` is unchanged in this release.** The column is still declared and still written by clients that write it. It is retired separately, and `os migrate comment-reactions` converts the reactions stored in it into records. +- 9963322: feat(plugin-audit)!: `sys_comment.reactions` retires; an emoji reaction lives only in `sys_comment_reaction`, and reactions stored in the old column are converted into `sys_comment_reaction` records by `os migrate comment-reactions` + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + + **Existing emoji reactions stored in `sys_comment.reactions` survive the upgrade.** They stay in the column, which no screen or API returns, until `os migrate comment-reactions` converts them into `sys_comment_reaction` records, and `os migrate apply --allow-destructive` drops the column only after that conversion is verified (see *Existing databases* below). Reactions now live in `sys_comment_reaction`, one row per comment, emoji and user, the reacting user's own record. Where the entry that introduces `sys_comment_reaction` says the column is unchanged, this entry supersedes it: the column retires. + + - **`sys_comment`** (`@objectstack/plugin-audit`) no longer declares `reactions`. Nothing takes its place on the comment row: no stored aggregate, and no block computed on read. Its label leaves the four generated translation bundles. + - **The data door refuses the field.** A create or an update of a comment that still carries `reactions`, and a list filter on it, answer `400 INVALID_FIELD` naming the field, and nothing is written. The rest of the payload is not applied either. A read of a comment no longer returns `reactions`. + - **`@objectstack/spec`**: the protocol-18 step of the ADR-0087 migration chain gains the semantic entry `sys-comment-reactions-column-retired`, so `os migrate meta` and the upgrade guide carry the prescription below. + + **What moves for consumers.** + + | You wrote | Write instead | + |:---|:---| + | `sys_comment.reactions` read as a map of emoji to user ids | one read of `sys_comment_reaction` for the comments on screen (`comment_id` `$in` their ids), grouped by comment and emoji | + | an update of `sys_comment` that rewrites `reactions` to add or remove a user | create the user's own `sys_comment_reaction` row (`comment_id`, `emoji`) to react; delete that row to un-react | + | `reactions` in a seed row, filter or list-view column on `sys_comment` | delete it | + + An application that shows reactions grants `sys_comment_reaction` read, create and delete beside its `sys_comment` grant; without it a member cannot react. The console's record chatter already reads and writes `sys_comment_reaction` rows wherever the deployment declares that object, as this framework does. + + **Existing databases — nothing moves automatically.** Schema sync is additive: the physical `reactions` column stays with the values it holds, the boot drift report names it orphaned (`sys_comment.reactions: column exists in the database but not in metadata`), and the boot is otherwise clean. The data door neither returns nor accepts the column. Convert first, then drop. `os migrate comment-reactions` previews the conversion and writes nothing; `os migrate comment-reactions --apply` turns each stored (comment, emoji, user) into that user's own `sys_comment_reaction` record, in the comment's organization, lists every stored value it cannot read instead of guessing at it, and records the run verified once every stored value has read and converted. A re-run converts nothing twice. Only then does `os migrate apply --allow-destructive`, run with the audit plugin composed (an auth secret resolves), drop the column; before a verified run is recorded it refuses the drop and exits `1`, because the drop would delete the reactions. +- 896a434: fix(plugin-approvals,plugin-audit)!: a lookup target's title is read only when the TARGET object's declared exposure serves `get` — the approvals inbox's `payload_display` and the activity summary (#22738) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The data routes judge an object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`), and the data door's `$expand` and the dataset door's labels already ask it of a lookup's TARGET. Two more reads follow a lookup to its target's title under a system context and never asked it. + + **FROM.** For an administrator and a member alike: + + - **Approvals inbox** (`@objectstack/plugin-approvals`): `GET /api/v1/approvals/requests` and `GET /api/v1/approvals/requests/:id` carried, in `payload_display`, the title of a snapshot lookup's target whose declaration refuses `get`. + - **Activity summary** (`@objectstack/plugin-audit`): an update's tracked-change summary, and a fired milestone's summary, named such a target's records by title in the `sys_activity` row served by `GET /api/v1/data/sys_activity`. + + **TO.** Each title read first asks the decision of the TARGET object, for `get` (turning an id into the record it names, the read `GET /api/v1/data/:target/:id` performs): + + - A refused target is not read. The inbox carries no `payload_display` entry for that key, so the snapshot's stored id stands, which is what a deleted target already answers. The activity summary names the record by its stored id, which is what an unresolvable reference already answers. + - A declaration that cannot be read withholds the title too (fail-closed, at `warn`). + - Only activity rows written from now on change. A written row is a snapshot and is never rewritten, and no other activity column changes. + + A target declaring `apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `get` (for example `['list']`) is withheld; a target with no `enable` block, or a whitelist that grants `get`, is served exactly as before. The decision takes no caller. + + **Measured producers.** Read on `origin/main` `bf515e724d` over every non-test `.ts` source under `packages/` and `examples/`: ten objects refuse `get` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false` (control: 88 lookups into `sys_user`). So no shipped object's served title changes. Deployed and cloud-held object definitions were NOT MEASURED. +- dc93781: `sys_comment` edit marks are platform-written: an update that changes a comment's `body` stamps `is_edited: true` and `edited_at` from the server clock, and a client can no longer set or clear either field. + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + - **Stamped by the platform.** The comment's `beforeUpdate` sets both fields when the update's `body` differs from the stored body, for every caller, the way the engine stamps `updated_at`. An update that does not name `body`, or sends the stored text again beside new `mentions`, is not an edit and stamps nothing. A create is not an edit either. + - **Read-only at the data door.** `is_edited` and `edited_at` now declare `readonly: true`. On an update that edits the body, the stamp replaces whatever the client sent; on any other update a non-system caller's value is dropped and reported in the response's `droppedFields`; on a create the platform sets both to "not edited" over a client value. Nothing is refused for carrying the fields — a client that still sends them on every edit, as the console released before this change does, keeps working and its values are ignored. Read both fields back from the row instead of writing them. + - **Predicate updates.** A `multi: true` update is stamped per matched row. One whose matched rows disagree — some bodies change, some already hold the new text — is now refused whole with `MULTI_UPDATE_HOOK_KEY_DIVERGENCE` (400) and writes nothing, because one SET clause cannot mark some rows and not others. Narrow the predicate to the rows whose text changes, or update by id. + - A `preserveAudit` (historical import) update keeps a mark it supplies, as it keeps `updated_at`. +- 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. +- 5b6c257: feat(plugin-audit): `sys_activity` rows carry two writer-stamped read-rule columns, and the withheld-row rule is a predicate over them, with no SYSTEM pre-scan (ADR-0139 E6) + + **Run `os migrate activity-changed-fields --apply` at once after upgrading.** After the upgrade, and until it runs, the `sys_activity` rows written before the upgrade carry no stamp and are withheld from every non-system reader: activity feeds, the header bell and record timelines show only the rows written since the upgrade. The door is `os migrate activity-changed-fields`, dry run by default, `--apply` to write, idempotent, and it stamps both columns in one run. Its flags are `os migrate audit-metadata-bodies`'s, one for one (`--database-url`, `--apply`, `--yes`, `--force`, `--json`). The columns arrive with the upgraded deployment's schema sync, which an `--apply` run performs itself; a dry run against a database no upgraded process has opened cannot read them and exits `1`. + + Clause-②: yes + + `sys_activity` gains two columns, stamped at write time by one derivation (`planActivityStamp`): the CRUD mirror stamps its own rows, and a `sys_activity` before-insert hook stamps every row any other writer inserts (an app's own server action, the seed loader), over whatever value that writer sent. + + - `changed_fields` (`tags`) — the key set of the recorded change: for an update, every field its recorded `old` / `new` values name; `[]` for a create, a delete, a note, or an update that touched only `internal` fields. It is served narrowed to the fields the reader is served. + - `milestone_key` (`text`) — for a fired activity milestone, one sorted, comma-joined key of every field the object's `activityMilestones` declaration watched when it fired; `null` for every other row. It is never served. + + The withheld-row rule is now a WHERE over those two columns, one branch per parent object, on `find`, `findOne`, `count` and `aggregate`. It reads no row, so the 2,000-row SYSTEM pre-scan and its window are gone: a broad read of the stream is served every row the caller may read, with a true `total`. What the rule withholds: + + - an update row whose `changed_fields` is non-empty and names no field the reader is served (unchanged); + - a fired milestone row, unless its key equals the key of the object's CURRENT `activityMilestones` declaration and the reader is served every field that declaration watches. **The cost:** a milestone row written under an earlier declaration, or naming a since-removed field, is withheld from every non-exempt reader until retention ages it out (14 days declared; `lifecycle.retention_overrides` may lengthen it); + - an unstamped row, from every non-system reader: one at rest from before the upgrade until the backfill stamps it, and one the planner cannot judge (a milestone row naming no readable watched-field list), which stays unstamped. + + The parent-field query guard covers both columns as it covers `metadata`: a reader withheld any field of the parent is refused a filter, sort, group or aggregate over `changed_fields` or `milestone_key` (`PERMISSION_DENIED` / 403). + + `@objectstack/plugin-audit` exports the backfill, `backfillActivityChangedFields` (with `ACTIVITY_BACKFILL_PAGE_SIZE` and its report and engine types), and the planner, `planActivityStamp` (with `ACTIVITY_CHANGED_FIELDS_COLUMN`, `ACTIVITY_MILESTONE_KEY_COLUMN` and `ActivityStamp`). Inside the module (none of it is exported from the package), `computeWithheldUpdateFilter` keeps its name and now builds the predicate, `isWithheldOnlyUpdate` keeps its name as the update half over `changed_fields`, and `isWithheldMilestone` / `isWithheldRow` are gone with the pre-scan they judged for. +- 67e8792: feat(plugin-audit): `sys_comment` rows carry a writer-stamped parent pair, `object_name` + `record_id`, and the comment-thread read gate narrows by it with one `ReadableIds` leaf per parent object, with no pre-scan (ADR-0139 E7) + + **Run `os migrate comment-parent-pair --apply` at once after upgrading.** After the upgrade, and until it runs, the `sys_comment` rows written before the upgrade carry no pair, and the read gate answers them as it answered every comment before: by a SYSTEM pre-scan of at most 2,000 candidate rows per read, fail closed. So a broad comment list — one not scoped to a single record's thread — can omit comments the reader may see, and its `total` agrees with the short list; a record's own comment thread is unaffected. The door is `os migrate comment-parent-pair`, dry run by default, `--apply` to write, idempotent, and it stamps both columns in one run, keeping each row's `updated_at`. Its flags are `os migrate activity-changed-fields`'s, one for one (`--database-url`, `--apply`, `--yes`, `--force`, `--json`). The columns arrive with the upgraded deployment's schema sync, which an `--apply` run performs itself; a dry run against a database no upgraded process has opened cannot read them and exits `1`. + + Clause-②: yes + + `sys_comment` gains two `readonly` text columns and an index over them. They are derived from `thread_id` (`{object}:{record_id}`, split on the first colon) by one planner, `planCommentParentPair`, and `thread_id` stays the key authors write: nothing changes for a writer. A `sys_comment` before-insert hook stamps every inserted row, whoever writes it, over whatever value the writer sent; an update that names `thread_id` (a re-point) is re-stamped from it; and an update that names either column without a thread has the pair re-derived from the row's stored thread, for every caller — a historical import (`preserveAudit`) included, where the engine's update-side `readonly` strip keeps a value — so no writer's value for either column lands. A multi-row update that names either column without a thread is refused whole (`VALIDATION_ERROR` / 400, nothing written) when its rows' threads name different records, since one write cannot store each row's own pair; over rows sharing one thread it stores that thread's pair. A thread that names no record stamps `null` / `null`, and such a comment stays hidden from every non-system reader, as before. + + The comment-thread read gate (`installCommentReadVisibility`) is now the parent-record gate the activity stream and the compliance ledger use, over the comment's own pair: one branch `{ object_name: P, record_id: { $in: ReadableIds.of(P) } }` per registered object other than `sys_comment`, ORed and ANDed into the read's `where`, on `find`, `findOne`, `count` and `aggregate`. A broad comment read is served every comment the reader may read, on any table size, with a true `total`. What keeps the bounded 2,000-row fail-closed probe, chosen up front by name, never by catching a refusal: + + - ADR-0139 D5's three classes: a delegated (on-behalf-of) caller (the whole read); a parent narrowed by an object-scoped read middleware (`sys_attachment`, `sys_comment_reaction`, `sys_approval_request`, …); a parent bound to another datasource; + - a security service that answers no `canReadObject`, or none at all (no leaf is written without it), and an engine that cannot classify a parent — each sends the whole read to the probe and is said once; + - a row with no stamped pair (until the backfill reaches it): probed by its `thread_id`, restricted to rows with no pair, never leafed; + - a composition whose registered `sys_comment` declares no pair: the thread probe over every row, said once. + + **The cost:** a deployment whose driver compiles no `ReadableIds` leaf — `driver-mongodb`, until ADR-0139's O3 — now fails a non-system comment read with `INTERNAL_ERROR` / 500 naming the driver in the log, as the activity, ledger and attachment reads already do (ADR-0139 D3); the gate does not catch that refusal into an empty list. + + Comment reactions are unchanged: their only parent is `sys_comment`, which this gate narrows by an object-scoped middleware, so the reaction gate keeps the bounded probe (ADR-0139 D5.2). + + `installCommentReadVisibility` takes an optional third argument, the security source whose `canReadObject` admits a leaf (`ParentObjectReadAdmissionSource`); without it every comment read is the bounded probe, as before. Its first parameter widens from `CommentAccessEngine` to `ParentRecordGateEngine` (`CommentAccessEngine` plus three optional members: a registry view, `hasObjectMiddleware` and `resolveEffectiveDatasource`); every existing caller's engine still satisfies it. Both types reach the published declaration through that signature. `CommentAccessEngine` gains an optional `getSchema`. `@objectstack/plugin-audit` exports the backfill, `backfillCommentParentPair` (with `COMMENT_PARENT_PAIR_BACKFILL_PAGE_SIZE` and its report and engine types), the planner `planCommentParentPair` (with `COMMENT_PARENT_OBJECT_COLUMN`, `COMMENT_PARENT_RECORD_COLUMN` and the `CommentParentPair` type). +- d65826f: fix: framework writes on tenant-column objects name the organization they can derive (ADR-0131 D9) + + Clause-②: yes (widening) + + A write on a tenant-column object with no organization is derived under `single` and refused under a wall (ADR-0131 D9). Before the engine stops letting such writes through, each of these producers now names the organization it can derive itself. Under `single` that is the Default Organization, which is the owner the engine would derive anyway, so no reader changes. Under a wall the row names an organization only where the producer has one. The rows it cannot attribute are written exactly as before. + + - **`@objectstack/plugin-auth`.** The self-registration grant (`sys_user_permission_set`) and the auth mails about a user (verification, password reset, account invitation, magic link, change-email notice → `sys_email`) read one derivable organization: the `tenancy` service's `defaultOrgId()`. Under `single` that is the Default Organization. Under a wall it is none, because the registrant belongs to no organization yet. The verification and reset mails used to persist with no organization even under `single`. + - **`@objectstack/plugin-audit`.** An activity row (`sys_activity`) about a record with no organization of its own (a `sys_user`, a platform setting), written with no session organization (a sign-up), takes the Default Organization under `single`. The record's own organization and then the session's still win. The ledger's `tenant_id` attribution is unchanged. New optional `AuditWriterOptions.getDefaultOrganizationId()`; `AuditPlugin` wires it to the `tenancy` service. + - **`@objectstack/service-settings`.** The `sys_secret` ciphertext and the `sys_setting_audit` history row of a `tenant` or `user` value carry that value's organization, which is the same owner as its `sys_setting` row. A `global` value's rows name none. New optional `organization_id` on `SettingsSecretStore.insert`'s row and `organizationId` on `SettingsAuditWriter.write`'s entry; the built-in writers persist them. A failed `sys_setting_audit` write is now reported at `error`, not `warn`: the settings write succeeds while its history row is lost. The first report names the consequence and the fix. `buildSettingAuditWriter`'s optional logger argument keeps every sink it accepted (none, `{}`, or one with an optional `warn`) and additionally takes a sink that carries `error`. It reports through `error` when the sink has it, else through `warn`. + - **`@objectstack/verify`.** The RLS probe and position personas' grants (`sys_user_permission_set`, `sys_user_position`) belong to the organization the persona is a member of (the Default Organization under `single`). They used to be written with an explicit `organization_id: null`. A persona that belongs to no organization, or to several, names none. + +### Patch Changes + +- 92ce3af: fix(plugin-approvals, plugin-audit, plugin-sharing): list views no longer show the target object's API name as an Object column + + Clause-②: no + + - **The Object column leaves these lists.** These list views showed `object_name`, the target object's API name (such as `showcase_announcement`), as plain text beside the `record_id` record pointer. They no longer list it: + - `sys_approval_request`: `all_requests`, `my_pending`, `submitted_by_me` and `completed`; + - `sys_approval_action`: `recent` and `all_actions`; + - `sys_audit_log`: `recent`, `writes_only`, `record_views` and `all_events`; + - `sys_record_share`: all six views; + - `sys_share_link`: all four views. + - **The Record column names the target record.** `record_id` is the id half of the `record_id` / `object_name` pointer pair. The console draws it as the target record, with its object's icon and the record's name, and fetches `object_name` for that cell without a column. This relies on a console that fetches a pointer's type half for its cell, as the console bundled with this release does; an older console draws the raw record id in that column. + - **Unchanged:** `object_name` stays a declared field on all five objects, and the pointer pair, its indexes and every query on it are untouched. `sys_audit_log`'s `config_changes` view keeps its Object column, because its rows carry no `record_id`. + + A patch: entries removed from list view `columns` on shipped platform objects. No export, type, accepted value, served key or stored column changes. +- 5018fc3: fix(plugin-audit): an object definition changed through the metadata door takes effect in the audit writers at once, with no restart + + Clause-②: no + + The audit writers kept each object's first definition for the life of the process. They now read the registered definition on every call. The registry already holds the merged definition behind its own invalidation points, so the read is two map lookups. + + - **Attachments follow `enable.files`.** An object whose `enable.files` was switched on through `PUT /api/v1/meta/object/:name` kept refusing `sys_attachment` writes with 403 `FILES_DISABLED` until the server restarted, although the metadata read-back already showed `enable.files: true`. The same attachment is now admitted at once, and switching the flag off refuses it again. `enable.feeds` on comments follows the same read. + - **The ledger copy follows field changes.** A `password` field added through the door to an already-audited object is masked on the ledger copy from the moment it is added, as on every other read exit. The same read decides the mask for `secret` fields and the omission of `internal` ones. + - **The activity row follows the object.** `enable.activities`, `activityMilestones`, the title field and the object label are read from the current definition too. + + What the metadata door accepts is unchanged. +- d1d42ec: `sys_activity.actor_name` is written: a user's create, update or delete records that user's display name on its activity row, so the record History tab names who acted instead of "Unknown user" + + Clause-②: no + + `sys_activity` declares `actor_name` and lists it among its highlight fields, because activity + entries are denormalized snapshots read in time order. The audit writer filled `actor_id` and never + `actor_name`, so every row carried the user's id and no name. A renderer that reads the name, such + as the record History tab, showed "Unknown user" for every entry, for every user. + + The writer now records the name when it writes the row. The name is the title of the user's + `sys_user` row (its `name` column), read once per user and kept for 30 seconds. A predicate write + over many rows costs one `sys_user` read for its actor, not one per row. + + - **A user write.** `actor_name` is the user's display name. `actor_id` is unchanged, and the name + is read for that same id. This includes a write the system authorized on behalf of a credited + user (`attributedUserId`). + - **A user whose name cannot be read** (no row, or a blank name). `actor_name` stays empty. The + writer never puts the id in it. + - **A write with no user** (boot, migration, a scheduled job, a flow running as the system). + `actor_name` stays empty, as before. Per ADR-0118 D1 the system actor is `null`, and the UI + renders the "System" label for it. + - **An object with `enable.activities: false`.** No activity row is written and no name is read, + as before. + + Rows written before this change keep `actor_id` and no name. This change does not backfill them. + Nothing you author changes: no key, option, export or type is added or removed. +- e194ab4: `sys_activity.actor_avatar_url` is written: a user's create, update or delete records that user's profile image on its activity row, so the record History tab and the activity feed show the user's avatar instead of the generic one + + Clause-②: no + + `sys_activity` declares `actor_avatar_url` as one of its denormalized actor columns, and the record + History tab and the record activity feed both read it as an image source. The audit writer never + filled it, so every row carried a null avatar, even for a user with a profile image. + + The writer now copies the acting user's `sys_user.image` (the profile-image URL that + `POST /api/v1/auth/update-user` and the console's avatar uploader write) into `actor_avatar_url` + when it writes the row. The value is copied as stored. It comes from the same `sys_user` read that + already supplies `actor_name`, read once per user and kept for 30 seconds, so the avatar adds no + read: a predicate write over many rows still costs one `sys_user` read for its actor. + + - **A user with a profile image.** `actor_avatar_url` is that image's URL. + - **A user with no profile image.** `actor_avatar_url` stays empty, as before. + - **A write with no user** (boot, migration, a scheduled job, a flow running as the system). + `actor_avatar_url` stays empty, as before. Per ADR-0118 D1 the system actor is `null`, and how + the system is shown is the renderer's choice. + + Rows written before this change keep their empty avatar; a later change to a user's image shows on + rows written after it, as with the name. +- 8bd0fcd: A `sys_activity` row that an `activityMilestones` entry fired carries `metadata.kind: 'milestone'`, so a reader can tell a milestone from an ordinary update or a finished task + + Clause-②: no + + ADR-0052 §5 keeps `sys_activity.type` domain-neutral and puts a row's domain kind in + `metadata.kind`. A fired milestone keeps the `type` its declaration names: `completed`, or + `updated` when it names none. Nothing on the served row said it was a milestone. So a "Case + resolved" milestone declared with `type: 'completed'` read as a finished task, and a feed that + hides completed tasks hid it too. + + The CRUD mirror now writes `metadata.kind: 'milestone'` on every row a milestone fires. `type` is + unchanged, and a row that no milestone fired carries no `kind`. + + The kind is served by the same rule as the text beside it. "A milestone fired" means that a + watched field entered its declared value, so: + + - **A reader served every field that a declared milestone watches** is served `metadata.kind`. + - **A reader not served one of those fields** is served the row without `metadata.kind`, just as + it is served the change without that field. + - **A row an app writes itself** keeps its own `metadata.kind` for every reader, as written. + + The rest of the redaction is unchanged. The provenance declaration (`metadata.text_sources`) is + never served, and a change key the reader is not served is dropped. Rows written before this change + carry no kind, and this change does not backfill them. Nothing you author changes: no key, option, + export or type is added or removed. +- f313fbc: A `sys_activity` row that an `activityMilestones` entry fired is withheld from a reader who is not served every field a declared milestone watches + + Clause-②: no + + A fired milestone's row speaks about its watched fields: its `type` and its summary come from the + milestone declaration, beside `metadata.kind`. Narrowing the row column by column left those two + columns speaking to a reader not served a watched field. + + The row is now withheld from that reader as a row, on every read (`find`, `findOne`, `count` and + `aggregate`), by the same pre-scan and the same served-field answer that withhold an update whose + every recorded change the reader is not served. A list's total, its pages and a by-id read all + agree with the rows served. + + - **A reader served every watched field** is served the row unchanged, with `metadata.kind`. + - **A reader withheld only fields no milestone watches** is served it too. + - **A row no milestone fired**, and a row an app writes itself, are unchanged. + - **If the pre-scan fails**, the read is denied rather than served unjudged. + + Rows written before the CRUD mirror declared the watched fields carry no `metadata.kind`, so nothing + on them says a milestone fired; they are judged as before. Nothing you author changes: no key, + option, export or type is added or removed. +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. +- 6a40a09: A failed boot provisioning of an audit table is logged at `error`, naming what is lost and the fix + + Clause-②: no + + At `kernel:ready`, `AuditPlugin` creates the storage for `sys_audit_log`, `sys_activity`, `sys_comment` and `sys_comment_reaction`. When the driver refuses one of those DDL statements, the boot still completes and the deployment looks healthy, but the table does not exist. For `sys_audit_log` and `sys_activity` the loss is silent: audited writes keep succeeding while their audit and activity rows have nowhere to land. That failure was logged at `warn`. It is now logged at `error`, once per object per boot and never per write. The boot is still not failed. + + - **What the line says.** It names the object, what is lost without it, and that the deployment keeps looking healthy. It gives the fix: resolve the driver's refusal, then restart (this provisioning runs on every boot) or run `os migrate apply`, which creates the missing storage. `os migrate plan` lists it as a `create_table` entry for that object. Both remedies were measured to create a missing `sys_activity` once the fault was gone, with and without the telemetry sibling database. + - **Where the driver's error is.** In the record's meta as `{ object, error }`, not in the message, so a multi-line driver error stays one record. + - **What is unchanged.** The other objects are still provisioned when one fails, and the placement lines (`system tables provisioned`, the non-default datasource note) are unchanged. A log search on `could not provision sys_activity storage` (or any of the four object names) still matches the line. +- 8e4a230: fix(plugin-audit): a user's own write to their own `sys_user_preference` row no longer adds a `sys_activity` row. The `sys_audit_log` row is written as before + + Clause-②: no + + The console saves the user's "recently visited" list (`ui.recent`), favourites and palette recents as `sys_user_preference` rows as they navigate. The audit writer mirrored each of those writes into `sys_activity`, and the bell's Activity tab lists the newest activity rows the caller may read. So every navigation came back to the user as `Updated User Preference "ui.recent"`, and a fresh user's bell showed nothing else. + + - **What changed.** For a per-user UI-state object, the writer skips the activity row when the acting user is the row's owner (`user_id`). The list of such objects is closed and declared beside the writer's exclusion list. Today it holds one object, `sys_user_preference`. + - **What stays.** The `sys_audit_log` row for the same write is written exactly as before. Another actor's write to a user's preference row keeps its activity row: an organization administrator editing it from Setup, a system write with no user, or a write that gives the row a different owner. Writes to every other object are unchanged. + - **Existing rows.** Activity rows written before this release are not deleted. They leave the feed when the `sys_activity` retention window (14 days) removes them. + - **Nothing to migrate.** No configuration is needed. +- d294951: Boot-time system-table provisioning honours `OS_SKIP_SCHEMA_SYNC` / `skipSchemaSync` + + Clause-②: no + + `OS_SKIP_SCHEMA_SYNC=1`, or `ObjectQLPlugin`'s `skipSchemaSync` option, declares that a deployment's DDL is managed out-of-band ("Use after running migrations manually"). The engine's boot honoured it, but `AuditPlugin` and `MessagingServicePlugin` still created their system tables at `kernel:ready`. A deployment that set the flag got tables created behind its back while its boot log said no DDL ran, and a database role without DDL rights got a DDL failure it never asked for. + + - **What changes.** With the flag set, `AuditPlugin` (`sys_audit_log`, `sys_activity`, `sys_comment`, `sys_comment_reaction`) and `MessagingServicePlugin` (`sys_notification`, `sys_inbox_message`, `sys_notification_receipt`, `sys_notification_delivery`, `sys_notification_preference`, `sys_notification_subscription`, `sys_notification_template`, `sys_http_delivery`) issue no DDL and no existence probe at boot. Each logs one `info` line naming the objects it did not provision and the flag, and pointing at `os migrate apply`, which creates any that are missing (`os migrate plan` lists them as `create_table`). That path was measured to create all twelve tables, on one database, with the telemetry sibling database, and with the flag set in the migrate run's own environment. + - **Which value is read.** The one `ObjectQLPlugin` resolved: the option, else the environment variable. An explicit `skipSchemaSync: false` therefore still provisions under `OS_SKIP_SCHEMA_SYNC=1`. `ObjectQLPlugin` publishes that value on the engine instance it registers. It is a property of the instance, not a declared member, so no type or export changes. + - **What is unchanged.** With the flag unset, provisioning is exactly as before. `syncObjectSchema` is not gated, so on a flag deployment a runtime publish still gives a new object its table. +- 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. +- 94114c1: fix(plugin-audit): the audit ledger no longer records a user's own writes to their own UI-state row (`sys_user_preference`). A deployment that exports `sys_audit_log` to a SIEM or a compliance archive stops receiving those rows from this release on, and nothing else tells that consumer. + + Clause-②: no + + The console saves a user's list preferences, recent items, favourites and palette recents as that user's own `sys_user_preference` rows, one write per click. Each write landed an `update` row in `sys_audit_log`, so Setup's Audit Logs opened on a run of one user's clicks, and the System Overview's "Events by User" chart counted them. + + - **What changed.** When the acting user is the row's owner (`user_id`) on both sides of the write, the audit writer records neither the `sys_audit_log` row nor the `sys_activity` row. It already skipped the activity row for such a write; it now skips the ledger row by the same test. A create, an update and a delete are all covered. + - **What is still recorded.** Every other actor's write to the same rows: an administrator editing someone's preferences from Setup, a system write with no user, and a write that moves a row to another owner. `sys_user_preference` is not an excluded object, and every other object is unchanged. + - **Nothing to migrate.** Rows already in `sys_audit_log` stay. + + The list of per-user UI-state objects is closed and declared inside the plugin, and today holds one object, `sys_user_preference`. No metadata key adds an object to it. +- f7af839: Clause-②: no + + Setup's Diagnostics group has one audit entry, "Audit Log", and it opens the audit browser + + - **What changed.** `@objectstack/plugin-audit` contributed two Setup entries onto the same rows: "Audit Logs" (the `sys_audit_log` record list) and "Audit Log Browser" (the console's `audit:log` page). It now contributes one, "Audit Log", which opens the browser. The browser filters by action, object, actor and date, offers the record list's views (Writes, Auth, Record Views, …) as presets, and links to the record list ("Open as list"). Those two parts of the page ship in the console this release pins. + - **Ids.** The entry keeps the id `nav_audit_logs`, so a sidebar pin a user set on "Audit Logs" stays on the new entry. The id `nav_audit_log_browser` is no longer served; a pin set on it no longer shows, and the user can pin "Audit Log" instead. + - **Labels.** `@objectstack/platform-objects` serves the entry's label in the four shipped locales (Audit Log, 审计日志, 監査ログ, Registro de auditoría), and drops the "Audit Log Browser" label. + - **Unchanged.** The `sys_audit_log` object, its list views, its routes and its data API answer as before. The record list is no longer an entry in Setup's menu, so the ⌘K palette's record search in Setup no longer searches audit rows. +- 62c4441: Clause-②: no + + fix(plugin-audit): a read of the activity stream, the compliance ledger or comments that names its parent objects writes one parent-record branch per named object, not one per registered object + + - **What changed.** The parent-record read gate on `sys_activity`, `sys_audit_log` and `sys_comment` ANDs one branch per parent object into every read: a `ReadableIds` leaf, or the bounded probe for an on-behalf-of caller, a middleware-narrowed parent or a parent on another datasource. It wrote one for every registered object, whatever the read asked for. When the read's own `where` names the objects its rows are about (`object_name` as a plain value, `$eq` or `$in` of machine names, at the top level or inside a top-level `$and`), the gate now writes branches for those objects only, and the bounded probe pre-scans only the named objects of its classes, or does not run. The activity stream's withheld-update rule, which wrote one branch per enumerated object too, narrows the same way. + - **What it costs now.** Measured on SQLite with 148 registered objects: a newest-first 20-row activity read filtered to 3 objects went from a 711.8 KB statement with 2,629 bindings to 14.9 KB with 60, for the rows and again for the total, and its two probe pre-scans no longer run. + - **Unchanged:** the rows, the totals and the grouped counts every reader is served. A read of any other shape (no `object_name` filter, an `$or`, a `$not`, another operator, a value that is not a machine name) keeps every branch, as before. No export, option or stored shape changes. + - **One failure mode is gone:** a leaf or a probe for an object the read does not name is no longer composed or run, so a refusal composing it, or a failing pre-scan for it, no longer fails or denies that read. +- 77e9f79: fix(plugin-audit): an activity summary written right after a workspace language change is stored in the new language, not the old one + + Clause-②: no (no export, option key, signature or schema changes; the held write locale is retired sooner, and the option docs say when) + + - **What was wrong.** The audit writer held the write locale for each tenant/user scope for 30 seconds, and nothing retired it when the `localization.locale` setting changed. A `sys_activity.summary` line is stored once, at write time, so every audited write in that window (a user's create, a seed publish) kept the old language for good. + - **What changes.** A write to `sys_setting` or `sys_platform_setting` through the engine now retires every held write locale. That covers a change made through the settings API, which persists through the engine, and a direct row write such as a seeder's, which emits no settings event. These are the same writes that retire `resolveLocalizationContext`'s own cache. A lookup still in flight when such a write lands is not held. The 30-second bound stays for what the writer cannot see: the storage behind a host `getLocale` resolver, and a settings write on another node. + - **Unchanged:** the host `getLocale` resolver is still asked first, and a write with no settings change between it and the last one is still answered from the held locale, without a settings read. +- 538f923: fix: an activity row is written only when an organization is derivable, never with a NULL organization, and the v18 ownership ceremony deletes the existing NULL activity rows it cannot attribute (ADR-0131 D9) + + Clause-②: no + + - **`@objectstack/plugin-audit`.** The audit writer's activity mirror (`sys_activity`) takes its organization from the record, then from the acting session, then from the `tenancy` service's Default Organization under `single`. When none of them names one, the mirror now writes no activity row at all. Before, it wrote a row with `organization_id` NULL. Under a wall that row was readable by no organization member. The change is still recorded: the compliance ledger row (`sys_audit_log`) is written as before, with its `tenant_id` attribution. Under `single` the Default Organization is always derivable, so nothing changes there. A record that names its organization keeps its activity row, with that organization. + - **`@objectstack/cli`.** `os migrate organization-ownership` still attributes a NULL `sys_activity` row when its record carries an organization. On a walled database (any posture but `single`), the mirror-deletion step now deletes the NULL rows no anchor attributes, such as activity about a sign-up's `sys_user`. Before, those rows were reported as unattributable and held certification. They are 14-day telemetry that the mirror no longer writes, and `sys_audit_log` keeps each change. The plan counts them as the departure `underivable-activity`, and warns before the first write that `--apply` deletes them. The apply report lists the count as the table's `deleted`. Under `single` the Default Organization takes these rows, as before, and nothing is deleted. +- Updated dependencies [5a871e4] +- Updated dependencies [f8ef144] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [913fd6d] +- Updated dependencies [8eac321] +- Updated dependencies [de43524] +- Updated dependencies [a5a781b] +- Updated dependencies [04669a4] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [7879391] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [0b997ea] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [018b077] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [87cd458] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [4663801] +- Updated dependencies [3071d3e] +- Updated dependencies [d294951] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [24e064f] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- 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 [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cd86e41] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [76560a1] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [abb6df0] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [a33f3d5] +- Updated dependencies [d050a8f] +- Updated dependencies [b25d98a] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [077b75f] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [aa31569] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [1bf5f6e] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [ec749fb] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [2f569a9] +- Updated dependencies [69147ab] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [9112bcd] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf16a0d] +- Updated dependencies [cf540a7] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [45bec43] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/objectql@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index d5a1208072e..f81a6c983d6 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Audit Plugin for ObjectStack — System audit log object and audit trail", "main": "dist/index.js", diff --git a/packages/plugins/plugin-auth/CHANGELOG.md b/packages/plugins/plugin-auth/CHANGELOG.md index 3644579cfbe..46b07b102fb 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,1210 @@ # Changelog +## 18.0.0 + +### Minor Changes + +- fbafb85: feat(plugin-email,plugin-auth): identity mail — verification, password reset, magic link, the email-change notice, an account invitation — is recorded in `sys_identity_email`, a deployment-level sibling of `sys_email` with no organization column + + Clause-②: yes (widening) + + ADR-0131 D7, ruling of record 6257510973 (letter Q2 A). Expand-first: this release adds the new log and its write path, and keeps reading every row and job an 18.0 install left behind. It moves and deletes nothing. + + - **The new object.** `sys_identity_email` (`@objectstack/platform-objects`, registered by `EmailServicePlugin`) has `sys_email`'s fields, so the row shape is the same, and `systemFields: { tenant: false }`, so it has no organization column. It is append-only, readable over `get` / `list`, and gated by `manage_platform_settings` (like `sys_job`), because its rows carry password-reset and sign-in links. + - **The contract.** `SendEmailInput` and `SendTemplateInput` (`@objectstack/spec/contracts`) gain `identityMail?: boolean`. With `true`, the mail service writes the row to `sys_identity_email`. Without it, the row goes to `sys_email`, as before. `identityMail: true` together with an `organizationId` is refused as `VALIDATION_FAILED` and recorded as a rejected row in `sys_identity_email`. The organization is not written. + - **One pipeline, both logs.** The queue worker, the outbox drain hook, the boot outbox sweep, attachment reclamation and the internal-header readback read a row from the log it lives in. A delivery or reclaim job for a `sys_identity_email` row carries `object: 'sys_identity_email'` beside `rowId`. A job with no `object` reads `sys_email`, which covers every job an 18.0 process queued. A job naming anything else is refused at `error` and touches no row. `EmailPersistence.insert` / `update` / `readHeadersJson` take the log as an optional last argument. A custom implementation that ignores it keeps every row in its one store. `@objectstack/plugin-email` exports `TENANT_EMAIL_OBJECT`, `IDENTITY_EMAIL_OBJECT`, `EMAIL_LOG_OBJECTS`, `isEmailLogObject` and `EmailLogObject`. + - **plugin-auth.** The five identity sends say `identityMail: true` and name no organization, under every posture. Before, they carried the Default Organization under `single` and nothing under a wall. The organization invitation (`auth.invitation`) is tenant mail. It still carries its organization and still lands in `sys_email`. + - **What a reader of `sys_email` sees.** + - **Before.** Identity mail was a `sys_email` row: stamped with the Default Organization under `single`, organization-less under a wall. + - **Now.** New identity mail is a `sys_identity_email` row. Rows already in `sys_email` stay there and are still delivered, swept and reclaimed. + - **Remedy.** A report, export or query that looks for verification or reset mail reads both logs. For example: `ql.find('sys_identity_email', { where: { related_object: 'sys_user', related_id: userId } })` beside the same query on `sys_email`. + - **Inventory.** `os migrate organization-ownership`'s inventory lists `sys_identity_email` as deployment-level (column-drop fate, nothing to attribute). Moving the identity rows already in `sys_email` is an 18.x upgrade-ceremony step, not part of this release. +- b2e7870: fix(security): under the `single` tenancy posture the first-user platform-admin grant is owned by the Default Organization, and every reader of platform standing reads that row + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + - **FROM** (`@objectstack/plugin-security`): a `single` deployment holding several organizations, none slugged `default`, promoted its first user with an organization-less `admin_full_access` grant. **TO:** it is not promoted; the bootstrap logs one `error` line, returns `reason: 'default_organization_unresolved'` and writes no grant row. **Fix:** give the intended organization the slug `default` (or keep one organization, or declare a walled posture); the next `kernel:ready` or sign-up pass promotes. + - **FROM** (`@objectstack/plugin-auth`): a `sys_organization` `slug` change or delete was never refused. **TO:** it is refused with `403 PERMISSION_DENIED` when that organization is the Default Organization whose `admin_full_access` grant is the last unbanned administrator's anchor. **Fix:** give another account administrator standing first, then make the change. + - **FROM** (`@objectstack/plugin-auth`): `ensureDefaultOrganization` under `single`, with a sole organization of another slug, created a second, `default`-slugged organization and bound the administrator there. **TO:** it reuses the sole organization; no second organization is created. **Fix:** none needed; an install that wants a `default`-slugged organization re-slugs its existing one or creates it explicitly. + - **Widening** (`@objectstack/core`): under `single` only an organization-less `admin_full_access` grant conferred `PLATFORM_ADMIN`. Now a grant owned by the Default Organization confers it too and applies in every organization; a pre-existing Default-Organization-scoped `admin_full_access` grant gains the rung on upgrade; and `sys_organization` becomes a standing-deriving table (the resolver reads it only under `single` for a holder of an organization-owned `admin_full_access` grant — one or two small reads per uncached request; the last-administrator guard adds three to six small reads per guarded write). + + - **The promotion writes the Default Organization (`@objectstack/plugin-security`).** A `single` deployment with no configuration still promotes its first user that can sign in. The `admin_full_access` grant row that promotion writes now carries the Default Organization as its `organization_id` (ADR-0131 D5), never NULL. The Default Organization is the one with `slug = 'default'`, else the only organization: the organization the auth plugin creates at boot. A deployment that names no Default Organization (several organizations, none with the slug `default`) is not promoted. The bootstrap logs one `error` line saying why and returns `reason: 'default_organization_unresolved'`. It writes no grant without an owner. `OS_PLATFORM_OWNER_EMAIL` is still the walled-posture channel and is not required under `single`. + - **The authorization resolver reads that row (`@objectstack/core`).** Under `single`, `PLATFORM_ADMIN` derives from an `admin_full_access` grant owned by the Default Organization, and still from the organization-less row an earlier release wrote. The anchoring grant applies in whichever organization the user acts in, as the organization-less row always did. A grant of the set owned by any other organization confers the set in that organization, never platform standing. Under a walled posture no grant anchors standing (unchanged). One predicate decides both postures, with the posture as its parameter. The resolver reads `sys_organization` only for a user who holds an organization-owned `admin_full_access` grant under `single`. + - **An upgraded database keeps its administrator.** `os migrate organization-ownership --apply` gives a `single` database's organization-less grants to the Default Organization, the platform admin's grant included. Before this change the resolver then stopped grading that admin `PLATFORM_ADMIN`, and every later boot found no organization-less holder and promoted again. The holder scan now reads the Default Organization's grant, so a migrated database keeps its admin and promotes nobody else. A user who holds both rows counts as one holder. + - **The last-administrator guard and the default-organization bootstrap read it too (`@objectstack/plugin-auth`).** The break-glass guard counts the holder of the Default Organization's grant as a platform admin and treats that grant as evidence that the environment once had one. It also judges the two organization writes that move the anchor off that grant: changing the Default Organization's `slug` while another organization exists, and deleting it. Either is refused when it would leave no administrator who can sign in. `ensureDefaultOrganization` finds the admin through the same grant. Under `single` it also reuses the Default Organization when that is a sole organization with another slug, where it used to create a second, `default`-slugged organization. +- 34dba5a: Under the `single` tenancy posture the Default Organization is created at boot, before the application seeds and before the server accepts a request, and the first admin of a fresh deployment is its owner + + Clause-②: yes (narrowing) + + + + **BREAKING** boot and accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3). + + - **A boot invariant.** Under the `single` posture (the posture in force, so a degraded walled request counts), the auth plugin's `start()` finds or creates the Default Organization (`slug: 'default'`), with or without a platform admin. Every application plugin starts after it. Before this change the organization was created on `kernel:ready` only once a platform admin existed, so on a fresh deployment it arrived with the first sign-up, after the seeds had loaded with no owner. + - **A failure stops the boot.** If the organization cannot be created, or the store cannot be read, `start()` throws and the deployment does not boot. Before, the bootstrap logged a warning and the deployment served anyway. An install holding several organizations and none with `slug: 'default'` gets no new organization; the boot logs it and continues. + - **`autoDefaultOrganization: false` no longer means "no organization".** It now turns off only the platform admin's owner bind. A host that set it to keep an organization-less `single` deployment gets the Default Organization anyway: under `single` no row is organization-less. + - **Every user belongs to it from the first boot.** Because the organization exists before anyone signs up, the membership reconciler binds every new user (under the `auto` membership policy) to it as `member` the moment they are created, so every session carries it as the active organization. Before, a user created before the first admin carried none. The one-time membership backfill likewise has its target at the first `kernel:ready`. + - **The first admin is the owner.** That includes the first admin, whom the reconciler binds as `member` before the owner bind learns they are the platform admin. While the one-time owner bind is undecided and the Default Organization has no owner, the bootstrap promotes that `member` row to `owner` in place and records the decision as `promoted`. A decided bind, an existing owner, or a membership elsewhere is never touched. + - **The in-memory driver answers `503` until C8.** `@objectstack/driver-memory` refuses tenant-scoped reads, and every session now carries the Default Organization, so under `single` its signed-in reads answer `503` until ADR-0131 C8 (#15212, D8) gives `single` no read predicate. The platform admin met the same refusal before this change, once the Default Organization existed. A SQL driver (`connection: { filename: ':memory:' }` for an in-process store) serves the deployment meanwhile. + + **The remedy.** If the boot stops on the Default Organization, make the `sys_organization` insert land: check the datasource's write permission and connectivity, and whether a legacy unique index on `slug` refuses `default`. A host relying on `autoDefaultOrganization: false` for an organization-less `single` deployment has no such deployment any more; run a walled posture if organizations are meant to be absent until created. +- aa71c4d: `sys_user_permission_set` gains `permission_set`, the name of the permission set a grant holds, written beside `permission_set_id` (ADR-0131 D4) + + Clause-②: yes (widening) + + - **The column.** `permission_set` is a read-only text column, at most 100 characters, holding the `name` of the `sys_permission_set` row that `permission_set_id` points at. It is readable everywhere the grant row is readable. A grant written before this release has `NULL` here until the backfill stage rewrites it. No reader uses the column yet: the grant is still resolved from `permission_set_id`, which stays until it is dropped in a later major (ADR-0131 D10). + - **The platform writes it, on every write that carries `permission_set_id`, for every caller.** Two `@objectstack/plugin-security` engine hooks (`beforeInsert` and `beforeUpdate` on `sys_user_permission_set`) look up the set by id and store its name. A write that sends only the id, which is how the data door and the Setup forms write, gets the name filled in. + - **A name that names a different set is refused** with `400 VALIDATION_FAILED`, `invalid_value` at `permission_set`. This covers a name that disagrees with the id written beside it, or with the id already stored when only the name is written. For a non-system caller it also covers a name beside an id that names no set this caller's organization can see. A name that agrees is accepted. A cleared name (`null`) is not stored as a clear: the derived name is written back. Before this change the column did not exist, so a write naming it was refused with `400 INVALID_FIELD`. No write that was accepted before is refused now. + - **Every platform grant writer writes both columns:** the organization-admin reconcile and the platform-admin promotion in `@objectstack/plugin-security`, the self-registration grant in `@objectstack/plugin-auth`, and the RLS probe persona in `@objectstack/verify`. + - **Nothing to migrate.** No principal's grants change. To fill the column on grants written by your own code, write the set's name as `permission_set`, or leave it out and the platform fills it in. Do not write any other value there. +- 2e10c9a: feat(core)!: the authorization resolver reads which permission set a user grant holds from the grant's name, `sys_user_permission_set.permission_set` (ADR-0131 D4) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **What the resolver reads now.** `resolveUserAuthzGrants` (and every surface built on it: `resolveAuthzContext`, `hasPlatformAdminStanding`, the explain engine, `runAs: 'user'` automation) finds a user grant's permission set by the grant's `permission_set` name. The set row is the grant's own organization's row of that name, or else the organization-less row of that name. `permission_set_id` is no longer read for this. Deactivation is still read from the set row. Sets a principal holds through a position are still reached through the position binding's id. + + - **Platform standing.** An unscoped grant is read against the organization-less `admin_full_access` row only. An organization's copy of `admin_full_access` gives that organization's grants its capabilities, never platform standing. + - **Nobody's resolved permissions change** where a grant's name and id agree. The platform's grant writers store both, and the one-time backfill names grants stored before the name column existed. Goldens for the platform administrator, an organization administrator, a member and an agent are unchanged in `single`, `group` and `isolated`. + + **What stops conferring.** + + - **A grant that names nothing.** This is a grant the backfill has not named yet, or one it could not name: its id names no set row, its id names another organization's set row, or the name is not in the security catalog. Such a grant now confers nothing through the resolver. The backfill runs at `kernel:bootstrapped`, before any server opens its socket, so on the first boot after upgrading no request is answered before the grants it can name are named. The grants it cannot name are listed in its boot report. + - **A grant whose id names another organization's set.** Before this change, such a grant conferred that set. It now confers nothing. + - **Remedy.** Read the backfill's boot report, which lists each grant by row id. Re-point each listed grant at a permission set of its own organization, or at an organization-less one, with an update of `permission_set_id`. The platform stamps the name, and the grant confers again. + + **What the grant name hook refuses now** (`@objectstack/plugin-security`). The name is taken only from a set row of the grant's own organization, or from an organization-less one. A tenant-less system writer can read every organization's sets, and it could name an organization-less grant after another organization's set. Now such a grant is stored without a name. A write that supplies that name is refused, from a system writer too, with the hook's usual `400 VALIDATION_FAILED` and `invalid_value` at `permission_set`. An update that re-points a grant at such a set, or moves a grant to an organization its set does not belong to, clears the name. + + **What the last-administrator guard judges now** (`@objectstack/plugin-auth`). `organization_id` on `sys_permission_set` is a standing column. Moving the organization-less `admin_full_access` row into an organization takes away every grant-anchored platform administrator, as deleting it does, so the guard refuses it when it would leave none. An organization's copy of `admin_full_access` no longer counts as the set being in effect. + + **Nothing to migrate** in code or metadata. +- bfc15d2: feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4) + + Clause-②: yes + + + + **BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes. + + **FROM → TO.** A position's permission sets were `sys_position_permission_set` rows. They are now declared in `permissionSets` on the position's definition; junction rows no longer grant. + + ```ts + definePosition({ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }); + ``` + + The one-line fix for an app that bound sets to its positions with a boot-time binder: name the sets in `permissionSets` on each position. + + **`@objectstack/spec`.** `PositionSchema` accepts `permissionSets`: a list of permission-set names (lowercase snake_case). Before, the key was refused with a pointer to the junction. Each name is resolved in the environment catalog when a holder's grants are resolved. A name the catalog does not hold confers nothing. + + **`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`): + + - A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. + - A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing — including a row inserted directly (a script or a harness, outside the product's doors). + - **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. + - The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition. + - `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. + - Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. + - An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. + - `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot. + + **`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it). + + **`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A ledger write that switches `admin_full_access` off is refused only when it would leave no administrator who can sign in, and switching it back on is never refused. The guard no longer judges `sys_permission_set` writes, because the resolver no longer reads that row. + + **`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES. + + **`@objectstack/plugin-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row. + + **What stops granting at upgrade, and what to do.** + + - **A deactivation made in Setup.** The Deactivate action on a position or a permission set writes the row's `active` column, which no longer switches anything off. ⚠️ Until the upgrade ceremony converts the rows (`convertDeactivatedCatalogRows`), a position or set deactivated that way **grants again**. No enable/disable door accepts the `permission` or `position` type yet, so a new deactivation has no in-product path. To revoke now, remove the assignments or the binding. + + - **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code. + - **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today. + - **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate organization-ownership --apply`) under `single` only, where its `authored-conversion` step applies `convertPositionBindingRows`. Under a wall (`group`, `isolated`) the ceremony converts no binding (ADR-0131 D3): its `rewrite` step refuses each binding whose position's definition does not name the bound sets, and the ceremony stops there until the operator acts and re-runs it. A position no catalog declares is declared with those sets in `permissionSets` through one door only: the deployment's code, or the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator). A declared position the metadata door can edit takes the missing sets through that door. A definition a package ships is sealed (ADR-0131 D6), and no door on the database adds sets to it. A name that organizations bound differently is reported `conflicting` and is never merged. +- e7e6544: feat(plugin-auth)!: the last-administrator guard, the self-registration admission and the default-organization step read the security catalog and the activation ledger, never a `sys_permission_set` row (ADR-0131 D3/D4) + + Clause-②: yes (narrowing: a zero-administrator write is refused when an unscoped grant names a set the security catalog does not hold, and self-registration is refused when its declared set is not in the catalog or is switched off in the activation ledger; widening: writes to a `sys_permission_set` row are no longer judged by the guard, and a ledger write switching `admin_full_access` off is refused only when it would leave no administrator) + + + + **BREAKING**, shipped as `minor` under the launch-window convention for breaking changes. It finishes, for `plugin-auth`, the move the authorization resolver made in the same release: a permission set exists when the security catalog bound to the engine holds its definition, and it is switched off by a `sys_metadata_activation` row of type `permission` whose `active` is false. No `sys_permission_set` row decides either any more, its `active` column included. + + **The last-administrator guard.** + + - A grant-anchored platform administrator is counted the way the resolver derives one: an unscoped, in-window grant naming `admin_full_access`, which the catalog holds and the ledger leaves on. An engine with no catalog bound counts none, as the resolver grants none. + - Switching `admin_full_access` off in the ledger (an insert, an update of its row, or an update moving another row onto that pair) is judged like every other standing write. It is refused only when it would leave no administrator who can sign in. A config-anchored or organization administrator still standing permits it. The flat refusal of every such write is gone, and with it `refuseLedgerSwitchingAdminOff`. + - Writes to a `sys_permission_set` row (delete, rename, `active`, `organization_id`) are no longer judged. The resolver reads no row, so none of them moves an administrator. `PERMISSION_SET_STANDING_KEYS` is removed, and with it the guard's two `sys_permission_set` hooks. + - With zero administrators, the guard still tells the bootstrap window from an emptied environment. The evidence is now an unscoped, in-window grant naming a set the catalog does not hold (or naming none), or `admin_full_access` switched off in the ledger while such grants name it. Its remedy is to switch the set back on, and that write is permitted. + + Two exports of the guard are removed, with nothing in their place: `PERMISSION_SET_STANDING_KEYS` (the table it judged is no longer read) and `refuseLedgerSwitchingAdminOff` (`registerLastAdminGuard` judges ledger writes itself). No package in this repository imports either outside `plugin-auth`. + + **Self-registration.** The admission asks whether the declared `selfRegistrationPermissionSet` would grant: the catalog holds it and the ledger leaves it on. A `sys_permission_set` row of that name must also exist, because the grant's `permission_set_id` is written with its id. The row's `active` is no longer read, at admission or when the grant is written. + + **The default organization.** The platform administrator the default-organization step binds as owner is found through a set the catalog holds and the ledger leaves on, not through a `sys_permission_set` row. +- 8c5aa50: feat(plugin-email,plugin-auth)!: an organization can no longer create or edit an email template row; the template provenance stamp and the auth SMS template seed retire + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing and three retired exports), shipped as `minor` under the launch-window convention for breaking changes. It carries ADR-0131 D6 and the maintainer's ruling C on ADR-0131 §6 Q1: email templates are not overridden per organization. + + **What stops being accepted.** + + - **The `sys_email_template` organization door is closed.** An organization's create and update of a template row are refused with `403 PERMISSION_DENIED`, and the message names the closed door: every engine `insert` / `update` whose context names a caller and is not system-elevated. That covers `POST` / `PATCH /api/v1/data/sys_email_template`, the Studio record editor, batch and import routes, and scripts and flows that run as a user or a service principal. System-context writes still pass: the built-in seed, the declared-template boot sweep, the live projection of a Studio `email_template` save into its row, and the v18 migration ceremony's promotion. Delete is not part of this door. + - **The template provenance stamp is retired.** It marked a package- or platform-seeded row `customized: true` when a non-system caller updated it. With the door closed no such update reaches the engine's write, so nothing marks a row any more. Rows already marked keep their mark and are still never overwritten by the boot seeders; they are the population the v18 migration ceremony promotes to environment-level Studio templates. + - **Retired exports of `@objectstack/plugin-email`:** `bindEmailTemplateProvenanceStamp`, `unbindEmailTemplateProvenanceStamp` and `EMAIL_TEMPLATE_PROVENANCE_PACKAGE`. They have no replacement. `EmailServicePlugin` closes the door itself, and there is no stamp left to bind, so a direct call is deleted, not rewritten. + - **The auth SMS template seed is retired.** A boot with phone sign-in on no longer writes the built-in OTP and invitation texts into `sys_notification_template` as rows. It was internal to `@objectstack/plugin-auth` and exported nothing. + + **What renders unchanged.** + + - Every email template renders as before: the template loader still reads `sys_email_template`, and a Studio edit of an `email_template` still reaches the mail at once and survives a restart. + - Every auth SMS text renders byte for byte as the seeded store rendered it, for every built-in text and every recipient locale. Each locale rung renders an operator's active row when one exists, and the built-in text where no row exists at that locale. A deactivated or blank row passes its rung on, as it did before. + - A `sys_notification_template` row an operator already has still wins, and no existing row is touched. + + **What changes for you.** To change what an email template sends, edit the `email_template` in Studio. A script or integration that wrote `sys_email_template` rows through the data API now receives `403 PERMISSION_DENIED`. `@objectstack/platform-objects` corrects the `is_system` and `customized` field help on `sys_email_template`, which said an organization may edit a row. +- c6fe02d: `createIdentityObjectsPlugin()` registers plugin-auth's identity objects (`sys_user`, `sys_member`, `sys_organization` and the rest of the list `AuthPlugin` registers) on a kernel that does not mount `AuthPlugin`, such as an app's or a plugin's own test kit. + + Clause-②: yes (widening) + + - **What is new.** `createIdentityObjectsPlugin(options?)`, the `IdentityObjectsPlugin` class it returns, `IdentityObjectsPluginOptions` (`manifestDatasource`, with the meaning `AuthPluginOptions.manifestDatasource` has) and `IDENTITY_OBJECTS_PLUGIN_NAME` (`com.objectstack.auth.identity-objects`, its kernel plugin name). + - **One list.** It registers the identity half of plugin-auth's manifest: the header, the objects and the field plugin-auth adds to `sys_sso_provider`. `AuthPlugin` spreads the same builder into the one manifest it registers, so a reduced kernel and a full one register the same objects, and a kit no longer copies plugin-auth's object list or manifest id. + - **Same owner.** The objects register under plugin-auth's package id, `com.objectstack.plugin-auth`, as `AuthPlugin` registers them. The registry records one owning package per object, so a different id would make the owner of `sys_user` depend on which plugin a kernel mounts. + - **No authentication.** It registers objects only: no sessions, no routes, no `auth` service. + - **Not beside `AuthPlugin`.** `AuthPlugin` registers the same objects itself. A kernel that mounts both is refused at boot, by the identity plugin's `init()`, with an error naming both. + - **Usage.** `await kernel.use(createIdentityObjectsPlugin())` after `ObjectQLPlugin`. A suite that boots through `@objectstack/verify` already gets these objects: its `bootStack` mounts `AuthPlugin`. + - **Unchanged.** `AuthPlugin` registers the same manifest it registered before, under the same id. +- f66c440: fix(auth): the plugin-auth doors that re-dispatch to better-auth or call its endpoints in-process no longer renew a browser session behind its cookie (#22398) + + **What was wrong.** A better-auth session read renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that read's own response. Eight doors read the session in-process by a route other than `auth.api.getSession`, so the rule the `getSession` readers follow did not reach them, and each kept only the JSON, or the status and body, of the response the cookie was staged on. Measured on better-auth 1.7.3 with a session aged to `now + expiresIn − updateAge − 60 s`, each moved `expires_at` by +86460 s on a cookie request and set no session cookie, before its own answer (a refusal included): + + - through a `/get-session` re-dispatch and the bridge's forward to a better-auth route: `POST /api/v1/auth/admin/sso/register`, `POST /api/v1/auth/admin/sso/register-saml`, `POST /api/v1/auth/admin/sso/request-domain-verification`, `POST /api/v1/auth/admin/sso/verify-domain` and `POST /api/v1/auth/send-verification-email`; + - through an in-process vendor endpoint call carrying the request's headers: `POST /api/v1/auth/organization/add-member` (`addMember`), `POST /api/v1/auth/set-initial-password` (`setPassword`) and `POST /api/v1/auth/sys-oauth-application/register` (`createOAuthClient`). + + **The rule now** is the one every in-process `getSession` reader follows, decided by what the request carries: + + - **A session cookie** (a browser): the re-dispatched URL carries `disableRefresh=true`, and a vendor endpoint call takes `inProcessSessionReadInput(headers)` from `@objectstack/types`, whose `query` better-auth's session middleware passes into its read. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie, so cookie and session expire together. Measured after the change: each door leaves `expires_at` unchanged on a cookie request and sets no cookie. + - **No session cookie** (a bearer-only client): unchanged. Each door still renews the session to `now + expiresIn` and sets no cookie on a response to a request that sent none. + + **Upgrading.** Nothing to change. The shared helpers the cloud auth proxy mounts (`runRegisterSsoProviderFromForm`, `runRegisterSamlProviderFromForm`, `runRequestDomainVerification`, `runVerifyDomain`, `runResendVerificationEmail`, `runSetInitialPassword`) carry the same rule, so both mount points stay in step. `SetPasswordCapableApi.setPassword` now also accepts an optional `query: { disableRefresh: true }`; better-auth's own `auth.api.setPassword` honours it (measured on 1.7.3). +- e148ca9: With SSO domain verification on, `POST /api/v1/auth/admin/sso/request-domain-verification` and `POST /api/v1/auth/admin/sso/verify-domain` answer an unknown `providerId` with `404 RESOURCE_NOT_FOUND`, no longer with `400 DOMAIN_VERIFICATION_DISABLED` + + Clause-②: yes (widening) + + - **Before.** With `OS_SSO_DOMAIN_VERIFICATION` on, a platform admin who sent a `providerId` that does not exist got `400 DOMAIN_VERIFICATION_DISABLED`, "Domain verification is not enabled for this environment (set OS_SSO_DOMAIN_VERIFICATION)": an instruction to turn on a feature that was already on. `@better-auth/sso` answers both an unmounted route (feature off) and an unknown provider (feature on) with a `404` that carries no `code`, and both bridges mapped every such `404` to the feature-off answer. + - **Now.** The two mounts hand each bridge the environment's own setting (`AuthManager.isSsoDomainVerificationEnabled()`). Off: `400 DOMAIN_VERIFICATION_DISABLED`, as before, and the vendor is no longer called. On: a code-less vendor `404` is the provider lookup missing, answered `404 RESOURCE_NOT_FOUND` with a message naming the provider id. The decision never reads the vendor's `404` wording. + - **Unchanged.** The platform-admin gate answers first, on or off (an anonymous caller gets `401`). A missing `providerId` is still `400 INVALID_REQUEST`. With the feature on, an existing provider still gets the vendor's own answer: the DNS record, `NO_PENDING_VERIFICATION`, `DOMAIN_VERIFICATION_FAILED`, a `403` for a provider the caller may not manage. + - **The exported bridges.** `runRequestDomainVerification` and `runVerifyDomain` take an optional third argument, `{ domainVerificationEnabled?: boolean }`. A caller that omits it keeps the earlier mapping: a code-less `404` is answered `400 DOMAIN_VERIFICATION_DISABLED`, byte for byte. A host that mounts these helpers itself passes the environment's setting to get the not-found answer. +- f9ebb7d: fix(plugin-auth)!: the identity import asks each lookup target its declared exposure before matching a cell, like every other `runImport` caller (#22800) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The identity import (`runAdminImportUsers`) parses its payload with `prepareImportRequest` and runs its rows through `runImport`, with a run protocol of its own that writes through better-auth. Its `sys_user` field map, which decides which cells are references, is read through the import's declaration reader (`IdentityImportDeps.getMetaItem`). + + **FROM.** The run protocol was built without that reader, so `runImport`'s `servesReferenceTarget` answered "served" without asking: a lookup cell was matched against its TARGET object even when the target's declaration refuses `list` (`enable.apiEnabled: false`, or an `apiMethods` whitelist without `list`). A cell naming an existing record resolved and a cell naming none answered `reference_not_found`, so the row report told the two apart. + + **TO.** The run protocol carries the same reader the parser is handed (one binding, so whenever a cell is a reference its target is judged). A target the spec's decision (`canServeApiOperation`, for `list`) does not serve answers every cell, naming a record or not, a pasted id included, `reference_not_found`, and nothing of the target is read. A target with no `enable` block, or a whitelist that grants `list`, resolves exactly as before. The decision takes no caller, so the system identity the import's reads run under is answered like any other caller. No new error code, no status change. + + **Measured producers.** `sys_user` declares two lookups: `manager_id` into `sys_user` (the import strips that column before `runImport`; its own second pass links managers) and `primary_business_unit_id` into `sys_business_unit`. Both targets serve `list`, and the registry injects no columns into `sys_user` (`managedBy: 'better-auth'`), so no shipped import answer changes. Deployed object extensions of `sys_user` were NOT MEASURED. + + **Fix, if an import relied on the old answer:** the target's own declaration decides. Grant `list` in its `enable.apiMethods`, or drop `enable.apiEnabled: false`, if the object is meant to be matched by import. +- 3490bf5: The identity import's automatic invitation email links to the Console's set-password page, no longer to an `INVALID_TOKEN` error + + Clause-②: yes (widening) + + - **Before.** For every row an admin imported under the Automatic (or Invite) password policy with email delivery, `POST /api/v1/auth/admin/import-users` requested the password reset with no `redirectTo`. better-auth then emailed a link ending in an empty `callbackURL=`, and its `GET /reset-password/:token` refused that link with a redirect to `?error=INVALID_TOKEN`, which the Console shows on its sign-in page. The token in the link was still valid, but the invited user had no page to use it on. + - **Now.** The reset request carries `redirectTo` set to the Console's set-password page (`${origin}${uiBasePath}/reset-password`, by default `/_console/reset-password`), the page the Console's own forgot-password flow lands on. The emailed link opens `/_console/reset-password?token=…`, and better-auth's origin check accepts it because it is the deployment's own origin. + - **The two new members.** Both are additions; nothing existing changes shape. + - `AuthManager.getConsoleUrl(path)` is a public reader of the absolute URL of a Console page. It returns what the class already composes for every email and SMS link it sends. + - `IdentityImportDeps.consoleResetPasswordUrl?()` is optional. `AuthPlugin` wires it to `getConsoleUrl('/reset-password')`. A host that calls `runAdminImportUsers` itself should pass it to get a working invitation link. A caller that omits it sends the request exactly as before (`{ email }`), so the link keeps the empty `callbackURL`. + - **Unchanged.** The SMS invitation, the temporary-password policy, and the email copy (the invitation still uses the password-reset template). +- 917b1a0: fix(plugin-auth)!: IdentityObjectsPlugin keeps the single posture's Default Organization at boot + + Clause-②: no (narrowing) — it restores ADR-0131 D3's Default Organization boot invariant, which `default-organization-invariant.ts` states as "[ADR-0131 D3] The Default Organization as a BOOT INVARIANT under the single posture", in the kernel that mounts `createIdentityObjectsPlugin()` without `AuthPlugin`; no published schema or export moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A kernel that mounts `createIdentityObjectsPlugin()` without `AuthPlugin` now holds its Default Organization under the `single` tenancy posture, so its seeds and system writes on tenant-scoped objects are stamped with it instead of refused with "the install holds no organization at all". + + - **Refused now:** a composition that mounts `createIdentityObjectsPlugin()` and publishes its driver after `bootstrap()` (the identity objects' `start()` reads `sys_organization` under `single`, so the boot fails with `No driver available for object 'sys_organization'`); and a kit that inserts its own organization row after boot, which now holds two organizations under `single`, so every system write that names no organization is refused as ambiguous (ADR-0131 D9). + - **Unchanged:** the walled postures (`group`, `isolated`), where `start()` creates nothing; the refusal at `init()` of `createIdentityObjectsPlugin()` mounted beside `AuthPlugin`; every composition that publishes its driver before boot; and a declaration-only boot (`os migrate`), which suppresses `start()`. + + - **Why.** ADR-0131 D3 makes the Default Organization a boot invariant under `single`: it owns every row. Registering `sys_organization` is what turns the engine's and the seed loader's zero-organization branch from "unstamped" into "refused" (D9). `IdentityObjectsPlugin` registers that object, but only `AuthPlugin.start()` created the organization, so the reduced kernel the plugin exists for (ObjectQL, `createIdentityObjectsPlugin()`, `SecurityPlugin`) refused every seed row of an organization-owned object. + - **What changed in `@objectstack/plugin-auth`.** `IdentityObjectsPlugin` gains a `start()` that, under `single`, finds or creates the Default Organization with the same invariant `AuthPlugin.start()` runs. It is idempotent, and a failure fails the boot. The posture is the `tenancy` service's when one is registered, otherwise the requested one (`OS_TENANCY_POSTURE`). Under `group` and `isolated` it does nothing. Nothing new is exported, and the plugin is still refused beside `AuthPlugin`. + - **What changed in `@objectstack/runtime`.** `AppPlugin` orders `com.objectstack.auth.identity-objects` ahead of itself, as it already ordered `com.objectstack.auth`, so the organization exists before the inline seed loads, wherever a host lists the two plugins. + - **If your test kit mounts `createIdentityObjectsPlugin()`.** Its kernel now reads and writes `sys_organization` during `bootstrap()` under `single`. Publish the driver before the boot (a `driver.default` service, or a datasource plugin), not with `registerDriver()` after it. Otherwise the boot fails with `No driver available for object 'sys_organization'`. A kit that inserted its own organization row to stand in for the missing one now holds two, and a system write that names no organization is refused as ambiguous; drop that insert and read the Default Organization (`slug: 'default'`) the boot created. +- 404fc47: feat(plugin-auth): `registerOrganizationRecordPage: false` leaves out the platform's `sys_organization` default record page, so a host that ships its own 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`. `AuthPlugin` always registered `sys_organization_detail` (the Members / Invitations / Teams tab strip) as the default record page of `sys_organization`, so a deployment composing `AuthPlugin` beside a host that ships its own default page for that object was refused at boot. + + - **New option.** `AuthPluginOptions.registerOrganizationRecordPage` defaults to `true`. Only an explicit `false` leaves out `sys_organization_detail`, so the host's page is the only default whichever plugin starts first. Plugin order and precedence rules play no part. + - **Nothing else moves.** With `false`, `AuthPlugin` still registers the identity objects, the `sys_user_detail` record page, the System Overview dashboard, and the auth routes and services. It logs one `info` line at boot naming the option. + - **The default is unchanged.** A deployment that does not set the option registers `sys_organization_detail` as before. A host page beside it, with no option set, is still refused with `DEFAULT_RECORD_PAGE_CONFLICT`. + - **To adopt it:** construct `new AuthPlugin({ …, registerOrganizationRecordPage: false })` in the host that registers its own `isDefault: true` record page for `sys_organization`. With the option set and no default page of the host's own, the organization record view opens the synthesized default layout, without the Members / Invitations / Teams tabs. +- 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. +- 02a691a: fix(plugin-auth)!: the platform-admin actions refuse a session the ADR-0069 authentication policy holds, a platform administrator's included + + Clause-②: no (narrowing: a held session is refused on the platform-admin surface, which ADR-0069 D3 already declares gated) + + + + **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 and the runtime dispatcher already refuse such a session. The platform-admin actions plugin-auth mounts on the host server (user creation, password, ban, removal, unlock, manager, import, membership, OAuth-client and SSO administration) are judged by one shared judge, `judgePlatformAdmin`, which read the session and the admin rung but never the posture, so a held platform administrator could still administer the platform. + + - **Refused now:** a held session meets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message` on every platform-admin action that judge guards, in the family's declared envelope, before the action runs. The judge asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) without a request path, so these actions never inherit the exemption that keeps the auth endpoints reachable. + - **Order:** an anonymous caller still meets `401 UNAUTHENTICATED` first. A held signed-in caller who is not a platform administrator now meets the posture's `403` code instead of `403 PERMISSION_DENIED`: same status, the remedy named. + - **Unchanged:** a session the policy does not hold is served exactly as before. Sign-in, change-password, two-factor enrollment and sign-out still answer a held session, so remediation works as before. + + What changes for you. A platform administrator the policy holds must remediate (change the password, or enroll an authenticator) before the platform-admin actions answer them, 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. +- 9cb9d95: feat(spec,plugin-auth): a short-lived principal-session mint, `IAuthService.mintPrincipalSession`, implemented by `AuthManager` + + Clause-②: yes (widening) + + An in-process service can now act as the requesting user through a door that only accepts a session, such as a headless browser rendering a page the user may see, under the user's own principal and never a system one. Nothing is removed and no HTTP route is added. + + - **`@objectstack/spec`** (`contracts`): an optional `IAuthService` member, `mintPrincipalSession(context, options?)`, its types `PrincipalSession`, `PrincipalSessionCookie` and `PrincipalSessionMintOptions`, and the constant `PRINCIPAL_SESSION_MAX_TTL_SECONDS` (`120`). The member is optional, so every existing `IAuthService` implementation compiles unchanged. + - **`@objectstack/plugin-auth`**: `AuthManager.mintPrincipalSession(context, options?)`. + + What the mint guarantees: + + - **Human principals only, refused before anything is minted.** A context with no user, a system context, or a `principalKind` other than `human` gets `PERMISSION_DENIED` / `403`. So does a user row that is missing, the system user or banned. A user an authentication policy holds gets `403` with the policy's own code (`MFA_REQUIRED`, `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED`), whether the posture rides on the context or is computed at the call. A `ttlSeconds` outside 1 to 120 gets `VALIDATION_ERROR` / `400`: it is refused, never clamped. + - **Short and never renewed.** The session lives at most 120 seconds. Reading it never extends it, on the cookie lane, the bearer lane or the in-process session read alike. + - **Revocable.** `revoke()` ends the session. Afterwards neither the token nor the cookie resolves one. It is bound to that session alone and safe to call twice, so call it in a `finally`. + - **The deployment's own cookie.** The cookie carries the name and attributes of the plugin's session cookie, the cookie prefix and, on a secure deployment, `__Secure-` included. The session's active organization is the context's `tenantId`. + + ```ts + const auth = ctx.getService('auth'); + const session = await auth.mintPrincipalSession?.(context, { ttlSeconds: 60 }); + try { + // send session.token as `Authorization: Bearer`, or set session.cookie in a browser + } finally { + await session?.revoke(); + } + ``` +- e246f06: feat(plugin-auth,platform-objects,core)!: an API key an administrator's forced password reset revoked is restored by a platform administrator only; its owner creates a new key (#23993) + + **BREAKING** (an accept-set narrowing at the `sys_api_key` write door), shipped as `minor` under the launch-window convention for breaking changes. For an operator: a key revoked by `POST /api/v1/auth/admin/set-user-password` (the forced reset, which revokes the user's keys by default) can no longer be restored by its owner; a platform administrator restores it with the Restore API Key action, or the owner creates a new key. For an integrator: `PATCH /api/v1/data/sys_api_key/{id}` with `{ revoked: false }` on such a key now answers `403 PERMISSION_DENIED` for its owner, where it answered `200`; and `sys_api_key` gains a readonly `revoked_reason` column. + + Clause-②: yes (narrowing) + + What widens: `sys_api_key` declares one more field, `revoked_reason`. What narrows: the owner's restore of a key whose `revoked_reason` is `admin_password_reset` is refused, where it was admitted. + + + + #23287 ruling C′ makes the forced reset revoke the user's API keys by default, for a suspected compromise: a key that outlives the reset is the hole the reset was meant to close. Its changeset names who acts afterwards: a platform administrator restores such a key, or the owner mints a replacement. The owner could nevertheless restore it through the same door as any revoked key, because the row recorded no reason. Now it does. + + - **`sys_api_key.revoked_reason` (`@objectstack/platform-objects`).** A readonly `select` whose one value is `admin_password_reset`, set when the forced reset revoked the key and cleared when the key is restored. Empty for a key revoked from the API Keys list. Translated in `zh-CN`, `ja-JP` and `es-ES`. + - **The stamp (`@objectstack/plugin-auth`).** The forced reset's revocation writes `revoked_reason: admin_password_reset` beside `revoked: true`, the one place the reason is set. A key already revoked before the reset is not written, so it keeps no reason. + - **The restore guard (`@objectstack/plugin-auth`).** One `beforeUpdate` hook on `sys_api_key`, registered after the ADR-0092 D2 identity write guard. A user-context restore of a key carrying the reason is refused `403 PERMISSION_DENIED` unless the caller has platform-administrator standing (the ADR-0068 D2 rung); the refusal names both roads. Every restore it admits, the administrator's included, clears the reason. Revoking an already-revoked key leaves the reason in place, and the column is not on the D2 whitelist, so no data-API write can clear it. On a multi-row update every row is judged by its own stored state. System-context writes (the mint, the last-used stamp, the forced reset) are never refused. + - **The key door's message (`@objectstack/core`).** `API_KEY_REVOKED` for a key the forced reset revoked now says only a platform administrator can restore it; any other revoked key keeps today's sentence. Code, status and every carried field are unchanged. + + What changes for you: + + - **Refused now:** the owner restoring a key the forced reset revoked: `403 PERMISSION_DENIED`, naming the platform administrator and the new-key road. The administrator's restore works as before and clears the reason. + - **Unchanged:** revoking and restoring a key from the API Keys list, by its owner or an administrator; a forced reset with `revokeSessionsAndKeys: false`, which revokes nothing. + - **Keys revoked by a forced reset before this release** carry no reason, so their owner can still restore them. Such keys exist only where `18.0.0-next.2` or a `main` build since 2026-10-14 ran a forced reset; nothing backfills them, because the reset's audit row records counts, not key ids. +- ab3ba2c: Clause-②: no (narrowing) + + fix(plugin-auth)!: sixteen of this package's own routes refuse a declared JSON body that does not parse with `400 INVALID_REQUEST`, instead of answering as if no body was sent + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + These routes read a body that does not parse as no body, through `try { body = await request.json() } catch { body = {} }` or a module-private `parseJson` that answered `{}` from its `catch`, so a truncated body was answered as if the client had sent none: + + - `POST /api/v1/auth/admin/oauth2/toggle-disabled`, `/admin/unlock-user` and `/sys-oauth-application/register`; + - `POST /api/v1/auth/admin/import-users`; + - `POST /api/v1/auth/admin/sso/register`, `/admin/sso/register-saml`, `/admin/sso/request-domain-verification` and `/admin/sso/verify-domain`; + - `POST /api/v1/auth/send-verification-email`; + - `POST /api/v1/auth/admin/ban-user` and `/admin/unban-user`; + - `POST /api/v1/auth/admin/set-user-manager`; + - `POST /api/v1/auth/admin/create-user` and `/admin/set-user-password`; + - `POST /api/v1/auth/organization/add-member`; + - `POST /api/v1/auth/set-initial-password`. + + `/admin/unlock-user` answered `400 "userId is required"` for a body that carried `userId` and lacked its closing brace. `/send-verification-email` read the same input as the one-click resend, and sent the link to the caller's own address for a body that named another one. + + What changes for an integrator. A body sent as `application/json` (matched case-insensitively) that is not blank and does not parse: FROM the route's answer for an empty body, TO `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "The request body could not be parsed as JSON (it was sent as application/json). It was refused rather than read as an empty body: send a well-formed JSON document, or no body at all." } }`, the answer the HTTP transports give the same body. A declared JSON body whose stream cannot be read is refused with the same code and "The request body could not be read. It was refused rather than read as an empty body." Each route refuses where it reads its body. The import, the four SSO bridges, ban/unban, set-user-manager, create-user, set-user-password and add-member read it after their platform-admin gate (and after the admin-plugin or organization-plugin check where the route has one). toggle-disabled, unlock-user, sys-oauth-application/register, the resend and set-initial-password read it before anything else runs. Nothing is looked up, written, sent or forwarded to better-auth. + + The exported bridges eleven of these routes mount (`runAdminImportUsers`, `runRegisterSsoProviderFromForm`, `runRegisterSamlProviderFromForm`, `runRequestDomainVerification`, `runVerifyDomain`, `runResendVerificationEmail`, `runSetInitialPassword`, `runAdminCreateUser`, `runAdminSetUserPassword`, `runAdminBanUser` and `runAdminUnbanUser`) answer the same refusal, so a host that mounts them itself refuses it too. Their signatures and result types are unchanged. + + **Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body. + + **Unchanged.** An empty body (no body at all, a zero-byte one, or whitespace only) is still no body, and every route answers it as before: `/admin/unlock-user` with `400 "userId is required"`, and `/send-verification-email` as the one-click resend to the caller's own address. A body that parses is read as before, whatever its `Content-Type`, and a body that does not declare JSON and does not parse is still read as no body. +- 8a88901: fix(plugin-auth,plugin-email): the organization invitation mail names the invitee's role by its label in the mail's language (`成员`, `メンバー`, `Miembro`, `Member`), not by the stored value `member` + + Clause-②: yes (widening) + + - **What changed.** The zh-CN, ja-JP and es-ES invitation mails printed the stored membership role inside a translated sentence: 「邀请您以 member 的身份加入」. The sender now passes `{{role}}` as the `sys_member.role` option label in the locale the mail is written in, the same label the console shows for that option. It reads the label through the runtime's i18n service and the console's own object translation path, so a tenant's translation of the option, made in Studio, reaches the mail too. The four bodies keep interpolating the one `role` variable; its name and type are unchanged, and its declaration now says it carries the label. + - **The plain-text part names the role too.** The four text bodies gain the role clause their HTML bodies carry, so both parts of the mail name the same facts. For example, the English text is now `Dana (dana@example.com) invited you to join Northwind on Acme as Member.` + - **Stated fallbacks.** A role list (`admin,member`) is labelled role by role and joined as a list in the mail's language (`管理员和成员`). A value with no option label is named as stored, and one warning names it. With no i18n service, or one that fails, the mail names the option's authored English label, never an empty string. + - **New option (widening).** `AuthManagerOptions.getI18n?: () => II18nService | undefined` is a lazy accessor for the `i18n` service, read when the invitation is sent. `AuthPlugin` wires it from the kernel. An embedder that builds `AuthManager` itself and omits it gets the authored labels. + - **What changes for you.** If you have overridden `auth.invitation` and interpolate `{{role}}`, it now receives the label, not the stored value. +- 99e9d29: feat(plugin-auth): an OAuth consent binds the consenting user's active organization, the access token carries it as `organization_id`, and `verifyMcpAccessToken` returns it as `organizationId` + + Clause-②: yes (widening) + + - **What changed.** When a user consents to an OAuth client, the embedded authorization server records the consenting session's active organization as the consent's `reference_id`. The value rides the authorization code onto the refresh family it starts (`sys_oauth_refresh_token.reference_id`), and every access token minted from either carries it as the `organization_id` claim. It is written through the installed `@better-auth/oauth-provider`'s own seams (`postLogin.consentReferenceId` and `customAccessTokenClaims`); no new configuration key or environment variable. + - **The session's organization, never the request's.** The value is read from the consenting session only. Nothing in the authorize request, the consent body or the client's registration can choose it. + - **No organization, no claim.** A user with no active organization at consent gets a consent with no reference, and tokens with no `organization_id`. There is no guessed or default organization; such a token's principal resolves with global grants only. + - **A refresh keeps the consent's organization.** A refresh token mints access tokens in the organization its consent bound, even after the user switches the browser session to another organization. Authorizing again while another organization is active asks for a second consent, for that organization. + - **Existing consents re-confirm once.** A consent stored before this change has no reference, so the next authorization made while the user has an active organization shows the consent page again, once. Until the user re-confirms, a refresh family started before this change keeps minting tokens with no `organization_id`, so they carry global grants only. + - **`AuthManager.verifyMcpAccessToken`.** Its result type gains `organizationId?: string`, matching `IAuthService.verifyMcpAccessToken` in `@objectstack/spec/contracts`, and it returns the token's `organization_id` claim there. A token whose `organization_id` is present but is not a non-empty string is refused, as every other malformed token is: this server never mints one. The organization is a record, not an authorization. The caller vets it on every request by the rule that vets a session's active organization (`vetOrganizationClaim`, `@objectstack/core`). + - **`@objectstack/platform-objects`.** The `reference_id` field description on `sys_oauth_consent`, `sys_oauth_refresh_token` and `sys_oauth_access_token` now names the bound organization, instead of "Caller-supplied correlation identifier". Prose only; no field, type or schema change. +- 51ad8e4: Clause-②: yes (widening) + + fix(cli,runtime): the 17 → 18 organization-ownership upgrade of a single-posture database that holds no organization leaves no row without one, the boot the ceremony prescribes writes nothing before it is refused, and a ceremony that did not verify is never served (ADR-0131 D3, D10) + + A 17.x single-posture install whose seed ran before any organization existed holds rows of organization-owned objects with `organization_id` NULL and an empty `sys_organization`. Upgrading it took this path: the ceremony's attribution checkpointed itself done at zero rows; the boot its rewrite step asks for created the Default Organization and let the inline seed stamp some rows before it refused; the second `--apply` did not run attribution again, dropped the columns and recorded discrepancies with `verified_at` NULL; and `os start` then served that database. + + - **The ceremony creates the Default Organization (`@objectstack/cli`).** Under the `single` posture, on a database that holds no organization and rows no anchor names, `os migrate organization-ownership` plans those rows as the Default Organization's and says so in a plan warning (`defaultOrganizationCreatedByApply: true` in the plan file). `--apply` creates it before its first write, through plugin-auth's boot invariant (the row a single-posture boot creates), and gives it every such row. A run that cannot create it — a boot without the auth family that declares `sys_organization` — blocks at attribution, naming why, instead of attributing nothing and certifying. Nothing is created under a walled posture, or when the database holds organizations. + - **The ceremony names the Default Organization as the boot does (`@objectstack/cli`).** Under `single` it is the organization with `slug = 'default'`, else the sole organization whatever its slug — the boot invariant's and the tenancy service's definition (ADR-0093 D3). Before, the plan required `slug = 'default'`, so a database whose only organization had another slug kept its unanchored rows NULL through the ceremony while v18 derives every owner from that organization. Several organizations, none of them `default`, still name none, and their rows are reported. In the plan file (the command's own output, not a published schema) `defaultOrganization.slug` is now that organization's own slug, no longer always `'default'`. + - **A checkpointed attribution runs again (`@objectstack/cli`)** whenever the plan derives an owner for rows still NULL: a row the operator made attributable since, or one an earlier run found no Default Organization for. It writes only where `organization_id` is NULL, so no owner already written is derived again. A database left by an earlier release's ceremony in that state is completed by re-running `--apply`. + - **The boot guard judges before any plugin writes (`@objectstack/runtime`, `@objectstack/plugin-auth`).** `OrganizationOwnershipBootGuardPlugin` judges in its own `start()`, after the engine's schema sync (the column the rewrite step waits for is still added) and before every plugin that writes rows at start: `AppPlugin` (the inline seed), `AuthPlugin` and the identity-objects plugin (the Default Organization) name it (`com.objectstack.organization-ownership-boot-guard`) in `optionalDependencies`. A refused boot leaves every row as it found it. + - **A ceremony that did not verify is refused (`@objectstack/runtime`).** With no table left in the v17 shape, a boot whose `adr-0131-organization-ownership` marker exists and is not verified — the post-check found rows the ceremony has not carried — is refused with `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`, naming the re-run. A database with no marker (a fresh one) boots as before. + - **The refusal now arrives as a `cause` (`@objectstack/runtime`).** A failed `start()` is reported by the kernel as its own error ("Plugin … failed to start"), with the plugin's error as its `cause`. A host that composes the guard and inspects the boot error asks `isOrganizationOwnershipBootRefusal` of each `cause` down the chain: asked of the kernel's own error it now answers `false`. `assertOrganizationOwnershipMigrated(engine)` still throws the refusal itself. `os serve` and `os dev` read it from the chain. + - **One new export (`@objectstack/plugin-auth`, `minor`).** `ensureDefaultOrganizationExists(ql, options?)` — the Default Organization's boot invariant, find-or-create under `single` — for the ceremony, so the organization it creates is the row a single-posture boot creates, from one producer. A host composing a kernel still never calls it: `AuthPlugin` and the identity-objects plugin keep the invariant on every boot. No other package entry changes. + + Unchanged: the ceremony's steps, fates and refusals otherwise; a walled posture's unattributable rows (reported, never assigned); the boot of a fresh or verified database; and every plugin's order on a kernel that does not compose the guard. Repairing a database whose organization-scoped seed load already inserted twins of NULL-organization rows is an operator's data repair. + +### Patch Changes + +- 19bb55e: The permission-set grant readers in `@objectstack/plugin-auth` read which set a grant holds from its name column, `sys_user_permission_set.permission_set` (ADR-0131 D4) + + Clause-②: no + + - **What reads the name now.** The last-administrator guard (`registerLastAdminGuard`) counts a grant-anchored platform administrator from an unscoped, in-window grant whose `permission_set` is `admin_full_access`, provided the `admin_full_access` row of that name is active. The default-organization bootstrap (`ensureDefaultOrganization`) finds the platform administrator by the same grant name. The self-registration grant checks by name whether the new user already holds the declared set. None of them reads `permission_set_id` for this any more. + - **The guard treats `permission_set` as a standing column.** A write that clears the name on the last administrator's grant is refused like any other revocation. A write that re-points `permission_set_id` without a name is treated as taking the standing away, because the platform derives the new name only after the guard runs. A write that only repeats the grant's own id keeps the standing. + - **A grant whose name is empty.** A grant written before the name column existed has no name until `@objectstack/plugin-security`'s one-time backfill names it at `kernel:bootstrapped`. The guard does not count such a grant as an administrator. When no administrator is counted, such a grant, if unscoped and in-window, is evidence that the environment is not a fresh install, so the write is refused instead of the bootstrap window opening. The default-organization bootstrap answers `no_admin` for it and binds no owner; that answer records no decision, so a later trigger binds once the grant has its name. + - **Nothing to migrate.** +- 0253192: Grants name their permission set, with no `sys_permission_set` row read (ADR-0131 D4) + + - `@objectstack/plugin-auth`: the self-registration grant names the declared set alone. Admission asks the security catalog and the activation ledger whether the set grants, and the settlement writes `{ user_id, permission_set }` with no row read, so a deployment with no `sys_permission_set` row for the set still grants it. The last-administrator guard reads which set a grant holds from its name only; writing `permission_set_id` moves no standing. + - `@objectstack/verify`: the RLS probe persona's grant names the registered probe set alone; it no longer needs, or refuses for want of, a boot-seeded `sys_permission_set` row. + - `@objectstack/platform-objects`: Setup's user page grants a permission set by name: the Add picker stores the set's machine name (`valueField: 'name'`, `linkField: 'permission_set'`), and the list shows the name column. The picker still lists `sys_permission_set` rows; listing the security catalog itself is a console change. +- 4935c66: The startup banner prints one line per warning class, each warning appears once, and a localhost boot no longer warns that OAuth is unencrypted. + + Clause-②: no + + - **One line per class.** Flows that declare a trigger but are not bound are grouped by trigger type and reason, with the flows listed: `⚠ 8 flows declare a 'schedule' trigger but are NOT bound — disabled by deployment policy — … (OS_AUTOMATION_SCHEDULED_WORK_ENABLED is unset or not truthy), so no time trigger arms …: flow_a, flow_b, …`. Before, the full reason (up to ~650 characters) printed once per flow. The banner now shows the reason's first sentence; `--log-level debug` still streams each flow's full reason. A real binding failure, or a missing trigger, keeps its own line, worded as before. + - **Printed once.** *Boot diagnostics* no longer repeats the automation plugin's per-flow `… is NOT bound` and shadowed-flow warnings, which the banner's `Flows:` list already shows. Its header counts them instead: `(8 more already listed above)`. Every other boot warning replays exactly as before. A boot that fails before the banner still replays all of them. + - **OAuth on loopback.** `OAuth is served UNENCRYPTED: …` is logged at `info` when the issuer's host is loopback (`localhost`, `*.localhost`, `127.0.0.0/8`, `::1`), so it is not shown at the default `warn` level. It stays `warn` on a private or link-local issuer. The sentence and the transport rule are unchanged. + - ⛔ Nothing you author changes. Which flows bind, the scheduled-work switch, the transport rule, the service-automation warning an embedded host reads, and every public key, export and parameter are unchanged. +- 6ff6ed6: A first boot no longer logs a refused `sys_migration` insert, and the owner-bind ledger row records the outcome that happened + + Clause-②: no + + On every first boot (`os serve` and `os dev`, on `:memory:`, file SQLite and PostgreSQL), *Boot diagnostics* showed `WARN Insert operation failed {"object":"sys_migration", … UNIQUE constraint failed: sys_migration.id}` with a full stack. The one-time default-organization owner bind (`createEnsureDefaultOrganizationOnce`, ADR-0093 D7) re-entered itself: the owner write makes the security plugin grant `organization_admin`, that grant insert is a bootstrap trigger, and the inner call recorded the decision first. It recorded `admin-already-member` with no organization. The outer call's accurate record (`promoted` or `bound`, with the organization id) was then refused. Nothing was bound twice. The ledger row named the wrong outcome. + + The gate now marks the decision in flight before its first `await`. A call that finds it in flight still creates a missing Default Organization, but binds nobody and records nothing. The deciding call records its own outcome once. A call that did not act (no platform admin yet, or a refused write) clears the mark, so the next trigger still decides. + + - **Nothing you author changes.** No key, export or option is added or removed. The walled `@objectstack/organizations` wiring calls the same gate and gets the same rule. + - **A real ledger write failure is still reported.** Only the gate's own second write is gone; a refused `sys_migration` insert for any other reason still logs at `error`, with the remedy. + - **Rows already written are not rewritten.** A deployment whose first boot recorded `admin-already-member` keeps that row; nothing reads its `details`. +- 0ee2d15: A seeded boot no longer reads the `auth` settings before the settings engine binds + + Clause-②: no + + Under `os serve` and `os dev`, an app with inline seed data emits `app:seeded` while plugins are still starting. The auth plugin's one-time membership backfill (ADR-0093 D6) ran on that event. Through it, the plugin bound the `auth` settings namespace before `SettingsServicePlugin` had bound its data engine. The boot logged `[SettingsService] Pre-bind READ of namespace 'auth'`, and the binding was computed from the manifest defaults, not from the saved `auth` settings. Measured on the showcase: one such line on every boot. + + The backfill is now armed by its own `kernel:ready` hook, which runs after the settings plugin binds its engine. A trigger of the pass before that hook does nothing: the `kernel:ready` pass runs afterwards and scans the same rows. A seed that settles after `kernel:ready` still re-runs the pass, as before. + + - **Which settings are read is unchanged.** Only the moment of the first read moves. + - **A saved `auth.membership_policy` governs the one-time pass.** Before, the pass's first run could read the manifest default `auto` while a saved `invite-only` sat unread. +- 4f4c4ed: fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`). + + **The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`: + + - **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. + - **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none. + + **Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`. +- 8a995b8: The auth plugin registers its `app:seeded` membership-backfill handler when its backfill is armed, not when the plugin starts + + Clause-②: no + + The one-time membership backfill (ADR-0093 D6) re-runs on `app:seeded`, so users written by a seed that settles after `kernel:ready` still get a membership. Its handler used to be registered in `start()` and kept from acting early by a runtime flag: until the backfill's own `kernel:ready` hook armed it, the handler returned without doing anything. The handler is now registered by that `kernel:ready` hook, at the moment it arms the backfill, so it does not exist before the settings engine binds. The boot-ordering gate reads this from the source, which it cannot do with a flag. + + - **What the backfill does is unchanged.** An `app:seeded` that fires before the backfill is armed still does nothing, and one that fires after it still re-runs the pass. + - **No option, setting or export changes.** `OS_SKIP_MEMBERSHIP_BACKFILL=1` still registers no `app:seeded` handler at all. +- 6f4c518: fix(plugin-auth): a fresh `os dev` database opens on the console shell, not on the "Set the workspace timezone" dialog — the dev seed gives the workspace it lands its admin in the host's time zone + + Clause-②: no + + **FROM.** The dev admin seed (`NODE_ENV=development`, on unless `OS_SEED_ADMIN=0`) created its admin and left the admin's workspace with `localization.timezone` unset. The console asks an administrator for a zone while that key still resolves from the manifest default, so every fresh `os dev` database (each `--fresh` run, each new database file) opened on the dialog. The console's own workspace-creation paths already send the creator's browser zone; the seed did not go through them. + + **TO.** On the boot that seeds the admin, the seed writes the host's resolved zone (`Intl.DateTimeFormat().resolvedOptions().timeZone`, so `TZ` chooses it) to `localization.timezone` for each organization the admin is a member of. Under the default `single` posture that is the Default Organization. The write goes through the settings service, the same door the dialog's confirm uses. It is one `tenant` row per organization, with the key's IANA zone check applied there. + + **Unchanged.** + - A zone already set on any rung (the organization's, the global one, an `OS_LOCALIZATION_TIMEZONE` override) is never overwritten. + - A host that reports no zone, or reports one the settings door refuses, gets nothing written. The workspace keeps the default and the dialog still asks. + - Boots after the seeding one write nothing, so a database that already exists is not touched. + - Production is not affected: the seed stays gated to `NODE_ENV=development`. + - `sys_organization.timezone` is a separate key, which the dialog does not read. It is not written. + + **Note for in-process test boots.** `@objectstack/verify`'s harness forces `NODE_ENV=development` to get the seeded admin, so its boots now give that admin's workspace the host's zone too. On a host in `UTC` the resolved value is the same as before. On a host in another zone, a suite that relies on the default `UTC` can set `TZ=UTC`, or set the zone through `PUT /api/settings/localization` as it would for any workspace. +- 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. +- 0cfd0c4: Clause-②: no + + A session minted by `IAuthService.mintPrincipalSession` is no longer refreshed by a read. Before, + `get-session` (and every endpoint that reads the session) still renewed it on each request: #23772's + guard held its expiry, but the row's `updatedAt` was rewritten, the session cookie was re-set with a + 7-day `Max-Age`, and `bearer()` answered `set-auth-token`. The console reads that header as a token + rotation, so a browser carrying a minted cookie stayed on "Opening your console…" and the print + service could not render under the requesting user. A request that carries a minted session now + sets better-auth's own per-request skip-refresh switch, on the cookie lane, the bearer lane and + in-process. The mint's contract, TTL, refusals and revoke are unchanged. +- 03ac4ec: fix(plugin-auth): the invitation role cap's refusals on `POST /organization/invite-member` carry an error code, and a membership read that fails answers `503` instead of a refusal + + Clause-②: no + + The role cap in `beforeCreateInvitation` refused with `403` and a message but no `code`, while better-auth's own `owner` refusal on the same endpoint carries one. A client, the console's Invite User dialog among them, could not tell the refusals apart without reading English prose. Each refusal now carries the code the error-code ledger registers for it under `@objectstack/plugin-auth`, in the same `{ message, code }` body: + + - `INVITE_ROLE_ABOVE_ISSUER`: the requested role grades above the issuer's own, for example a `delegated_admin` inviting as `admin`. + - `INVITE_ISSUER_MEMBER_ONLY`: an issuer below admin grade asked for a role other than a plain `member`, for example a `delegated_admin` inviting as `delegated_admin`. + - `INVITE_ISSUER_UNVERIFIED`: fail-closed. The issuer's membership role was not established: the read found no usable `sys_member` row, or the `AuthManager` has no data engine to read from. + + A membership read that throws is a store outage, not a verdict about the issuer. It used to answer the same `403` as an unverified issuer. It now answers `503` with the code `SERVICE_UNAVAILABLE`, as the organization slug guard does for its own read faults. No invitation is created either way. + + Unchanged: which invitations are admitted, the status of every refusal (`403`), better-auth's own `owner` refusal, and the exports. +- d731b89: fix(identity): the Invite User dialog offers only the roles the caller may grant, read from the same table the invitation role cap enforces + + Clause-②: yes (widening) + + **What was wrong.** A `delegated_admin` who opened Invite User was offered Owner, Admin and Delegated Admin. The server's invitation role cap refuses all three for that issuer. The `role` param inherited one static option list from the field, and nothing told the client which roles this caller may grant. + + **What is new in `@objectstack/spec` (`identity`).** One table, `INVITATION_ROLE_GRANTORS`, says which issuer grades may invite as each membership role: + + | role | issuers that may grant it | + |---|---| + | `owner` | `owner` | + | `admin`, `delegated_admin` | `owner`, `admin` | + | `member` | any issuer (`INVITATION_ROLE_ANY_ISSUER`) | + + New exports beside it: `invitationRoleGrantors(role)` (a role the closed vocabulary does not list takes the `owner`, `admin` row), `invitationRoleGrantPredicate(role)` (the CEL gate, one `'' in current_user.positions` term per grade, `undefined` for an any-issuer row), `INVITATION_ROLE_OPTIONS` (the role picker's options, each carrying its gate as `visibleWhen`), and the types `InvitationRoleGrantors` and `InvitationRoleOption`. + + **`@objectstack/plugin-auth`.** The invitation role cap now reads that table to decide which invitations it admits. It refuses exactly what it refused before, with the same refusal text. A pin compares it with the previous implementation on 1,517 request and issuer pairs. + + **`@objectstack/platform-objects`.** The `role` param of the three `invite_user` actions (`sys_user`, `sys_member`, `sys_invitation`) declares `INVITATION_ROLE_OPTIONS` itself instead of inheriting the field's list. The option values and English labels are unchanged. A `delegated_admin` is now offered Member only, an admin Admin, Delegated Admin and Member, and an owner all four. The per-option gate is a UI courtesy; the server cap remains the authority. Because the options are declared on the param, their labels now translate under `_actions.invite_user.params.role.options.`, and the bundles carry those keys in every locale. +- f792290: Clause-②: no + + perf(plugin-auth, plugin-hono-server): an authenticated request reads 8 rows in 4 round trips before its handler, down from 11 in 7, and `/auth/me/*` resolves the caller's grants once + + Measured on a real engine (SQLite through `@objectstack/driver-sql`), for a signed-in user whose session has an active organization: + + | before the handler | before | after | + |:--|:--|:--| + | REST and dispatcher doors (`resolveAuthzContext` with the in-process session read) | 11 reads, 7 round trips | 8 reads, 4 round trips | + | `GET /auth/me/permissions`, `/auth/me/localization`, `/me/apps` | 17 reads, 9 round trips | 8 reads, 4 round trips | + | `GET /api/v1/auth/get-session` | 11 reads, 7 round trips | 10 reads, 6 round trips when it reads the signing key; 8 in 4 while the key is held | + + - **An in-process session read no longer signs a JWT.** better-auth runs `/get-session`'s after-hooks for a server-side `auth.api.getSession(...)` call too, so every door that asked "who is calling" had the jwt plugin read the signing key (two `sys_jwks` round trips) and sign a `set-auth-jwt` header the caller then discarded. The hook now runs only when the call answers an HTTP request. `GET /api/v1/auth/get-session` still carries `set-auth-jwt`, signed exactly as before. A server-side caller that wants the header dispatches the request through `AuthManager.handleRequest`. + - **The session token is not read back after the session was found by it.** `sys_session.token` is withheld from ordinary reads and recovered for better-auth through a privileged read. better-auth finds the session by its token, so that read had nothing to add. It was a second round trip between the session row and the user row on every session read. A read keyed on any other column still recovers the token. + - **`/auth/me/*` and `/me/apps` resolve the grants once.** These routes resolved the caller's grants twice per request: once inside the session read (for the payload's `positions[]`) and once more for their own context. They now resolve through `resolveAuthzContext`, whose request-scoped memo serves the second resolution from the first while no write has landed in between. The memo ends with the request. The routes still admit a session only, and the session's active organization stands as before. + - **An unreadable authorization store is an outage on `/auth/me/*` and `/me/apps`, not a signed-out caller.** Because these routes now authorize through `resolveAuthzContext`, they follow the rule every transport through it follows: a permission-store read that fails answers `503` with the code `SERVICE_UNAVAILABLE` (on `HonoHttpServer`'s error seam) instead of `{ authenticated: false }` or `{ apps: [] }`, which a client cannot tell from an anonymous caller. A store whose tables were never created is not an outage: those routes answer the signed-in caller with no grants, as before. + - **The signing key is held across requests, under two bounds.** The keyring the jwt plugin signs with is kept after a read. Any write to `sys_jwks` that passes this process's engine drops it at once, and it is never kept longer than 30 seconds, which bounds a write this process cannot see (another node minting a key). An engine without a middleware seam keeps nothing. On a host without Ed25519 the keyring is still filtered to keys the host can use. + + Unchanged: the session and user every door resolves, every grant, the `/get-session` body and its renewal, every option and environment variable, and the exports. No new configuration key. The one answer that changes is the outage answer above. +- 9040945: fix(plugin-auth): the MCP access-token verifier follows the signing keyring instead of holding its own copy of the key set for five minutes + + Clause-②: no + + - **What changed.** `AuthManager.verifyMcpAccessToken` no longer keeps its own copy of the signing key set for five minutes. It reads the key set on every call through the same keyring reader the token signer and `/jwks` use, so the set it checks a signature against is held only under both halves of the cross-request caching rule: a `sys_jwks` write that passes this process's data engine retires it in the same operation, and a 30-second bound covers a write the process cannot see, such as one made on another node. + - **A token signed by a key the set does not carry gets one fresh read.** Before such a token is refused, the keyring is read once more from the table, and the token is checked again. That read is shared by every token that arrives while it is in flight, and a new one starts no sooner than 30 seconds after the previous one, so tokens that name keys which do not exist cost at most one keyring read per 30 seconds. A token whose key is still absent after that read is refused, as before. + - **Unchanged.** Issuer, audience, expiry, the `client_credentials` refusal and the `organization_id` claim are checked exactly as before. Only the public key set `/jwks` publishes is used for verification. No export, configuration key or environment variable is added. +- af284b0: feat(spec): the remaining `getSession` readers read `AuthSessionApi`'s declared result, and none reads a top-level `activeOrganizationId` + + Clause-②: yes (widening) + + - **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains three optional `user` members**, each declared for a named reader: `isPlatformAdmin` (`boolean`, the ADR-0068 D2 rung plugin-auth's `customSession` derives) and `role` (`string | null`, better-auth's `admin` plugin scalar), both read by `judgePlatformAdmin` in `@objectstack/plugin-auth`; and `name` (`string`), read by the runtime dispatcher's AI-route `req.user` resolver as the display name. Every member is optional, so every implementer and caller typechecks unchanged. + - **The readers read through the declared type instead of `any` or a cast** (types only, runtime behaviour unchanged): `judgePlatformAdmin` and the four platform-admin mounts in `@objectstack/plugin-auth` (better-auth's own session type now has to fit the declaration at those reads), the REST server's two session reads (`@objectstack/rest`), the dispatcher's posture read, `resolveSessionData` and both `HttpDispatcher` reads (`@objectstack/runtime`), the cloud-connection and install-local session reads (`@objectstack/cloud-connection`), the webhook redeliver door (`@objectstack/plugin-webhooks`), and the storage session bridge (`@objectstack/service-storage`). + - **Fix — the top-level `activeOrganizationId` fallback is removed** from the storage upload stamp (`@objectstack/service-storage`) and from install-local's `resolveActiveOrgId` (`@objectstack/cloud-connection`). The active organization is read from `session.activeOrganizationId` alone, where the contract declares it. No in-process producer answers a top-level `activeOrganizationId` (better-auth's read is `{ user, session }`), so for the shipped auth plugin nothing changes. An auth service that answered only the flattened shape now gets no organization: the upload is not stamped, and install-local reports no active organization. To fix it, answer the active organization on `session.activeOrganizationId`, as `AuthSessionApi` declares it. +- 83dfbc0: fix(plugin-auth): the organization, invitation-placement and phone-OTP hook refusals carry their ADR-0112 codes + + Clause-②: no + + Eight refusals thrown from plugin-auth's better-auth hooks answered with `message` alone, so a client could only show or match English prose. Each now carries a `code` it can branch on, and keeps its HTTP status. + + - `POST /auth/organization/create`: the reserved `__global__` id or slug answers 400 `VALIDATION_ERROR`. Where the deployment's effective tenancy posture enforces no organization wall (`/auth/config` publishes `features.multiOrgEnabled: false`), creating an organization answers 403 `ORGANIZATION_CREATION_DISABLED`. + - `POST /auth/organization/invite-member` with placement intent (`businessUnitId` and `positions`): with no delegated-administration runtime to authorize it, 403 `INVITE_PLACEMENT_UNSUPPORTED`; refused by the delegated-administration gate, 403 `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`. + - `POST /auth/organization/update` with a new slug: while the organization has an active environment (any environment that is neither archived nor failed), 403 `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS`. Its message no longer says environments "still reference" the organization or tells you to rename their hostnames: the guard reads no hostname, and archiving those environments is the remedy. When the guard's own read cannot answer, 503 `SERVICE_UNAVAILABLE`. + - Phone OTP sends (`/auth/phone-number/send-otp`, `/auth/phone-number/request-password-reset`): the per-number limit and the deployment's daily SMS quota both answer 429 `RATE_LIMIT_EXCEEDED`. It is one code for both walls, so a caller still cannot tell which budget it hit. The body is better-auth's flat `code` and `message`, with no `Retry-After` header. + - **One behaviour change:** a placement check that fails before the gate reaches a verdict (the issuer's grants could not be read from the store) now answers 503 `SERVICE_UNAVAILABLE`, not a 403 scope refusal carrying the store's message. No invitation is created either way. +- 9524666: fix(plugin-auth): the self-registration grant settles on the registrant's first membership, owned by that organization (ADR-0131 D3) + + Clause-②: no + + The declared self-registration permission set (`auth.audience.selfRegistrationPermissionSet`) is an assignment, and an assignment is an organization row (ADR-0131 D3). It is now written with the registrant's first membership and owned by that membership's organization, under every posture. It is never written without an organization. + + - **`single`: unchanged.** The membership reconciler binds the Default Organization in the same `user.create.after` hook, so a sign-up still ends with one grant, owned by the Default Organization. + - **A wall (`group`, `isolated`).** A self-registrant belongs to no organization at sign-up, so the sign-up writes no `sys_user_permission_set` row. It used to write one with no organization. The grant is held as a pending claim in `sys_verification`, which is deployment-level and survives a restart. The first `sys_member` row written for the user lands exactly one grant, owned by that organization. That row can come from creating an organization (the creator becomes its owner), accepting an invitation, being added by an administrator, or any other membership writer. A later membership writes no second grant. + - **The claim waits 365 days.** A registrant who first joins an organization after that holds only the deployment's baseline. An engine with no middleware seam cannot settle a claim, so there a registrant with no organization is reported ungranted at `error` at sign-up instead. + - Grants already written without an organization by earlier walled sign-ups are not touched here. +- d65826f: fix: framework writes on tenant-column objects name the organization they can derive (ADR-0131 D9) + + Clause-②: yes (widening) + + A write on a tenant-column object with no organization is derived under `single` and refused under a wall (ADR-0131 D9). Before the engine stops letting such writes through, each of these producers now names the organization it can derive itself. Under `single` that is the Default Organization, which is the owner the engine would derive anyway, so no reader changes. Under a wall the row names an organization only where the producer has one. The rows it cannot attribute are written exactly as before. + + - **`@objectstack/plugin-auth`.** The self-registration grant (`sys_user_permission_set`) and the auth mails about a user (verification, password reset, account invitation, magic link, change-email notice → `sys_email`) read one derivable organization: the `tenancy` service's `defaultOrgId()`. Under `single` that is the Default Organization. Under a wall it is none, because the registrant belongs to no organization yet. The verification and reset mails used to persist with no organization even under `single`. + - **`@objectstack/plugin-audit`.** An activity row (`sys_activity`) about a record with no organization of its own (a `sys_user`, a platform setting), written with no session organization (a sign-up), takes the Default Organization under `single`. The record's own organization and then the session's still win. The ledger's `tenant_id` attribution is unchanged. New optional `AuditWriterOptions.getDefaultOrganizationId()`; `AuditPlugin` wires it to the `tenancy` service. + - **`@objectstack/service-settings`.** The `sys_secret` ciphertext and the `sys_setting_audit` history row of a `tenant` or `user` value carry that value's organization, which is the same owner as its `sys_setting` row. A `global` value's rows name none. New optional `organization_id` on `SettingsSecretStore.insert`'s row and `organizationId` on `SettingsAuditWriter.write`'s entry; the built-in writers persist them. A failed `sys_setting_audit` write is now reported at `error`, not `warn`: the settings write succeeds while its history row is lost. The first report names the consequence and the fix. `buildSettingAuditWriter`'s optional logger argument keeps every sink it accepted (none, `{}`, or one with an optional `warn`) and additionally takes a sink that carries `error`. It reports through `error` when the sink has it, else through `warn`. + - **`@objectstack/verify`.** The RLS probe and position personas' grants (`sys_user_permission_set`, `sys_user_position`) belong to the organization the persona is a member of (the Default Organization under `single`). They used to be written with an explicit `organization_id: null`. A persona that belongs to no organization, or to several, names none. +- 44a5011: Clause-②: no + + Refusals and Setup help text that a user reads inside the running system no longer call that system + "this environment". They say "this system", or they drop the noun. + + - `@objectstack/plugin-auth`: the two self-registration refusals drop the noun ("Self-registration is + closed (audience posture invite_only).", "Self-registration is limited to approved email domains, …"). + So do the OAuth self-service `503` ("OIDC provider is not enabled") and the SSO `400` + `DOMAIN_VERIFICATION_DISABLED` ("Domain verification is not enabled (set + OS_SSO_DOMAIN_VERIFICATION)."). The last-administrator guard's refusals and its two warning lines say + "this system", including "nobody able to administer the system". The OIDC authorize-gate refusal + (`403 ENV_ACCESS_DENIED`) keeps "this environment". Only the cloud control plane installs that gate, + and there the noun names the environment a cloud user is signing in to. + - `@objectstack/plugin-security`: the diagnostic `message` of the packaged-permission-set lock now reads + "… is declared by package '…' and is locked — editing it here would …". The provenance-unknown + refusal says "this system could not determine …". Both `userMessage` texts are unchanged. + - `@objectstack/service-settings`: the Mail and SMS provider groups read "Choose how this system sends + outbound email." and "… outbound SMS.". The Authentication audience group reads "Who may become a user + of this system's apps.". The zh-CN, ja-JP and es-ES bundles change only the noun, to 本系统, + このシステム and este sistema. + + Only the human-readable text moves. Every HTTP status, error code, settings key and response shape + stays as it was. +- e22defb: fix(plugin-auth): a migrate-and-exit boot (`OS_MIGRATE_AND_EXIT=1`) no longer provisions the development admin, whatever `NODE_ENV` says + + Clause-②: no + + - **What changed.** `objectstack serve` reads `OS_MIGRATE_AND_EXIT` only after the kernel bootstrap, and the dev-admin seed runs on `kernel:ready`, inside that bootstrap. So a migration run with `NODE_ENV=development` seeded exactly as a dev boot does: run against an empty database, it left a known-credential login behind. `isDevAdminSeedArmed()` now answers "not armed" when `OS_MIGRATE_AND_EXIT` is `1`, the same spelling `serve` exits on, and the clause wins over an explicit `OS_SEED_ADMIN=1`. A migrate-and-exit boot of an empty database now creates no `sys_user` row and no `sys_account` row. + - **The walled owner check follows.** It reads the same function, so on a migration run the seed no longer counts as a way to verify a declared platform owner. A walled migrate-and-exit boot of an empty database completes its schema sync as before; it just seeds no login. + - **Unchanged.** The `objectstack dev` seed: still armed under `NODE_ENV=development` on a database with no login, and still off under `OS_SEED_ADMIN=0`. Any `OS_MIGRATE_AND_EXIT` value other than `1` is a boot that goes on to serve, and keeps its seed. A database that already holds a login was never seeded and is not touched. + - **If a pipeline relied on the seed.** Create the first account through the deployment's own provisioning path after the migration step, as a production deployment does. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- c7a3234: perf(core,plugin-auth): an authenticated request resolves its caller's grants once, not twice + + Clause-②: no + + `resolveAuthzContext` learns who the caller is from the transport's `getSession`. Against `@objectstack/plugin-auth` that is better-auth's `getSession`, whose `customSession` hook resolves the principal's grants for the session payload's `positions[]` and `isPlatformAdmin`; `resolveAuthzContext` then resolved the same grants again, with the same arguments, for the request's envelope. Every authenticated request on a door that resolves identity through `resolveAuthzContext` with a better-auth session read (the runtime dispatcher and the REST server among them) paid every grant read twice: eight reads per resolution for a caller in an organization. + + `resolveAuthzContext` now runs inside a request-scoped grants memo. A resolution that already completed inside the same call, with the same user, organization and seeds, is served to the next caller that asks for exactly that resolution, so the hook's resolution serves the resolver's own. The decision a request is authorised with is unchanged: + + - The memo lives for one `resolveAuthzContext` call (an `AsyncLocalStorage` scope, closed when the call settles). Nothing is cached across requests; the cross-request grants cache keeps its own default-off switch, `OS_AUTHZ_GRANTS_CACHE_TTL_MS`. + - An entry is served only when a fresh read would agree with it. No write may have started, been executed at the driver, or still be in flight on the engine since the first resolution opened: the engine's write epoch (bumped when a write starts) and a write observer the memo registers as an engine middleware (counting writes into the chain and, after the driver step, out of it) must both read what they read at the open, with nothing inside the chain. The observer sees statement execution, not commit visibility. A write inside an `engine.transaction()` becomes visible only at its COMMIT, outside every chain. On driver-sql (not on the Turso remote face, which opens no transaction), a request whose step 2 falls inside that one commit round trip is authorised as of its first resolution, and the next request reads fresh — the same answer as a write from another process. No grant validity boundary may lie between the two clocks. The organization must be the same (the session arm's dropped-claim re-resolution is its own entry). A `bypassGrantsCache` caller is never served, a failed resolution is never stored, and an engine without the write-epoch seam or `registerMiddleware` declines entirely. + - `plugin-auth`'s hook now passes the session's email as its seed email, exactly as `resolveAuthzContext` does for the same session, so the two calls ask for the same resolution. The seed reaches only the envelope's `email`, which the hook does not read: the payload's `positions[]` and `isPlatformAdmin` are unchanged, and platform-admin standing still compares the stored `sys_user.email`, never a seed. + + Where the saving does not apply, the request reads the grants twice, as before — always the safe direction: + + - a request that meets a concurrent write on its engine; + - a request whose session read itself writes: the first request on a fresh auth instance generates its signing key, and with an idle timeout configured `enforceSessionControls` stamps `sys_session.last_activity_at` about once a minute per session; + - the first `resolveAuthzContext` call on an engine, which registers the write observer and stores nothing for it. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [a7a48b7] +- Updated dependencies [b88c356] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [0db5ad5] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [166a94f] +- Updated dependencies [b2549a8] +- Updated dependencies [7806a14] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [4b7c630] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [9859e4e] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [d952358] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [3fdb92d] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [d294951] +- Updated dependencies [6a6a4d3] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [646303e] +- Updated dependencies [e9bc60b] +- Updated dependencies [52ff308] +- Updated dependencies [d1f082d] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [8024e12] +- Updated dependencies [c4b84ad] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [0511341] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [5d63c37] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [d9d7edd] +- Updated dependencies [4e0838d] +- Updated dependencies [d45d0e9] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [0d34066] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [24e064f] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [cac0be8] +- Updated dependencies [1f3ac4f] +- Updated dependencies [57994c3] +- Updated dependencies [1eac7ad] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [ee0e115] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [5c2ac83] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [aefab7c] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [c1c0a90] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a06f0d4] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [9975c26] +- Updated dependencies [2ac0616] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [aaaeeac] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8259ea0] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [2bcd406] +- Updated dependencies [58a2faa] +- Updated dependencies [e74ec56] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/rest@18.0.0 + - @objectstack/service-messaging@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index a4ece37658b..a24f67168dc 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Authentication & Identity Plugin for ObjectStack", "main": "dist/index.js", diff --git a/packages/plugins/plugin-dev/CHANGELOG.md b/packages/plugins/plugin-dev/CHANGELOG.md index aecd443303b..d402fee3fd4 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,697 @@ # @objectstack/plugin-dev +## 18.0.0 + +### 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 [5a871e4] +- Updated dependencies [f8ef144] +- Updated dependencies [5b14b7f] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [913fd6d] +- Updated dependencies [8eac321] +- Updated dependencies [de43524] +- Updated dependencies [a5a781b] +- Updated dependencies [04669a4] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [c395405] +- Updated dependencies [29527f2] +- Updated dependencies [7c7741d] +- Updated dependencies [1e8b628] +- Updated dependencies [3b8e2b1] +- Updated dependencies [14ccb1d] +- Updated dependencies [7879391] +- Updated dependencies [a789148] +- Updated dependencies [d61ed6a] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [959c209] +- Updated dependencies [c28f317] +- Updated dependencies [17e4425] +- Updated dependencies [7d7943d] +- Updated dependencies [aa71c4d] +- Updated dependencies [19bb55e] +- Updated dependencies [19bb55e] +- Updated dependencies [ba8af87] +- Updated dependencies [6a49efb] +- Updated dependencies [99801d8] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [5c9bfb0] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [e7e6544] +- Updated dependencies [cd3d391] +- Updated dependencies [149294c] +- Updated dependencies [990341a] +- Updated dependencies [9646991] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [2fbe1fd] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [a7a48b7] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [93125ae] +- Updated dependencies [29678f2] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [e67ba80] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [4935c66] +- Updated dependencies [c6fe02d] +- Updated dependencies [c6fe02d] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [6ff6ed6] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [3aa0c7b] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4049cac] +- Updated dependencies [7ebbc01] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [ffb31fc] +- Updated dependencies [0b997ea] +- Updated dependencies [3513ac7] +- Updated dependencies [81bd9fa] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [0ee2d15] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [f2626c7] +- Updated dependencies [59d993c] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [ca135dc] +- Updated dependencies [e36ee53] +- Updated dependencies [1884948] +- Updated dependencies [8a995b8] +- Updated dependencies [54c3ce1] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [00bee27] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [f66c440] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [166a94f] +- Updated dependencies [dd986d8] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [7806a14] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [ce3d0ad] +- Updated dependencies [ce3d0ad] +- Updated dependencies [e148ca9] +- Updated dependencies [557ae7c] +- Updated dependencies [f782f17] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d303b3e] +- Updated dependencies [018b077] +- Updated dependencies [0fea05f] +- Updated dependencies [86bf9ed] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [25be876] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [18d9990] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [874a38d] +- Updated dependencies [cb3bb93] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [4b7c630] +- Updated dependencies [87cd458] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [9859e4e] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [322ff1e] +- Updated dependencies [680a86b] +- Updated dependencies [d952358] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [c11b758] +- Updated dependencies [f9ebb7d] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [3fdb92d] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [117817e] +- Updated dependencies [4663801] +- Updated dependencies [d294951] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [0aef788] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [646303e] +- Updated dependencies [e9bc60b] +- Updated dependencies [52ff308] +- Updated dependencies [d1f082d] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [8024e12] +- Updated dependencies [c4b84ad] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [0511341] +- Updated dependencies [a3d47c2] +- Updated dependencies [21c8644] +- Updated dependencies [dfb3ba2] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [d4aaeaf] +- Updated dependencies [4cc3ff9] +- Updated dependencies [3d6392f] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [3490bf5] +- Updated dependencies [920bc12] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [6f4c518] +- Updated dependencies [08b38cb] +- Updated dependencies [87d0619] +- Updated dependencies [917b1a0] +- Updated dependencies [d9d7edd] +- Updated dependencies [4e0838d] +- Updated dependencies [d45d0e9] +- Updated dependencies [cb9841a] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [0d34066] +- Updated dependencies [8d3dd6f] +- Updated dependencies [8ed3786] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [01d5034] +- Updated dependencies [f24574a] +- Updated dependencies [e790cfc] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [09ef77c] +- Updated dependencies [5d3018d] +- Updated dependencies [404fc47] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [24e064f] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [6d96721] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [28e294f] +- Updated dependencies [775ed65] +- Updated dependencies [0be162c] +- Updated dependencies [02a691a] +- Updated dependencies [e4ec6ec] +- 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 [b9dbc3d] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- Updated dependencies [1692cd1] +- Updated dependencies [0d784da] +- Updated dependencies [be50633] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [650933e] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [cac0be8] +- Updated dependencies [1f3ac4f] +- Updated dependencies [57994c3] +- Updated dependencies [1eac7ad] +- Updated dependencies [70d4c06] +- Updated dependencies [989aa48] +- Updated dependencies [23e689d] +- Updated dependencies [9fcea20] +- Updated dependencies [19c554d] +- Updated dependencies [5bb9a83] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [ee0e115] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [f7cd580] +- Updated dependencies [c9810dd] +- Updated dependencies [e611e2b] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cd86e41] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [f77ba41] +- Updated dependencies [395ca21] +- Updated dependencies [76560a1] +- Updated dependencies [9e0945c] +- Updated dependencies [23d0bc7] +- Updated dependencies [7428da0] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [b55be42] +- Updated dependencies [abb6df0] +- Updated dependencies [0b1179f] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [f7a5ebe] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9d4fb3c] +- Updated dependencies [c5cf546] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [34b740f] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [0cfd0c4] +- Updated dependencies [ae4dfaf] +- Updated dependencies [099cc9d] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2e15620] +- Updated dependencies [0cb45e9] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [605aa01] +- Updated dependencies [a33f3d5] +- Updated dependencies [d050a8f] +- Updated dependencies [9e797a8] +- Updated dependencies [b25d98a] +- Updated dependencies [75ced06] +- Updated dependencies [5fb8d15] +- Updated dependencies [5c2ac83] +- Updated dependencies [4ffdebe] +- Updated dependencies [03ac4ec] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [aefab7c] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [6e43b7b] +- Updated dependencies [6dede7a] +- Updated dependencies [727091c] +- Updated dependencies [62f959d] +- Updated dependencies [c1c0a90] +- Updated dependencies [ab3ba2c] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [f792290] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [d702b18] +- Updated dependencies [a06f0d4] +- Updated dependencies [a06f0d4] +- Updated dependencies [9f024d8] +- Updated dependencies [9975c26] +- Updated dependencies [2ac0616] +- Updated dependencies [2ac0616] +- Updated dependencies [2ac0616] +- Updated dependencies [5a9a80b] +- Updated dependencies [1489952] +- Updated dependencies [aaaeeac] +- Updated dependencies [8eccf51] +- Updated dependencies [8a88901] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [aa31569] +- Updated dependencies [8259ea0] +- Updated dependencies [9040945] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [1bf5f6e] +- Updated dependencies [1bf5f6e] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [2bcd406] +- Updated dependencies [2bcd406] +- Updated dependencies [58a2faa] +- Updated dependencies [e74ec56] +- Updated dependencies [58a2faa] +- Updated dependencies [2bcd406] +- Updated dependencies [e74ec56] +- Updated dependencies [af284b0] +- Updated dependencies [cbfa5e4] +- Updated dependencies [14a8bd2] +- Updated dependencies [83dfbc0] +- Updated dependencies [9822b05] +- Updated dependencies [889bf12] +- Updated dependencies [7a8733f] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [d79dc48] +- Updated dependencies [1173017] +- Updated dependencies [51ad8e4] +- Updated dependencies [ec749fb] +- Updated dependencies [c4526a7] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [2f569a9] +- Updated dependencies [69147ab] +- Updated dependencies [3a734e0] +- Updated dependencies [6868514] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [9112bcd] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [94e60c0] +- Updated dependencies [9524666] +- Updated dependencies [d65826f] +- Updated dependencies [741aa85] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf16a0d] +- Updated dependencies [cf540a7] +- Updated dependencies [d8e11b5] +- Updated dependencies [44a5011] +- Updated dependencies [adb7910] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [2a1e48a] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [e22defb] +- Updated dependencies [45bec43] +- Updated dependencies [964a237] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] +- Updated dependencies [cdf3777] + - @objectstack/core@18.0.0 + - @objectstack/objectql@18.0.0 + - @objectstack/runtime@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/plugin-auth@18.0.0 + - @objectstack/service-storage@18.0.0 + - @objectstack/plugin-security@18.0.0 + - @objectstack/rest@18.0.0 + - @objectstack/service-realtime@18.0.0 + - @objectstack/types@18.0.0 + - @objectstack/plugin-hono-server@18.0.0 + - @objectstack/service-i18n@18.0.0 + - @objectstack/driver-memory@18.0.0 + - @objectstack/account@18.0.0 + - @objectstack/setup@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index b7c124c20ff..8932bdc6a52 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Development Assembly Plugin for ObjectStack — wires the real platform stack for zero-config local development", "main": "dist/index.js", diff --git a/packages/plugins/plugin-email/CHANGELOG.md b/packages/plugins/plugin-email/CHANGELOG.md index bef1a718310..250ef765a28 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,630 @@ # @objectstack/plugin-email +## 18.0.0 + +### Minor Changes + +- fbafb85: feat(plugin-email,plugin-auth): identity mail — verification, password reset, magic link, the email-change notice, an account invitation — is recorded in `sys_identity_email`, a deployment-level sibling of `sys_email` with no organization column + + Clause-②: yes (widening) + + ADR-0131 D7, ruling of record 6257510973 (letter Q2 A). Expand-first: this release adds the new log and its write path, and keeps reading every row and job an 18.0 install left behind. It moves and deletes nothing. + + - **The new object.** `sys_identity_email` (`@objectstack/platform-objects`, registered by `EmailServicePlugin`) has `sys_email`'s fields, so the row shape is the same, and `systemFields: { tenant: false }`, so it has no organization column. It is append-only, readable over `get` / `list`, and gated by `manage_platform_settings` (like `sys_job`), because its rows carry password-reset and sign-in links. + - **The contract.** `SendEmailInput` and `SendTemplateInput` (`@objectstack/spec/contracts`) gain `identityMail?: boolean`. With `true`, the mail service writes the row to `sys_identity_email`. Without it, the row goes to `sys_email`, as before. `identityMail: true` together with an `organizationId` is refused as `VALIDATION_FAILED` and recorded as a rejected row in `sys_identity_email`. The organization is not written. + - **One pipeline, both logs.** The queue worker, the outbox drain hook, the boot outbox sweep, attachment reclamation and the internal-header readback read a row from the log it lives in. A delivery or reclaim job for a `sys_identity_email` row carries `object: 'sys_identity_email'` beside `rowId`. A job with no `object` reads `sys_email`, which covers every job an 18.0 process queued. A job naming anything else is refused at `error` and touches no row. `EmailPersistence.insert` / `update` / `readHeadersJson` take the log as an optional last argument. A custom implementation that ignores it keeps every row in its one store. `@objectstack/plugin-email` exports `TENANT_EMAIL_OBJECT`, `IDENTITY_EMAIL_OBJECT`, `EMAIL_LOG_OBJECTS`, `isEmailLogObject` and `EmailLogObject`. + - **plugin-auth.** The five identity sends say `identityMail: true` and name no organization, under every posture. Before, they carried the Default Organization under `single` and nothing under a wall. The organization invitation (`auth.invitation`) is tenant mail. It still carries its organization and still lands in `sys_email`. + - **What a reader of `sys_email` sees.** + - **Before.** Identity mail was a `sys_email` row: stamped with the Default Organization under `single`, organization-less under a wall. + - **Now.** New identity mail is a `sys_identity_email` row. Rows already in `sys_email` stay there and are still delivered, swept and reclaimed. + - **Remedy.** A report, export or query that looks for verification or reset mail reads both logs. For example: `ql.find('sys_identity_email', { where: { related_object: 'sys_user', related_id: userId } })` beside the same query on `sys_email`. + - **Inventory.** `os migrate organization-ownership`'s inventory lists `sys_identity_email` as deployment-level (column-drop fate, nothing to attribute). Moving the identity rows already in `sys_email` is an 18.x upgrade-ceremony step, not part of this release. +- 8c5aa50: feat(plugin-email,plugin-auth)!: an organization can no longer create or edit an email template row; the template provenance stamp and the auth SMS template seed retire + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing and three retired exports), shipped as `minor` under the launch-window convention for breaking changes. It carries ADR-0131 D6 and the maintainer's ruling C on ADR-0131 §6 Q1: email templates are not overridden per organization. + + **What stops being accepted.** + + - **The `sys_email_template` organization door is closed.** An organization's create and update of a template row are refused with `403 PERMISSION_DENIED`, and the message names the closed door: every engine `insert` / `update` whose context names a caller and is not system-elevated. That covers `POST` / `PATCH /api/v1/data/sys_email_template`, the Studio record editor, batch and import routes, and scripts and flows that run as a user or a service principal. System-context writes still pass: the built-in seed, the declared-template boot sweep, the live projection of a Studio `email_template` save into its row, and the v18 migration ceremony's promotion. Delete is not part of this door. + - **The template provenance stamp is retired.** It marked a package- or platform-seeded row `customized: true` when a non-system caller updated it. With the door closed no such update reaches the engine's write, so nothing marks a row any more. Rows already marked keep their mark and are still never overwritten by the boot seeders; they are the population the v18 migration ceremony promotes to environment-level Studio templates. + - **Retired exports of `@objectstack/plugin-email`:** `bindEmailTemplateProvenanceStamp`, `unbindEmailTemplateProvenanceStamp` and `EMAIL_TEMPLATE_PROVENANCE_PACKAGE`. They have no replacement. `EmailServicePlugin` closes the door itself, and there is no stamp left to bind, so a direct call is deleted, not rewritten. + - **The auth SMS template seed is retired.** A boot with phone sign-in on no longer writes the built-in OTP and invitation texts into `sys_notification_template` as rows. It was internal to `@objectstack/plugin-auth` and exported nothing. + + **What renders unchanged.** + + - Every email template renders as before: the template loader still reads `sys_email_template`, and a Studio edit of an `email_template` still reaches the mail at once and survives a restart. + - Every auth SMS text renders byte for byte as the seeded store rendered it, for every built-in text and every recipient locale. Each locale rung renders an operator's active row when one exists, and the built-in text where no row exists at that locale. A deactivated or blank row passes its rung on, as it did before. + - A `sys_notification_template` row an operator already has still wins, and no existing row is touched. + + **What changes for you.** To change what an email template sends, edit the `email_template` in Studio. A script or integration that wrote `sys_email_template` rows through the data API now receives `403 PERMISSION_DENIED`. `@objectstack/platform-objects` corrects the `is_system` and `customized` field help on `sys_email_template`, which said an organization may edit a row. +- 7de0265: feat(plugin-email)!: email templates resolve from the registry; the built-in seed, the declared-template boot sweep and the live projector retire + + Clause-②: yes (narrowing) + + + + **BREAKING** (retired exports and plugin options of `@objectstack/plugin-email`), graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, so this ships in an `18.0.0-next.N`. It carries ADR-0131 D6/D10 and the maintainer's ruling C on ADR-0131 §6 Q1. + + **What changes.** + + - **Templates resolve from the registry.** `sendTemplate` and `renderTemplate` read `email_template` metadata by `(name, locale)` at every send: the protocol's layered list (`GET /meta/email_template`'s items — the environment's item over each code package's declaration), or the ObjectQL registry on a host with no metadata door. The locale ladder is unchanged: the named locale exactly, then `en-US`, then — only for a call that named none — the bundle's lowest locale tag. + - **The built-in auth templates are code-declared items** of the email plugin's own package (`com.objectstack.service.email`), registered at plugin init, so they now appear in Studio's email-template list. An app that declares the same `(name, locale)` wins over the built-in; the built-in still serves the locales the app leaves out; a Studio save overrides either at environment level. Every built-in renders byte-identical to the row it used to be seeded into, in every locale (pinned on the rendered subject, HTML and text). + - **A Studio save is live on the next send**, with no projection and no restart. The `PUT /meta/email_template/:name` response no longer carries `projectionApplied`, because nothing is projected. + - **No `sys_email_template` row is written at boot or read at send.** The table and its rows stay; the organization door stays closed. + - **A malformed template fails its send loudly** with the schema's own message, instead of falling through to the built-in copy of the same slot. + + **What moves for consumers.** + + - FROM `createSysEmailTemplateLoader(engine, options?)` TO `createRegistryEmailTemplateLoader(sources)`, where `sources` is `{ protocol?, registry? }` (type `EmailTemplateRegistrySources`) or a function returning it. FROM the types `TemplateLoaderEngine` / `CreateTemplateLoaderOptions` TO `EmailTemplateRegistrySources`. + - FROM `bootstrapDeclaredEmailTemplates(...)`, `upsertDeclaredEmailTemplate(...)` or `deactivateDeclaredEmailTemplate(...)` TO nothing: templates are not copied into rows any more. Delete the call. + - FROM `mapTemplateToRow(tpl)` TO nothing: there is no row projection. To resolve a template the way the mailer does, call `createRegistryEmailTemplateLoader(...).load(name, locale)`. + - FROM `EMAIL_TEMPLATE_OBJECT` TO the literal `'sys_email_template'`, only if you still address the legacy table. FROM the type `BootstrapDeclaredEmailTemplatesResult` TO nothing. + - FROM `new EmailServicePlugin({ seedTemplates: false })` TO `new EmailServicePlugin()`: there is no seed to switch off. Delete the option. + - FROM `new EmailServicePlugin({ templates: [...] })` TO the stack's `emailTemplates: [...]` (or a `*.email-template.ts` file): declared templates resolve from the registry. Delete the option. + + **What a deployment observes.** A `sys_email_template` row an organization customized before its door closed is no longer what sends: the template's code declaration (or an environment-level Studio save) is. The v18 migration ceremony promotes those rows to environment-level templates; to make such an edit live before it runs, re-save the template in Studio. + + **`@objectstack/spec`** (patch): the `email_template` liveness ledger is re-anchored to the registry loader, and `category` and `isSystem` are graded `dead` — their only consumer was the retired row projection. The `notify` node's `template` description and refusal messages, and the `IEmailService` contract's documentation, name the email templates rather than `sys_email_template` rows. No schema shape changes. + + **`@objectstack/service-automation`** (patch): the notify node's `template` help text served at `GET /api/v1/automation/actions` names the email templates rather than `sys_email_template` rows. +- 7895671: feat(metadata-core,rest,runtime,plugin-email,spec)!: the `/meta` doors carry no organization; organization-admin metadata authoring closes and `manage_org_presentation` retires (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio belongs to the whole deployment. The `/meta` doors of both transports (`@objectstack/rest` and the runtime dispatcher's `/meta` branch) used to thread the caller's active organization into writes and reads of the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`). They no longer do, for any type, and the read and the write flip together. + + **What changes.** + + - **Writes land environment-wide.** `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` on `/api/v1/meta/:type/:name` hand the protocol no organization, so the row and its audit and history rows carry `organization_id` NULL, whatever the caller's active organization. + - **Reads are environment → code.** The item read (both cache arms), the list, the layered view (`/layers` and `?layers=true`), `/published`, `?state=draft`, `GET /meta/_drafts`, `/history`, `/diff`, `/audit` (the environment rows, `organizationId: null`), `GET /meta/diagnostics` and `/references` name no organization. + - **An organization admin's metadata write is refused.** `metaWriteCapabilityVerdict` admits `isSystem` or `manage_metadata` only. A caller holding `manage_org_presentation` and not `manage_metadata` is answered `403` on all four item doors (`FORBIDDEN` on REST, `PERMISSION_DENIED` on the dispatcher) with `… requires the \`manage_metadata\` capability.`, for every type and whatever its active organization. + - **`manage_org_presentation` retires** from `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`). A permission set naming it still parses and loads, and its other grants still apply; the grant itself admits nothing. The `sys_capability` row seeded for it earlier is not pruned (the seeder upserts only); an operator may delete it in Setup. + - **The email-template boot sweep** (`@objectstack/plugin-email`) reads the effective templates environment → code, as the door serves them; it no longer reads in the Default Organization. + + **What moves for consumers.** + + - FROM `import { organizationIdForMetaWrite } from '@objectstack/metadata-core'` TO nothing: a `/meta` write carries no organization. Delete the call and the `organizationId` it fed. + - FROM `import { ORG_PRESENTATION_AUTHORING_CAPABILITY } from '@objectstack/metadata-core'` TO nothing: delete the import. + - FROM `metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType, activeOrganizationId, operation })` TO `metaWriteCapabilityVerdict({ isSystem, systemPermissions, operation })`: drop the two members. + - FROM `import { metaReadOrganizationId } from '@objectstack/rest'` TO nothing: a `/meta` read carries no organization. `metaCallerOrganizationId` stays. + - FROM `bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy })` TO `{ protocol }`: the `tenancy` source is gone. + - A permission set granting `manage_org_presentation`: grant `manage_metadata` to whoever must author those five types, and delete the stale grant. + + **What a deployment observes.** An overlay row an earlier release stored under an organization stays in `sys_metadata` untouched, and is no longer served by any `/meta` read or projected by the email-template sweep until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Under the `single` posture that is every earlier Studio save of the five types, because it was filed under the Default Organization: on the `/meta` doors such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. + + **What does not change.** Saves of every other type were already environment-wide. Flow saves, the capability gate's answer for `manage_metadata` holders and `isSystem`, the protocol's own organization-scoped refusals, and the `/packages` doors are untouched by this change. +- 7098aca: `bootStack` mounts the always-on capability slate `objectstack serve` mounts for every app, and builds each provider from the app's configuration the way `serve` builds it + + Clause-②: yes (narrowing: a configuration whose mail or SMS settings, or the OS_EMAIL_* / OS_SMS_* environment, name a transport that cannot deliver, now fails bootStack where it booted; widening: bootStack mounts the always-on slate objectstack serve mounts for every app and hands each provider the app's configuration, so the app's analytics cubes now reach the registry, where it mounted fewer and built them with defaults) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — the composition `serve` mounts, built the way `serve` builds it.** For one configuration, `bootStack(config, opts)` already mounted the providers the app's `requires` names and the plugins in its own `plugins` array. It now also: + + - **Mounts the always-on slate** `serve` mounts for every app, whether or not `requires` names it: `queue`, `job`, `cache`, `settings`, `email`, `storage`, `sms`, `sharing`, `messaging`, `analytics` and `package-registry`. Before, a slate provider was mounted only where a mounted plugin hard-depended on it, so an app's tests ran without the inbox delivery, the mail service, the file storage and the package registry its users' server has. + - **Builds each provider from the app's configuration**, by the same rule `serve` reads: the analytics service gets the app's `analyticsCubes` (the top level, then each package body's), the email service the app's `email` block with `OS_EMAIL_*` over it, the SMS service the `sms` block with `OS_SMS_*` over it, and the storage service the `OS_STORAGE_LOCAL_ROOT` root (`.objectstack/data/uploads` under the working directory by default). Before, each was built with its own defaults: no cubes, no mail configuration. So the app's cubes now reach the analytics registry; a cube the service refuses (one over an object the API does not serve) is warned and skipped by the service, under `bootStack` as under `serve`, and no boot fails on it. + - A caller's `extraPlugins`, `security` and `analytics` instances still take precedence by identity, and are not handed the configuration. A suite that needs a provider configured its own way (a temporary storage root, a mail transport) passes its instance in `extraPlugins`. + - Not mounted, because they are decisions about a server process rather than about the configuration: `serve`'s MCP endpoint (`OS_MCP_SERVER_ENABLED`) and its pinyin search (`OS_SEARCH_PINYIN_ENABLED`). A suite that exercises either passes the provider in `extraPlugins`. + + **What now fails that booted before (the narrowing).** A provider `bootStack` constructs and cannot build fails the boot, naming the capability token and the package — whether the app declared the token or the slate appended it. `serve` logs a slate provider it cannot build and boots on; a test boot does not, so an app's tests never pass on a composition its server does not run. In practice: + + - a configuration whose `email` or `sms` settings, or the `OS_EMAIL_*` / `OS_SMS_*` environment of the test process, name a transport that cannot deliver (`provider: 'smtp'` with no host, `resend` / `postmark` with no API key, an unknown provider tag) is refused with the reader's own remedy. Set `OS_EMAIL_PROVIDER=log` / `OS_SMS_PROVIDER=log` in the test environment if it is not meant to send mail or SMS. + + **`@objectstack/core`** exports the rule both boots read: `resolveServedCapabilities` (given the tokens a stack declares, which capability tokens a served boot mounts providers for — those, `email` for a declared `auth`, the host's own defaults, the always-on slate unless the preset is `minimal`, and `job` / `queue` ahead of what schedules background work) and `resolveCapabilityArgument` (what each provider is constructed with), with their `ServedCapabilities`, `CapabilityArgumentInput` and `CapabilityArgument` types; and `resolveStorageCapabilityArg`, `resolveStorageLocalRootEnv` and `StorageCapabilityArg`, moved here from the CLI. + + **`@objectstack/plugin-email`** exports `resolveEmailCapabilityArg`, `resolveDeploymentAppName` and `EmailCapabilityArg`, and **`@objectstack/service-sms`** exports `resolveSmsCapabilityArg` and `SmsCapabilityArg` — the readers that build each provider from the deployment's configuration, moved from the CLI to sit beside the transport vocabulary they refuse against. `resolveCapabilityArgument` reads each off the provider module it is handed. + + **`@objectstack/cli`**: `serve` reads both rules from `@objectstack/core` and composes exactly what it composed before. `os migrate plan` / `apply`'s declaration boot reads the same token rule for the providers it composes (the same set as before; a declared `auth` now places `email` right after the declared tokens, as `serve` does). `os verify` boots through `bootStack`, so it now mounts the always-on slate and builds the providers from the app's configuration. The moved functions stay importable from the `serve` command module. +- 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 + +- 56bf27a: The declared-email-template boot sweep reads the stored rows in bulk and no longer rewrites a template that has not changed + + Clause-②: no + + Every boot used to look each effective email template up on its own and rewrite its + `sys_email_template` row unconditionally: one lookup, one UPDATE and the engine's two read-backs + per template, whether or not anything had changed. Measured on `ObjectQL` over `SqlDriver` + (better-sqlite3), a steady boot over 450 unchanged templates sent 1,800 statements; it now sends 3 + reads and no write (79 templates: 316 statements, now 1). + + - The sweep reads the stored rows for the declared names in `$in` pages of 200 names. Each + `(name, locale)` takes the first row the read returns, in the order the driver gives the + per-template lookup, so a slot several organizations hold resolves to the same row as before. + - A template the bulk read did not answer (new since the last boot, a read cut short at its row + bound, or a failed read) is looked up on its own before anything is inserted, as before. + - A `managed_by: 'package'` row that already holds every column the template projects is not + rewritten. `upsertDeclaredEmailTemplate` now returns `false` for it, and + `bootstrapDeclaredEmailTemplates` counts it under `skipped`, the value both already document for + a row deliberately not written. A row with any other provenance is handled exactly as before: + admin-owned and customized rows are never written, and a legacy row with no `managed_by` is + rewritten and adopted. + + An org-scoped template edit still survives the next boot. Signatures and accepted input are unchanged. +- ec7f7f8: feat(spec)!: retire an email template's `category` and `isSystem` — the grouping and the built-in flag nothing read (#22876) + + **BREAKING** — `category` and `isSystem` on an email template (`EmailTemplateDefinitionSchema`, the `email_template` metadata type) are now refused at parse. Nothing read either key once templates stopped being copied into `sys_email_template` rows: the Studio listing never offered the filter facet `category` promised, and no door refused deleting a template that set `isSystem`. Delete both keys. A template is found by its `name`, so group templates with a dotted namespace prefix (`auth.password_reset`, `crm.welcome`). A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. + + Clause-②: no (narrowing) + + Measured before removal: no reader of either key in `@objectstack/plugin-email`, `@objectstack/service-messaging`, `@objectstack/plugin-auth`, the metadata protocol, ObjectQL, the runtime or the REST layer, and none in the Studio console at its pinned commit beyond a fallback form input, which the console's own companion change removes. Both keys carried a schema default (`category: 'custom'`, `isSystem: false`), so every parsed template carried them and the liveness advisory fired on every template, with no way for an author to clear it. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `category: 'auth'` (or `'notification'`, `'workflow'`, `'marketing'`, `'custom'`) | nothing — delete the key; group by the `name` prefix | + | `isSystem: true` | nothing — delete the key | + | `isSystem: false` | nothing — delete the key | + | `import { EmailTemplateDefinitionCategorySchema } from '@objectstack/spec/system'` | nothing — the enum is removed with its key | + | `type EmailTemplateDefinitionCategory` | nothing — the type is removed with its key | + + **The one-line fix:** delete `category` and `isSystem` from every email template. + + **What an author who still writes either sees.** `tsc` fails at the authoring site (`EmailTemplateDefinition` types both keys as retired), and the parse — `defineEmailTemplateDefinition()`, `defineStack({ emailTemplates })`, `PUT /api/v1/meta/email_template/:name` — refuses each at its own path with the prescription: + + > `email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). 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. + + > `email_template.isSystem` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. 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 + + - **Two `retiredKey()` tombstones** on `EmailTemplateDefinitionSchema`, a `strictObject` — so the refusal carries the prescription rather than a bare unknown-key report, and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `system/EmailTemplateDefinition:category`, `system/EmailTemplateDefinition:isSystem`. The `category` enum, `EmailTemplateDefinitionCategorySchema`, had no other consumer and leaves with it: `RETIRED_DEFS_BY_MAJOR[18]`: `system/EmailTemplateDefinitionCategory`. + - **The D2 conversion `email-template-category-is-system-removed`** (protocol 18, retired from the load path) deletes both keys from every `emailTemplates[]` entry, one notice per key, as a lossless delete. Because both keys carried a default, every built artifact and every stored `email_template` row written under 17.x carries them; both load through the rehydration seams, which replay this conversion. The retired-default residue stage is not adopted: the replayed conversion heals those populations, and an authored source that still writes either key meets the prescription. + - **The D3 entry `email-template-category-is-system-retired`** asks the author whether anything they built read either key — a report or integration that filtered templates by `category`, or one that treated `isSystem: true` as protection against deletion. Nothing ever enforced that. + - **Studio:** the email-template form drops the `category` select and the `isSystem` checkbox. + - **Ledger:** both rows stay `dead`, re-noted as tombstones (a tombstone keeps the key in the walked shape). + - **No deprecation window**, per the project's startup-stage posture. + + **`@objectstack/plugin-email`:** the six built-in auth templates (in every locale) no longer write `category: 'auth'` or `isSystem: true`. They render and send exactly as before. + + **`@objectstack/platform-objects`:** the metadata-form translation bundles drop the two retired form inputs' labels (`email_template.fields.category`, `email_template.fields.isSystem`). The `sys_email_template` object and its `category` / `is_system` columns are unchanged. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + +- 33228c5: Clause-②: no + + fix(plugin-email): a template send issued before `EmailServicePlugin`'s own `kernel:ready` renders, persists its `sys_email` row and delivers, instead of throwing `TEMPLATE_NOT_FOUND: no templateLoader configured on EmailService` + + `EmailServicePlugin` now wires `sys_email` persistence and the registry template loader in `init`, before it registers the `email` service. Before this, both were wired in the plugin's own `kernel:ready` hook, and a plugin that starts earlier runs its `kernel:ready` hooks first. The dev admin seed (`objectstack dev --seed-admin`, the default) signs its account up from one of those hooks, so on a boot with email verification required the seed's verification mail failed. Each such boot logged `ERROR [Better Auth]: Failed to run background task: Error: TEMPLATE_NOT_FOUND …` and left no `sys_email` row. That send now goes through the transport and default sender the boot was configured with. The `mail` settings namespace is still applied at `kernel:ready`. Sends made after boot behave as before. +- 8a88901: fix(plugin-auth,plugin-email): the organization invitation mail names the invitee's role by its label in the mail's language (`成员`, `メンバー`, `Miembro`, `Member`), not by the stored value `member` + + Clause-②: yes (widening) + + - **What changed.** The zh-CN, ja-JP and es-ES invitation mails printed the stored membership role inside a translated sentence: 「邀请您以 member 的身份加入」. The sender now passes `{{role}}` as the `sys_member.role` option label in the locale the mail is written in, the same label the console shows for that option. It reads the label through the runtime's i18n service and the console's own object translation path, so a tenant's translation of the option, made in Studio, reaches the mail too. The four bodies keep interpolating the one `role` variable; its name and type are unchanged, and its declaration now says it carries the label. + - **The plain-text part names the role too.** The four text bodies gain the role clause their HTML bodies carry, so both parts of the mail name the same facts. For example, the English text is now `Dana (dana@example.com) invited you to join Northwind on Acme as Member.` + - **Stated fallbacks.** A role list (`admin,member`) is labelled role by role and joined as a list in the mail's language (`管理员和成员`). A value with no option label is named as stored, and one warning names it. With no i18n service, or one that fails, the mail names the option's authored English label, never an empty string. + - **New option (widening).** `AuthManagerOptions.getI18n?: () => II18nService | undefined` is a lazy accessor for the `i18n` service, read when the invitation is sent. `AuthPlugin` wires it from the kernel. An embedder that builds `AuthManager` itself and omits it gets the authored labels. + - **What changes for you.** If you have overridden `auth.invitation` and interpolate `{{role}}`, it now receives the label, not the stored value. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [4840720] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/formula@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index 264633fe350..99e003706bc 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Email service plugin for ObjectStack — IEmailService + transport-pluggable outbound delivery with sys_email persistence.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-hono-server/CHANGELOG.md b/packages/plugins/plugin-hono-server/CHANGELOG.md index ac437b63b54..d943b3f5cc1 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,595 @@ # @objectstack/plugin-hono-server +## 18.0.0 + +### Minor Changes + +- 5cfd866: feat(sharing): a share-link password can be sent in the `X-Share-Password` header whatever its characters, under a declared encoding (`X-Share-Password-Encoding: utf-8`) + + Clause-②: yes (widening) + + - **What was missing.** A browser cannot put a character above U+00FF in a request header (`Headers` throws a `TypeError` before the request leaves), and it strips leading and trailing spaces. `createLink` accepts any password, so a link whose password has a CJK character or an emoji could not be opened through the header. + - **What is now accepted.** A new companion request header, `X-Share-Password-Encoding`, declares how `X-Share-Password` is encoded. Its one value is `utf-8`, compared case-insensitively. Under it, `X-Share-Password` carries the password's UTF-8 bytes percent-encoded, as `encodeURIComponent(password)` produces them, and both public routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) decode it on both mounts: the sharing plugin's routes and the runtime dispatcher's `/share-links` domain. Both read the pair through one helper, `readSharePasswordHeader`, exported from `@objectstack/types` with the header-name constants. + - **Unchanged.** Without `X-Share-Password-Encoding`, `X-Share-Password` is read raw, exactly as before, so every value a client sends today resolves as it did. That includes a Latin-1 password and a raw password containing `%`; the server never percent-decodes a value nobody declared encoded. The `?password=` query parameter is still read first, and when it is present the header pair is not read. + - **What is refused.** `X-Share-Password-Encoding` naming any other value, or a password header that is not percent-encoded UTF-8 under `utf-8` (a `%` without two hex digits, octets that are not well-formed UTF-8, a character outside visible ASCII), answers `400 VALIDATION_FAILED` before the token is looked up. It is never compared raw instead. The message names the headers and the rule, never the presented value. + - **Response headers.** Both public routes now answer `Vary: X-Share-Password, X-Share-Password-Encoding`, still beside `Cache-Control: no-store`. + - **Cross-origin clients.** `X-Share-Password-Encoding` is in the default CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS` in `@objectstack/plugin-hono-server`, which the `@objectstack/hono` adapter also applies). A deployment that passes its own `allowHeaders` must add `X-Share-Password-Encoding` beside `X-Share-Password` to let a cross-origin client send an encoded password. +- 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) +- 75ced06: fix(plugin-hono-server)!: a request body the adapter cannot parse is refused `400 INVALID_REQUEST` when a route handler reads it, never handed to the handler as `{}` + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The adapter used to build a route's `req.body` as `{}` and overwrite it only when a parse succeeded, so a body it could not parse reached the handler as an empty object. Measured on a fresh `objectstack dev` showcase boot: `POST /api/v1/data/showcase_account/query` with its closing brace missing, with `{ not json`, or with a valid body sent as `text/plain` answered `200` with all 14 rows where the filter matched 2; a truncated `PATCH` answered `200`, changed no field and moved `updated_at`; and a truncated create answered `400 VALIDATION_FAILED` "Account Name is required", for a body that carried a name. + + What changes for an integrator. Each refusal is `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "…" } }`, raised when the handler reads `req.body`, so the handler acts on nothing past that read and nothing is written. The message names the failure and never echoes the body. + + - A body sent as `application/json` (matched case-insensitively) that is not blank and does not parse: FROM the handler reading `{}`, TO the refusal "The request body could not be parsed as JSON (it was sent as application/json). It was refused rather than read as an empty body: send a well-formed JSON document, or no body at all." + - A body in a media type the adapter does not parse (`text/plain`, `application/octet-stream`, any other type, or no `Content-Type` at all) sent to a route that reads `req.body`: FROM `{}`, TO the refusal "The request body was sent as text/plain, which this server does not read into a request body. It was refused rather than read as an empty body: send it as application/json." + - A `multipart/form-data` or `application/x-www-form-urlencoded` body that does not parse: FROM `{}`, TO the refusal naming form data. + + **Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body. + + **Unchanged.** + + - An empty or whitespace-only JSON body is still `{}`, which the REST data doors read as an empty query; a request with no body is still `{}`, whatever it declares. + - Well-formed JSON and well-formed forms reach the handler parsed, as before. + - A route that reads `req.rawBody()` and never `req.body` receives any media type byte for byte, as before: the storage raw upload stores a JSON-with-comments file sent as `application/json` (the type its presign tells the client to send for a `.json` file) and a text file sent as `text/plain`. + - A route, or the unmatched-request fallback, that never reads `req.body` answers as before. + - A body is judged present by its framing headers (`Content-Length` other than `0`, or `Transfer-Encoding`), as RFC 9112 defines it; nothing is read to find out. + +### Patch Changes + +- a45d5d8: fix(auth): a server-side session read no longer renews a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Ten doors read the session in-process (`auth.api.getSession`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, later `GET /api/v1/auth/get-session` calls found a fresh row and re-issued nothing, and the cookie expired first: a dead cookie beside a live bearer, and every cookie-only path then saw a signed-out user. Measured on a fresh dev stack (better-auth 1.7.3, `expiresIn` 604800 s, `updateAge` 86400 s, a session aged to `now + expiresIn − updateAge − 60 s`): `GET /api/v1/data/:object`, `GET /api/v1/auth/me/permissions`, `GET /api/v1/meta/object`, `GET /api/v1/i18n/locales`, `GET /api/v1/packages`, `GET /api/v1/marketplace/install-local` and the MCP door each moved `expires_at` by +86460 s and set no session cookie. + + **The rule now, decided by what the request carries.** + + - **A session cookie** (a browser, including a console that sends its cookie beside its bearer): the in-process read passes `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. Measured after the change: every door above leaves `expires_at` unchanged on a cookie request and sets no cookie. + - **No session cookie** (a bearer-only client: `@objectstack/client` outside a browser, the `os` CLI): unchanged. A data read past `updateAge` still renews the session (+86460 s, measured on the same doors), so an active bearer client keeps sliding forward without calling `get-session`. No cookie is ever set on a response to a request that sent none. + + **Upgrading.** Nothing to change. A browser session renews whenever the app calls `GET /api/v1/auth/get-session`; a tab that never calls it now signs out at the session's real expiry instead of keeping a live bearer beside a dead cookie. + + `@objectstack/types` gains `inProcessSessionReadInput(headers)` (the `getSession` input for an in-process read: the request's own headers, plus `query: { disableRefresh: true }` when they carry a better-auth session cookie), `carriesSessionCookie(headers)` and the `InProcessSessionReadInput` type. A host that calls `auth.api.getSession` itself should read through `inProcessSessionReadInput` for the same reason. + + The same rule is applied to the in-process `auth.api.getSession` readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` by their own changeset. +- 0aef788: `GET /api/v1/auth/me/permissions` answers "no capabilities", not "capabilities unknown", when it fails after the caller is known + + Clause-②: no + + Once the handler knows who the caller is, it has two ways to fail. When the permission-set resolution rejects, it already answered a closed body: `systemPermissions: []`, so every capability gate closes. Any other throw inside the handler (the effective-object merge, or a resolver that throws synchronously) was caught by an outer `catch` that answered `{ authenticated: true, userId, objects: {}, fields: {} }`. That body has no `systemPermissions`, which is the shape a deployment predating ADR-0066 sends, and the console reads it as "capabilities unknown" and fails open on it. So a server-side fault offered authoring handles the server then refused. + + The outer `catch` now answers the closed body, built by the same function the success path uses: + + - `authenticated: true`, the caller's `userId`, and `tenantId` (the session's, or `null`), which this body did not carry before; + - `positions`, `permissionSets` and `systemPermissions` as `[]`, and `objects`, `fields` and `tabPermissions` as `{}`. + + The log line is unchanged: `[hono] /auth/me/permissions failed` at `warn`, once. + + **What does not change.** The success body is byte-identical. The resolver-rejection path still reports the session's positions. A stack with no SecurityPlugin or no metadata service still answers its deliberate open body without `systemPermissions`, since there the server enforces nothing either. A caller with no session still gets `{ authenticated: false }`. No accept set, export or `spec` key moves, and the console needs no change. +- 8ed3786: The `/auth/me` endpoints resolve their caller through `@objectstack/core`'s `resolveRequestExecutionContext`, the one request-to-`ExecutionContext` composition + + Clause-②: no + + - `makeExecutionContextResolver`, which `GET /auth/me/permissions`, `GET /auth/me/localization` and `GET /me/apps` call, resolves the caller by calling `resolveRequestExecutionContext` with this door's differences as named parameters: `anonymous: 'fail-closed'`, `authGate: 'withhold'`, `credentials: 'session-only'`, `sessionApi: 'required'`, `session: 'request'`, no tenancy posture, `localization: 'withhold'`, no request locale, `accessToken: 'withhold'`, and no OAuth provenance or admission channel. The per-request service reads, the environment-kernel resolution and the fail-closed `catch` stay at the door. Every platform door that resolves a request's caller now calls the one composition. + - One answer changes. When the auth service's session read carries `session.userId` but no `user.id` (no user object, a user without an id, or `user.id: null`), these endpoints answered as signed out; they now answer as signed in as that user, which is how the runtime dispatcher, REST, the settings routes and the storage download already read it. No session producer yields that shape: in this repository better-auth's session read returns no session when the session's user is missing and plugin-auth passes its read through, and cloud's control plane uses the same plugin-auth read. + - Every other answer is unchanged: no credential, an unrecognised cookie, a failing session read, an API key alone and a host with no auth service get each route's signed-out body; a session the authentication policy holds is still served on these allow-listed routes, with no `authGate` on its context; an unreadable permission store is still the host's `503 SERVICE_UNAVAILABLE`. + - Nothing to do for a consumer: no export, route, configuration key or response shape changed. +- 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 +- 7428da0: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + refactor(plugin-hono-server,runtime): the Hono doors and the HTTP dispatcher read the duplicate-record wire code off the shared error classification instead of spelling it themselves (#23714) + + **No behaviour change.** A repeated unique value still answers `409 UNIQUE_VIOLATION` at both Hono doors (a `HonoHttpServer` route or raw mount, and `createHonoApp`'s catch arms through `declaredEnvelopeForThrow`) and at the dispatcher's doors (`HttpDispatcher`'s thrown-error envelope), with the same `message`, the same `details` (`developerMessage`, `object`, and `field` when the driver named the column) and the same envelope keys. The answers were compared byte for byte at those doors before and after. A client or an app author has nothing to change. + + **What changed.** Both translations used to spell the wire code as a string literal. They now read it off the classification the REST server answers with (`structuredCodeAnswer` in `@objectstack/types`), as `@objectstack/mcp` already does. So the code has one source, and neither package stamps a code that the error-code ledger does not list under it. The engine's in-process code is unchanged: `DuplicateRecordError.code` is still `DUPLICATE_RECORD`. + + Clause-②: no +- f792290: Clause-②: no + + perf(plugin-auth, plugin-hono-server): an authenticated request reads 8 rows in 4 round trips before its handler, down from 11 in 7, and `/auth/me/*` resolves the caller's grants once + + Measured on a real engine (SQLite through `@objectstack/driver-sql`), for a signed-in user whose session has an active organization: + + | before the handler | before | after | + |:--|:--|:--| + | REST and dispatcher doors (`resolveAuthzContext` with the in-process session read) | 11 reads, 7 round trips | 8 reads, 4 round trips | + | `GET /auth/me/permissions`, `/auth/me/localization`, `/me/apps` | 17 reads, 9 round trips | 8 reads, 4 round trips | + | `GET /api/v1/auth/get-session` | 11 reads, 7 round trips | 10 reads, 6 round trips when it reads the signing key; 8 in 4 while the key is held | + + - **An in-process session read no longer signs a JWT.** better-auth runs `/get-session`'s after-hooks for a server-side `auth.api.getSession(...)` call too, so every door that asked "who is calling" had the jwt plugin read the signing key (two `sys_jwks` round trips) and sign a `set-auth-jwt` header the caller then discarded. The hook now runs only when the call answers an HTTP request. `GET /api/v1/auth/get-session` still carries `set-auth-jwt`, signed exactly as before. A server-side caller that wants the header dispatches the request through `AuthManager.handleRequest`. + - **The session token is not read back after the session was found by it.** `sys_session.token` is withheld from ordinary reads and recovered for better-auth through a privileged read. better-auth finds the session by its token, so that read had nothing to add. It was a second round trip between the session row and the user row on every session read. A read keyed on any other column still recovers the token. + - **`/auth/me/*` and `/me/apps` resolve the grants once.** These routes resolved the caller's grants twice per request: once inside the session read (for the payload's `positions[]`) and once more for their own context. They now resolve through `resolveAuthzContext`, whose request-scoped memo serves the second resolution from the first while no write has landed in between. The memo ends with the request. The routes still admit a session only, and the session's active organization stands as before. + - **An unreadable authorization store is an outage on `/auth/me/*` and `/me/apps`, not a signed-out caller.** Because these routes now authorize through `resolveAuthzContext`, they follow the rule every transport through it follows: a permission-store read that fails answers `503` with the code `SERVICE_UNAVAILABLE` (on `HonoHttpServer`'s error seam) instead of `{ authenticated: false }` or `{ apps: [] }`, which a client cannot tell from an anonymous caller. A store whose tables were never created is not an outage: those routes answer the signed-in caller with no grants, as before. + - **The signing key is held across requests, under two bounds.** The keyring the jwt plugin signs with is kept after a read. Any write to `sys_jwks` that passes this process's engine drops it at once, and it is never kept longer than 30 seconds, which bounds a write this process cannot see (another node minting a key). An engine without a middleware seam keeps nothing. On a host without Ed25519 the keyring is still filtered to keys the host can use. + + Unchanged: the session and user every door resolves, every grant, the `/get-session` body and its renewal, every option and environment variable, and the exports. No new configuration key. The one answer that changes is the outage answer above. +- 2a1e48a: fix(plugin-hono-server): a request for port 0 no longer warns that port 0 is in use + + Clause-②: no (no export, option or type moves: a log line stops firing for a legal request) + + - **What changed.** `HonoServerPlugin`'s `kernel:listening` hook compared the requested port with the bound one and warned `Port 0 is in use, using port N instead` whenever they differed. Port `0` asks for a kernel-assigned port, so the two can never match and nothing was in use. Every `os serve --port 0` boot printed that warning under the banner's Boot diagnostics, at the default log level and at `info`. The warning now fires only for a non-zero request that the adapter moved to another port. + - **Unchanged.** A kernel-assigned boot prints the existing `HTTP server started successfully` line, which already names the bound port, and nothing else. A non-zero request whose port is held still warns, naming the requested and the bound port. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/types@18.0.0 + - @objectstack/observability@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index 0d02ab109c7..cdb8b82a4fe 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Standard Hono Server Adapter for ObjectStack Runtime", "main": "dist/index.js", diff --git a/packages/plugins/plugin-pinyin-search/CHANGELOG.md b/packages/plugins/plugin-pinyin-search/CHANGELOG.md index 443831f9e90..79f607f70a9 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,142 @@ # @objectstack/plugin-pinyin-search +## 18.0.0 + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [f8ef144] +- Updated dependencies [913fd6d] +- Updated dependencies [de43524] +- Updated dependencies [04669a4] +- Updated dependencies [9a7838e] +- Updated dependencies [7879391] +- Updated dependencies [b2e7870] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [66949b7] +- Updated dependencies [4806b18] +- Updated dependencies [c8b062f] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [eebaf3b] +- Updated dependencies [f85a83b] +- Updated dependencies [5cfd866] +- Updated dependencies [c8d06a9] +- Updated dependencies [8f2e808] +- Updated dependencies [fe98cc6] +- Updated dependencies [4578c56] +- Updated dependencies [0b997ea] +- Updated dependencies [a45d5d8] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [e87070e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [3089848] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [440bed6] +- Updated dependencies [35ef501] +- Updated dependencies [da989bb] +- Updated dependencies [018b077] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [87cd458] +- Updated dependencies [1bb1da1] +- Updated dependencies [615cba8] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [51f36e7] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [1e62f71] +- Updated dependencies [038d4c9] +- Updated dependencies [8d8f965] +- Updated dependencies [21914c3] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [4663801] +- Updated dependencies [d294951] +- Updated dependencies [5f37d36] +- Updated dependencies [8464380] +- Updated dependencies [abf8655] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [24e064f] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [1d79556] +- Updated dependencies [dd29bbe] +- Updated dependencies [daafae6] +- Updated dependencies [be48c5e] +- Updated dependencies [989aa48] +- Updated dependencies [a69a40a] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cd86e41] +- Updated dependencies [3bc407d] +- Updated dependencies [76560a1] +- Updated dependencies [abb6df0] +- Updated dependencies [a33f3d5] +- Updated dependencies [d050a8f] +- Updated dependencies [b25d98a] +- Updated dependencies [4ffdebe] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [2ac0616] +- Updated dependencies [2ac0616] +- Updated dependencies [aa31569] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [1bf5f6e] +- Updated dependencies [ec749fb] +- Updated dependencies [fa26308] +- Updated dependencies [62e2b6d] +- Updated dependencies [2f569a9] +- Updated dependencies [69147ab] +- Updated dependencies [9112bcd] +- Updated dependencies [cf16a0d] +- Updated dependencies [cf540a7] +- Updated dependencies [fb0d85b] +- Updated dependencies [45bec43] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/objectql@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index f249f3ba7a5..3b29c8fd1f2 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Pinyin search recall for ObjectStack — populates the hidden `__search` companion column (full pinyin + initials of the display/name field) so `$search` hits CJK names typed as pinyin. Locale-gated via OS_SEARCH_PINYIN_ENABLED (#2486).", "main": "dist/index.js", diff --git a/packages/plugins/plugin-security/CHANGELOG.md b/packages/plugins/plugin-security/CHANGELOG.md index 47d8562049e..b12cdb8b1cd 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,1873 @@ # @objectstack/plugin-security +## 18.0.0 + +### Major Changes + +- 41d0d40: feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING** on a deployment whose `org-scoping` service declares `platformGlobalObjects`. Nothing changes on any other deployment. + + ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." Until now the declaration stood the organization wall down for the object while the object kept its `organization_id` column, so the SQL driver went on scoping a read by the caller's organization that the wall no longer scoped. + + - **The plan** (`@objectstack/spec`): `resolveInjectedSystemColumns(def, deployment?)` takes the deployment's validated `platformGlobalObjects` as an optional second argument. An object it names is planned with no `organization_id`, and nothing else in its plan moves. With no second argument, or an empty list, the plan is the same as before. Author-time callers (the linter, the import mapper, the tenancy census) have no deployment and pass none. + - **The registry** (`@objectstack/objectql`): `ObjectQLPlugin` reads the declaration at the top of `start()`, before the first schema sync, and installs it with the new `SchemaRegistry.setDeploymentPlatformGlobalObjects()`. A declared object is registered with no `organization_id`, no tenant index and `systemFields: { tenant: false }`, and its table is created without the column. An object registered earlier, inside another plugin's `init()`, is re-planned at that moment, before its table exists. The `/meta` read exits serve the same shape. On the way back in, the write path removes the recorded `tenant: false`, so a Studio save stores the body the author wrote. The plugin logs the declared list once at boot. + - **The order** (ADR-0116): every `init()` completes before any `start()`, so a provider that registers `org-scoping` in its `init()` has registered by the time the engine reads it. `OrganizationsPlugin` now declares `providesServices: ['org-scoping']` (`@objectstack/organizations`). A provider that registers the service later, with a different declaration than the one the columns were planned from, fails the boot at `kernel:ready` with an error that names both lists and the fix. + - **The stand-down is retired** (`@objectstack/plugin-security`): `getObjectSecurityMeta` no longer reads `platformGlobalObjects`. A declared object reaches the security layer as its own `systemFields.tenant: false`, the same way every deployment-level object does: Layer 0 composes no organization predicate on it, a wildcard `organization_id` policy does not apply to it, and the ADR-0123 D2 no-active-organization write refusal does not fire on it. The `[security] deployment declares N platform-global object(s)` boot line is gone; the engine logs the list instead. + - **One reader** (`@objectstack/core`): `readDeploymentOrgScopingEntitlement` moved from plugin-security into `@objectstack/core` and is exported there, with its rules unchanged. An absent key declares nothing. A malformed key is refused whole: no object is declared, and the consumer of that key warns once. The engine warns for a malformed `platformGlobalObjects`, and plugin-security for a malformed `suppressUnboundedOrgAdminGrant`. `suppressUnboundedOrgAdminGrant` and its behaviour are unchanged. + + **What moves for consumers.** + + - **On the declaring deployment**, a declared object has no `organization_id`. FROM an authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on that object, TO the same reference with that field removed. A write that still names it is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. The object is governed by object permission, not by the organization wall: a reader the object permission admits reads every row, where the SQL driver used to narrow a read to the caller's organization and the rows with no organization. + - **An object that declares its own `organization_id`** keeps that column, because it is the author's and not the platform's, and the organization wall keeps scoping it. The engine warns once at boot and names the object. Remove the authored field, or drop the object from the declaration. + - **Author-time tools** have no deployment, so they still list `organization_id` among a declared object's columns. Only the declaring deployment differs, and it refuses the name at runtime. + - **Existing databases: nothing moves automatically** (ADR-0131 D14). Schema sync is additive, so on a declaring deployment the physical `organization_id` column stays and the boot drift report names it orphaned. The operator removes it once the rows need nothing from it. No boot step reads or writes it. +- 8311650: `sys_user_permission_set` refuses an assignment whose validity window ends at or before it starts (ADR-0091 D1/D2) + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on the data door, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + - **What is refused.** A write that leaves an assignment with both `valid_from` and `valid_until` set and `valid_until` at or before `valid_from` is refused with `400 VALIDATION_FAILED`, a `rule_violation` at `valid_until`. The window is half-open, `[valid_from, valid_until)`, so such an assignment can never grant anything: the resolver drops it at every evaluation. Until this release it was stored without a word, so an administrator who mistyped a date got an assignment that silently granted nothing. + - **Which writes are judged.** Every insert, from every writer, system writers included, single rows and batches alike. An update is judged by the stored row overlaid with the patch, and only when it moves a bound: by id, and for each matched row of a multi-row update. The refusal is a declared validation rule on the object, `validity_window_order`, so the engine runs it on every one of those write paths. + - **What still lands, unchanged.** A window that ends after it starts, one bound alone (either one), and no window at all. An update that moves neither bound is not judged, so an assignment stored before this release with an inverted window does not block an unrelated edit; the write that repairs it is the only one that has to state a valid window. + - **To fix a refused write**, set `valid_until` later than `valid_from`, or leave one of the two empty for an open-ended window. + +### Minor Changes + +- b2e7870: fix(security): under the `single` tenancy posture the first-user platform-admin grant is owned by the Default Organization, and every reader of platform standing reads that row + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + - **FROM** (`@objectstack/plugin-security`): a `single` deployment holding several organizations, none slugged `default`, promoted its first user with an organization-less `admin_full_access` grant. **TO:** it is not promoted; the bootstrap logs one `error` line, returns `reason: 'default_organization_unresolved'` and writes no grant row. **Fix:** give the intended organization the slug `default` (or keep one organization, or declare a walled posture); the next `kernel:ready` or sign-up pass promotes. + - **FROM** (`@objectstack/plugin-auth`): a `sys_organization` `slug` change or delete was never refused. **TO:** it is refused with `403 PERMISSION_DENIED` when that organization is the Default Organization whose `admin_full_access` grant is the last unbanned administrator's anchor. **Fix:** give another account administrator standing first, then make the change. + - **FROM** (`@objectstack/plugin-auth`): `ensureDefaultOrganization` under `single`, with a sole organization of another slug, created a second, `default`-slugged organization and bound the administrator there. **TO:** it reuses the sole organization; no second organization is created. **Fix:** none needed; an install that wants a `default`-slugged organization re-slugs its existing one or creates it explicitly. + - **Widening** (`@objectstack/core`): under `single` only an organization-less `admin_full_access` grant conferred `PLATFORM_ADMIN`. Now a grant owned by the Default Organization confers it too and applies in every organization; a pre-existing Default-Organization-scoped `admin_full_access` grant gains the rung on upgrade; and `sys_organization` becomes a standing-deriving table (the resolver reads it only under `single` for a holder of an organization-owned `admin_full_access` grant — one or two small reads per uncached request; the last-administrator guard adds three to six small reads per guarded write). + + - **The promotion writes the Default Organization (`@objectstack/plugin-security`).** A `single` deployment with no configuration still promotes its first user that can sign in. The `admin_full_access` grant row that promotion writes now carries the Default Organization as its `organization_id` (ADR-0131 D5), never NULL. The Default Organization is the one with `slug = 'default'`, else the only organization: the organization the auth plugin creates at boot. A deployment that names no Default Organization (several organizations, none with the slug `default`) is not promoted. The bootstrap logs one `error` line saying why and returns `reason: 'default_organization_unresolved'`. It writes no grant without an owner. `OS_PLATFORM_OWNER_EMAIL` is still the walled-posture channel and is not required under `single`. + - **The authorization resolver reads that row (`@objectstack/core`).** Under `single`, `PLATFORM_ADMIN` derives from an `admin_full_access` grant owned by the Default Organization, and still from the organization-less row an earlier release wrote. The anchoring grant applies in whichever organization the user acts in, as the organization-less row always did. A grant of the set owned by any other organization confers the set in that organization, never platform standing. Under a walled posture no grant anchors standing (unchanged). One predicate decides both postures, with the posture as its parameter. The resolver reads `sys_organization` only for a user who holds an organization-owned `admin_full_access` grant under `single`. + - **An upgraded database keeps its administrator.** `os migrate organization-ownership --apply` gives a `single` database's organization-less grants to the Default Organization, the platform admin's grant included. Before this change the resolver then stopped grading that admin `PLATFORM_ADMIN`, and every later boot found no organization-less holder and promoted again. The holder scan now reads the Default Organization's grant, so a migrated database keeps its admin and promotes nobody else. A user who holds both rows counts as one holder. + - **The last-administrator guard and the default-organization bootstrap read it too (`@objectstack/plugin-auth`).** The break-glass guard counts the holder of the Default Organization's grant as a platform admin and treats that grant as evidence that the environment once had one. It also judges the two organization writes that move the anchor off that grant: changing the Default Organization's `slug` while another organization exists, and deleting it. Either is refused when it would leave no administrator who can sign in. `ensureDefaultOrganization` finds the admin through the same grant. Under `single` it also reuses the Default Organization when that is a sole organization with another slug, where it used to create a second, `default`-slugged organization. +- 959c209: feat(plugin-security)!: the six built-in positions are declared position metadata of the plugin (ADR-0131 D2) + + Clause-②: yes (narrowing) + + + + **BREAKING** on one write door, shipped as `minor` under the repo's launch-window convention for breaking changes. + + - **What is declared.** The identity positions `platform_admin`, `org_owner`, `org_admin` and `org_member` (ADR-0068 D2) and the audience anchors `everyone` and `guest` (ADR-0090 D5/D9) are registered with the engine registry as `position` metadata owned by `com.objectstack.plugin-security`, the way the plugin's own permission sets are. Their names, labels and descriptions come from one list built from `@objectstack/spec`'s `BUILTIN_IDENTITY_NAMES` and `BUILTIN_IDENTITY_METADATA` and the anchor text, and the built-in seeder reads that same list. + - **Rows and grants do not move.** `sys_position` is seeded exactly as before: the same names, the same organizations (one copy per organization under a walled posture, one organization-less pass under `single`), `managed_by: 'platform'`, `active: true`, `is_default: false`, and the same labels and descriptions. No principal's grants change. + - **What a client can now read.** `GET /api/v1/meta/position` lists the six beside the stack's own positions, and `GET /api/v1/meta/position/:name` answers `200` with each one's definition, where it answered `404 RESOURCE_NOT_FOUND`. The security catalog read (`createSecurityCatalogReader` in `@objectstack/core`) lists them from the engine registry. + - **What a client can no longer write.** `PUT /api/v1/meta/position/:name` naming one of the six is refused `403 NOT_OVERRIDABLE`: the name is provided by a code package, and `position` has no overlay. It was saved as an environment-wide definition before. Give an authored position a different name. `DELETE /api/v1/meta/position/:name` on one of the six still answers `200` and removes nothing. + - **The declared-positions seeder** keeps reading the engine registry first and the metadata service only when the registry holds no position. The six are left out of that decision and out of what it seeds, so stack-declared positions keep seeding and the six keep the built-in seeder as their one writer. +- aa71c4d: `sys_user_permission_set` gains `permission_set`, the name of the permission set a grant holds, written beside `permission_set_id` (ADR-0131 D4) + + Clause-②: yes (widening) + + - **The column.** `permission_set` is a read-only text column, at most 100 characters, holding the `name` of the `sys_permission_set` row that `permission_set_id` points at. It is readable everywhere the grant row is readable. A grant written before this release has `NULL` here until the backfill stage rewrites it. No reader uses the column yet: the grant is still resolved from `permission_set_id`, which stays until it is dropped in a later major (ADR-0131 D10). + - **The platform writes it, on every write that carries `permission_set_id`, for every caller.** Two `@objectstack/plugin-security` engine hooks (`beforeInsert` and `beforeUpdate` on `sys_user_permission_set`) look up the set by id and store its name. A write that sends only the id, which is how the data door and the Setup forms write, gets the name filled in. + - **A name that names a different set is refused** with `400 VALIDATION_FAILED`, `invalid_value` at `permission_set`. This covers a name that disagrees with the id written beside it, or with the id already stored when only the name is written. For a non-system caller it also covers a name beside an id that names no set this caller's organization can see. A name that agrees is accepted. A cleared name (`null`) is not stored as a clear: the derived name is written back. Before this change the column did not exist, so a write naming it was refused with `400 INVALID_FIELD`. No write that was accepted before is refused now. + - **Every platform grant writer writes both columns:** the organization-admin reconcile and the platform-admin promotion in `@objectstack/plugin-security`, the self-registration grant in `@objectstack/plugin-auth`, and the RLS probe persona in `@objectstack/verify`. + - **Nothing to migrate.** No principal's grants change. To fill the column on grants written by your own code, write the set's name as `permission_set`, or leave it out and the platform fills it in. Do not write any other value there. +- ba8af87: feat(plugin-security)!: under `single`, a position created or edited in Setup is also written to the environment ledger, and positions Setup wrote earlier are backfilled into it once (ADR-0131 D3) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0131 D3 gives positions one home, the environment registry. Under `single`, a position an administrator creates in Setup used to be a `sys_position` row only: no environment definition, so the security catalog read did not find it. Under `single`, every Setup create, edit, rename and delete of a position now also writes the position's definition through the metadata door, at environment scope. + + **What a Setup position write does now, under `single`.** + + - **Create.** The row is written as before, with the same checks: a required label, the reserved built-in names, and one name per organization. The definition `{ name, label, description, delegatable }` is then saved from that row as an environment item, and the security catalog read resolves it at once. `active` and `is_default` stay on the row only. + - **Edit.** The row is updated, and the definition is saved again from it. A patch that touches only `active` or `is_default` writes the row and nothing else. + - **Rename.** The new name's definition is saved, and the old name's definition is deleted. + - **Delete.** The row is deleted, then the definition. If the definition delete fails, an error is logged that names the remedy, because the next boot would bring the position back from the surviving definition. + - **Unchanged.** Under a walled posture, every write behaves as before. System writes (the seeders and the package door) are never translated. On a kernel without a metadata door, the row is written as before. For a position a package or a built-in declares, a Setup edit is written to the row exactly as before, and nothing is written to metadata. + - **No grant changes.** Every reader still reads the row, so a user holding a position is granted exactly what they were granted before. + + **What stops being accepted.** Under `single`, the data door now refuses a create, or a rename into, a position name that the metadata door refuses. The refusal is the metadata door's own: `400 INVALID_REQUEST` for a name outside the item-name grammar (uppercase, a space, a hyphen, a leading digit or underscore), or `422 INVALID_METADATA` for a name `PositionSchema.name` refuses (a single character, a dot). No row is kept. `PositionSchema` already declared such names rejected, and a position named that way could never have a definition. + + - An edit of an existing row that already carries such a name is still accepted. It stays a row write, with no definition. + - **Remedy.** Name the position in lowercase `snake_case`: it starts with a letter, is at least two characters, and holds letters, digits and underscores only. For example, write `sales_manager` instead of `Sales Manager`. Then re-point any assignment that names the old spelling. + + **One more refusal follows from the new definitions.** Positions, permission sets and capabilities hold one name per deployment. Once a Setup position is defined in the environment ledger, a package that registers a position under the same name is refused with `422 NAMESPACE_CONFLICT`, and the refusal names both holders. Before this change, the same registration was accepted. + + **The one-time backfill.** At `kernel:bootstrapped`, under `single`, every position whose name the security catalog read does not resolve gets an environment definition from its row. This covers positions Setup wrote before this release. + + - A name the environment ledger, a package or a built-in already declares is left alone. + - A name the metadata door refuses is not written. It is reported at `warn`, with its remedy, as a final class. + - If two rows of one name disagree, the name is reported at `error` and nothing is written for it. + - When every name is decided, the verdict is recorded in `sys_migration` (`adr-0131-position-environment-backfill`). If a write fails or two rows disagree, the verdict is not recorded, and the next boot runs the pass again. +- 99801d8: feat(plugin-security)!: under `single`, a Setup create of a position, or a rename into a name, that a package or a built-in already holds is refused with the metadata door's own refusal (ADR-0048 addendum N.2/N.3) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Positions hold one name per deployment (ADR-0048 addendum, N.2). A position a package declares, and the platform's built-in positions, are managed items: the metadata door already refused an environment save under their names (`PUT /api/v1/meta/position/:name` answers `403 NOT_OVERRIDABLE`). The Setup data door did not. + + **FROM.** Under `single`, a non-system `POST /api/v1/data/sys_position` whose `name` a package or a built-in holds answered `201`. The new row sat beside the package's (or the platform's) position under the same name. A `PATCH` that renamed an administrator's position into such a name answered `200`, and it also deleted that position's own environment definition. Measured on the showcase: a create of `manager` (package-declared), `everyone` or `guest` (built-in audience anchors) answered `201`, and a rename into `exec` answered `200`. + + **TO.** Under `single`, the same create, or a rename into such a name, is refused with the metadata door's own refusal, relayed as the door builds it: `403 NOT_OVERRIDABLE`. No row is kept, a renamed row keeps its old name, and its definition is untouched. The door's own verdict is asked; nothing is written to environment metadata for such a name. + + - **Unchanged.** The engine's own refusals answer first, as before: a reserved built-in identity name (`platform_admin`, `org_owner`, `org_admin`, `org_member`) stays `400 VALIDATION_FAILED`, and a name already held in the same organization stays `409 UNIQUE_VIOLATION`. An edit that keeps such a name, and a delete, behave as before. System writes, walled postures and kernels without a metadata door behave as before. Under a walled posture, each organization already holds its own rows of the declared and built-in names, so the same create answers `409 UNIQUE_VIOLATION`, as before. + - **Remedy.** Rename the position: give it a name no package and no built-in holds (for example `regional_manager` instead of `manager`), then point any assignment at the new name. `GET /api/v1/meta/position` lists the names already held. +- 2e10c9a: feat(core)!: the authorization resolver reads which permission set a user grant holds from the grant's name, `sys_user_permission_set.permission_set` (ADR-0131 D4) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **What the resolver reads now.** `resolveUserAuthzGrants` (and every surface built on it: `resolveAuthzContext`, `hasPlatformAdminStanding`, the explain engine, `runAs: 'user'` automation) finds a user grant's permission set by the grant's `permission_set` name. The set row is the grant's own organization's row of that name, or else the organization-less row of that name. `permission_set_id` is no longer read for this. Deactivation is still read from the set row. Sets a principal holds through a position are still reached through the position binding's id. + + - **Platform standing.** An unscoped grant is read against the organization-less `admin_full_access` row only. An organization's copy of `admin_full_access` gives that organization's grants its capabilities, never platform standing. + - **Nobody's resolved permissions change** where a grant's name and id agree. The platform's grant writers store both, and the one-time backfill names grants stored before the name column existed. Goldens for the platform administrator, an organization administrator, a member and an agent are unchanged in `single`, `group` and `isolated`. + + **What stops conferring.** + + - **A grant that names nothing.** This is a grant the backfill has not named yet, or one it could not name: its id names no set row, its id names another organization's set row, or the name is not in the security catalog. Such a grant now confers nothing through the resolver. The backfill runs at `kernel:bootstrapped`, before any server opens its socket, so on the first boot after upgrading no request is answered before the grants it can name are named. The grants it cannot name are listed in its boot report. + - **A grant whose id names another organization's set.** Before this change, such a grant conferred that set. It now confers nothing. + - **Remedy.** Read the backfill's boot report, which lists each grant by row id. Re-point each listed grant at a permission set of its own organization, or at an organization-less one, with an update of `permission_set_id`. The platform stamps the name, and the grant confers again. + + **What the grant name hook refuses now** (`@objectstack/plugin-security`). The name is taken only from a set row of the grant's own organization, or from an organization-less one. A tenant-less system writer can read every organization's sets, and it could name an organization-less grant after another organization's set. Now such a grant is stored without a name. A write that supplies that name is refused, from a system writer too, with the hook's usual `400 VALIDATION_FAILED` and `invalid_value` at `permission_set`. An update that re-points a grant at such a set, or moves a grant to an organization its set does not belong to, clears the name. + + **What the last-administrator guard judges now** (`@objectstack/plugin-auth`). `organization_id` on `sys_permission_set` is a standing column. Moving the organization-less `admin_full_access` row into an organization takes away every grant-anchored platform administrator, as deleting it does, so the guard refuses it when it would leave none. An organization's copy of `admin_full_access` no longer counts as the set being in effect. + + **Nothing to migrate** in code or metadata. +- dac99c2: feat(plugin-security)!: the permission-set projection, its seed-name lookup and refusal sink are deleted (ADR-0131 D3, C3 stage 7a) + + Clause-②: no (narrowing) + + + + **BREAKING** (retired exports of `@objectstack/plugin-security`), graded `minor` on the v18 prerelease line. + + Stage 8 stopped registering the `sys_permission_set` row object, the `permission` mutation projector and the data-door write-through. This change deletes the modules behind them: `permission-set-projection`, `seed-name-lookup` and `seed-refusal-sink`. A permission set is written through the metadata door alone (`PUT /api/v1/meta/permission/:name`), and the security catalog serves it by name. + + What a consumer sees: + + - FROM `createPermissionSetWriteThrough(deps)` TO nothing: no `sys_permission_set` data-door write exists to redirect. Write the set through the metadata door. Delete the call. + - FROM `registerPermissionSetProjection(protocol, deps)` TO nothing: no row mirrors a definition. Delete the call. + - FROM `reconcilePermissionSetProjection(...)` TO nothing: there is no projection to reconcile at boot. Delete the call. + - FROM `projectPermissionMutation(...)` TO nothing. Delete the call. + - FROM `upsertEnvPermissionSet(...)` TO `PUT /api/v1/meta/permission/:name`, or the metadata protocol's `saveMetaItem({ type: 'permission', name, item })`. + - FROM `permissionSetBodyFromRow(row)` TO `permissionSetDefinitionFromRow(row)` from `@objectstack/core`: the same mapping, for converting a stored `sys_permission_set` row into its definition. + - FROM `permissionSetRowFields(definition)` TO nothing: no row is written. Delete the call. + - FROM the types `PermissionSeedOutcome`, `ProjectionDeps`, `ProjectionReconcileOutcome` and `WriteThroughDeps` TO nothing. Delete the import. + + Unchanged: the security catalog's echo of a metadata-door permission set or position on an environment kernel, which lives in its own module since #23308. The packaged-permission-set lock still refuses a metadata-door save of a set a code package ships (`403 NOT_OVERRIDABLE`), and the boot still reports environment overlays that shadow such a set. `OverlayDetectionOptions.logger` now asks only for `warn`, the one channel the reading reports on. + + **`@objectstack/spec`** (patch): the `permission.tabPermissions` liveness entry no longer cites the deleted projection, and the liveness README's registered-but-unbound `permission-set-projection` proof row is retired with the invariant it described (no `@proof` tag carries it any more). +- 0253192: A permission-set grant names its set by name: `sys_user_permission_set.permission_set` is the grant's one reference, and `permission_set_id` is no longer written or required + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing on `sys_user_permission_set`, shipped as `minor` under the repo's launch-window convention for breaking changes. ADR-0131 D10's first step: every grant names its set, then the id column goes. + + - **Before.** A grant was written with `permission_set_id`, a required lookup to a `sys_permission_set` row, and the platform derived `permission_set` (read-only) from that row. A write carrying only the id was accepted and named; a supplied name had to agree with the id's row. + - **Now.** `permission_set` is required and writable: it holds the permission set's machine name, and the set is the security catalog's definition of that name (ADR-0131 D3). `permission_set_id` is optional, and nothing writes it or derives anything from it. A grant that names no set is refused `400 VALIDATION_FAILED` (`required` at `permission_set`). A non-system write naming a set the security catalog does not hold is refused `400 VALIDATION_FAILED` (`reference_not_found` at `permission_set`), the same envelope `sys_user_position.position` answers with; a deactivated set is still a set and is accepted. System writes are not judged against the catalog. + - **The platform's own grant writers** (the platform-admin promotion and the organization-admin reconcile) write the name alone and read no `sys_permission_set` row to do it, so a database with no such rows still gets its first platform administrator and its organization administrators. A row id is read only to revoke, or not duplicate, a grant written before the name column existed, which names nothing until the one-time backfill names it. Uninstalling a package revokes the grants that name its sets. + - **Unchanged.** Grants written before this release keep their id and are named by the existing backfill; the resolver and every grant reader already read the name. + + **The remedy.** A caller that wrote `{ user_id, permission_set_id: }` writes `{ user_id, permission_set: '' }`: the `name` of that set, as the metadata door (`GET /api/v1/meta/permission`) lists it. +- bfc15d2: feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4) + + Clause-②: yes + + + + **BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes. + + **FROM → TO.** A position's permission sets were `sys_position_permission_set` rows. They are now declared in `permissionSets` on the position's definition; junction rows no longer grant. + + ```ts + definePosition({ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }); + ``` + + The one-line fix for an app that bound sets to its positions with a boot-time binder: name the sets in `permissionSets` on each position. + + **`@objectstack/spec`.** `PositionSchema` accepts `permissionSets`: a list of permission-set names (lowercase snake_case). Before, the key was refused with a pointer to the junction. Each name is resolved in the environment catalog when a holder's grants are resolved. A name the catalog does not hold confers nothing. + + **`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`): + + - A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. + - A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing — including a row inserted directly (a script or a harness, outside the product's doors). + - **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. + - The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition. + - `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. + - Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. + - An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. + - `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot. + + **`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it). + + **`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A ledger write that switches `admin_full_access` off is refused only when it would leave no administrator who can sign in, and switching it back on is never refused. The guard no longer judges `sys_permission_set` writes, because the resolver no longer reads that row. + + **`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES. + + **`@objectstack/plugin-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row. + + **What stops granting at upgrade, and what to do.** + + - **A deactivation made in Setup.** The Deactivate action on a position or a permission set writes the row's `active` column, which no longer switches anything off. ⚠️ Until the upgrade ceremony converts the rows (`convertDeactivatedCatalogRows`), a position or set deactivated that way **grants again**. No enable/disable door accepts the `permission` or `position` type yet, so a new deactivation has no in-product path. To revoke now, remove the assignments or the binding. + + - **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code. + - **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today. + - **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate organization-ownership --apply`) under `single` only, where its `authored-conversion` step applies `convertPositionBindingRows`. Under a wall (`group`, `isolated`) the ceremony converts no binding (ADR-0131 D3): its `rewrite` step refuses each binding whose position's definition does not name the bound sets, and the ceremony stops there until the operator acts and re-runs it. A position no catalog declares is declared with those sets in `permissionSets` through one door only: the deployment's code, or the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator). A declared position the metadata door can edit takes the missing sets through that door. A definition a package ships is sealed (ADR-0131 D6), and no door on the database adds sets to it. A name that organizations bound differently is reported `conflicting` and is never merged. +- 2ff0825: feat(plugin-security)!: the security plugin's own readers (set resolution, the delegated-administration gate, the permission explainer and the position-assignment refusal) read the security catalog and the activation ledger, not the catalog rows (ADR-0131 D3/D4) + + Clause-②: yes + + + + **BREAKING** (enforcement stops reading `sys_permission_set` rows), shipped as `minor` under the launch-window convention for breaking changes. This is the second step of the cutover that `position-permission-sets-declared` registers: the authorization resolver already reads the catalog; now the security plugin's readers read the same catalog. + + **Permission-set resolution (`@objectstack/plugin-security`).** + + - FROM: a set name the metadata service and the bootstrap sets did not answer was loaded from a `sys_permission_set` row (the caller's organization's, else an organization-less one), its object map and field map included, and dropped when the row's `active` was false. + - TO: it is loaded from the security catalog (the environment registry the security plugin binds to its engine): every package's sets and every set an author saved through the metadata door. A set that only a row carries resolves nothing. The resolver already granted nothing through such a set; now its object and field permissions stop applying too. + - Fix: declare the set through the metadata door (`PUT /api/v1/meta/permission/NAME`) or in a package. + - A permission set that resolves only because a POSITION of the same name was folded into the request is dropped when the activation ledger (`sys_metadata_activation`, type `permission`) switched it off. The row's `active` column is not read. + + **Delegated administration (ADR-0090 D12).** + + - FROM: the sets a position distributes were its `sys_position_permission_set` rows in the caller's organization; `delegatable` was the organization's `sys_position` row's; the assignable-positions listing (`describeDelegableScope`) listed `sys_position` rows; a direct grant's set (and its `adminScope`) was its `sys_permission_set` row. + - TO: all four read the security catalog by name. A position distributes the sets its definition's `permissionSets` names, so the containment check and the listing see a binding declared only on the definition. `delegatable` is the definition's. The listing offers every position the catalog holds. A direct grant's set is the catalog's definition of the name the grant carries; a grant naming a set the catalog does not hold is refused for a delegate (a tenant administrator is not judged). + - A name a position's `permissionSets` carries that the catalog does not hold must be on the delegate's allowlist too. It grants nothing today, but it starts granting as soon as a set of that name is authored. + - A catalog read that fails refuses the delegate's write. Before, it approved it as "distributes nothing". + + **The permission explainer.** "deactivated" is read from the activation ledger through the resolver's own read, so explain and enforce agree. A direct grant whose set the catalog does not hold is reported neither as expired nor as deactivated: there is no set to lose. + + **Position assignments (`sys_user_position.position`).** + + - FROM: refused `400 VALIDATION_FAILED` unless a `sys_position` row (the writer's organization's, or an organization-less one) carried the name. A position declared only in a package or saved only through the metadata door was refused. + - TO: refused unless the security catalog holds a position of that name. A registry-declared position is accepted. A name only a `sys_position` row carries (with no definition) is refused, because it grants nothing. A deactivated position is still accepted. + + **One position name per deployment under `single`.** + + - FROM: a Setup create of a position, or a rename into a name, that an environment definition already held overwrote that definition (another organization's label and description, an empty `permissionSets`). + - TO: refused `409 UNIQUE_VIOLATION`, the answer a second row of the name in one organization already gets, and the row write is undone. This covers a definition another organization's row wrote, one saved through the metadata door, and one an application stack declares. A name a package or a built-in holds keeps its `403 NOT_OVERRIDABLE`. + + **`@objectstack/core`.** `readDisabledCatalogNames(ql, positions, sets)` is exported: the resolver's one read of the activation ledger. It returns the names switched off for this deployment and is now shared by the explainer and the security plugin. +- 149294c: A boot report lists, per organization, every security catalog reference whose name resolves nowhere (ADR-0131 D3/D4) + + Clause-②: no + + ADR-0131 D4 makes every reference to a position or a permission set a name, resolved through the security catalog (the environment registry). A stored reference whose name the catalog does not hold confers nothing, while Setup still lists it. At every boot, at `kernel:bootstrapped` and after the two one-time backfills, `@objectstack/plugin-security` now reads the stored references and logs what the catalog does not resolve. The report writes nothing: no row, no definition, no ledger entry. + + - **Where to read it.** In the boot log, as `[security]` lines at `error`, one line per kind of finding. Each line's metadata lists the organizations (`organizationId`, `null` for organization-less rows) with a count and the names or row ids, at most 50 per list. A clean boot logs one `info` line that says every reference resolves. + - **What it lists, per organization:** + - `sys_user_position` assignments whose `position` the catalog does not resolve; + - `sys_user_permission_set` grants whose `permission_set` the catalog does not resolve; + - `sys_user_permission_set` grants with no `permission_set` at all. The one-time name backfill could not name them: the set row is missing, belongs to another organization, or carries a name the catalog does not resolve (a row-only set). An unnamed grant confers nothing; + - `sys_position` rows whose name the catalog does not resolve, by name and organization. Under a walled posture these are the positions an organization authored in Setup: the catalog is environment-level, so they have no catalog home, and their assignments distribute no permission set. Under `single` these are the row-only positions the row-only backfill could not define, with `refusedNames` naming the ones whose names the metadata door refuses. The backfill reports that class once and records its verdict; this report lists it at every boot. + - **And under `single` only:** position names carried by more than one `sys_position` row (two organizations' rows, or an organization's row beside a package's or a built-in's seeded row). A position name is unique per deployment under `single`, so each is reported as conflicting, never guessed and never merged. + - **What clears a line.** The next boot re-reads everything. A reference whose definition is declared again (its package or its environment definition) leaves the report. So does a walled organization's position once a definition of that name is saved through the metadata door. Otherwise remove the row, or re-point it at a name the catalog holds. + - **A read that did not happen is not a finding.** If a scan or the catalog read fails, the report says which store it could not read, at `warn`, and reports nothing for that boot. + - **The grant-name backfill's warnings now say what such a grant is worth.** A grant the backfill could not name, because its set row's name is not in the catalog or its name write did not land, confers nothing until a later boot names it. The warning used to say these grants still resolved by id. That stopped being true when the authorization resolver began reading grants by name. + - **Nothing to migrate.** No configuration is needed. +- 990341a: feat(plugin-security): the boot no longer writes `sys_position` rows — the built-in and declared position seeders and the `everyone` baseline junction writer are deleted + + Clause-②: no + + `SecurityPlugin` no longer seeds a `sys_position` row for the six built-in positions (`platform_admin`, `org_owner`, `org_admin`, `org_member`, `everyone`, `guest`) or for a position a stack declares, and no longer writes a `sys_position_permission_set` row binding the baseline sets to `everyone`. Positions are served by the security catalog (ADR-0131 D2): the built-ins are this plugin's declarations, a stack's positions are registry items, and `everyone`'s baseline — the app's default set and `member_default`, minus any set the high-privilege check refuses — is that anchor's declared `permissionSets`. `GET /api/v1/meta/position` lists all of them, and assigning a declared position needs no row. + + No authorization decision changes: since the resolver reads position definitions, no grant reads a `sys_position` or `sys_position_permission_set` row, and a fresh database and an existing one grant every principal the same. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. + + FROM → TO: + + - FROM reading the built-in or declared positions from `sys_position` → TO the security catalog read (`createSecurityCatalogReader`) or `GET /api/v1/meta/position`. + - FROM the `everyone` → baseline `sys_position_permission_set` rows → TO the `everyone` definition's `permissionSets`. +- 9646991: feat(plugin-security): the curated platform capabilities are declared `capability` metadata + + Clause-②: no + + `SecurityPlugin` now declares the nine curated platform capabilities (`PLATFORM_CAPABILITIES` in `@objectstack/spec`: `manage_users`, `manage_org_users`, `manage_metadata`, `manage_platform_settings`, `setup.access`, `setup.write`, `studio.access`, `manage_sharing`, `view_all_audit_log`) to the engine registry under its own package id, from that one spec list. The security catalog read (`createSecurityCatalogReader`) and `GET /api/v1/meta/capability` now list them beside the capabilities a stack declares, and `GET /api/v1/meta/capability/:name` answers each one's definition (`name`, `label`, `description`, `scope`) where it answered `404`. A write to one of them at the metadata door is still refused, as before: `capability` is code-only. + + Nothing else changes: the `sys_capability` rows the boot seeds are identical, on a fresh database and on one already seeded, and no capability seeder reports the platform's own declarations. +- 7aa8d51: feat(plugin-security): the boot no longer writes `sys_capability` rows for the platform's curated capabilities + + Clause-②: no + + The curated and derived-default capability seeder (`bootstrapSystemCapabilities`) is deleted. On a fresh database the boot writes no `sys_capability` row for the nine curated platform capabilities (`manage_users`, `manage_org_users`, `manage_metadata`, `manage_platform_settings`, `setup.access`, `setup.write`, `studio.access`, `manage_sharing`, `view_all_audit_log`), and none for a name a permission set grants in `systemPermissions` without declaring it. The curated capabilities are served by the registry: `GET /api/v1/meta/capability` lists them and `GET /api/v1/meta/capability/:name` answers each one's definition, as before. Declare a capability your package grants with `defineCapability` (`stack.capabilities`); a declared capability is still seeded as a package row. + + No authorization decision changes: a grant is still resolved by the capability name in `systemPermissions`, and no runtime check reads a `sys_capability` row. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. + + In `@objectstack/spec` (`PLATFORM_CAPABILITIES`) and `@objectstack/lint` (`validateCapabilityReferences`), the doc comments now name the registry instead of the retired seeder; no behaviour changes there. +- 95a8435: feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted + + Clause-②: no + + `SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served. + + A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run. + + In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `CAPABILITY_NAME_COLLISION` + - `capabilityNameCollisionDiagnostic` + - `formatCapabilityNameCollisionDiagnostic` + - `reportCapabilityNameCollisions` + - `CapabilityNameCollisionDiagnostic` (type) + + FROM → TO: + + - FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`. + - FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place. +- c8a9783: feat(plugin-security)!: no seeder or materializer writes a `sys_permission_set` row — the declared-permission seeder, the platform-default materialization and the `permission` publish materializer are deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `SecurityPlugin` no longer writes a `sys_permission_set` row for any permission set: not for the platform defaults (`admin_full_access`, `member_default`, …), not for a set a package declares, not for an organization's copy, and not on a metadata reload or an organization's creation. Permission sets are served by the security catalog (ADR-0131 D3), and since the grant names its set (`sys_user_permission_set.permission_set`), no grant path needs a row. A fresh database still promotes its first platform administrator, grants organization administrators and self-registered members, and grants every principal what it was granted before. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. Until a later stage retires the ADR-0094 projector, publishing a permission set (a package-door publish, or a writable base's direct publish) still has it write a projection row, `managed_by: 'admin'` with no `package_id` (a package-door publish wrote `managed_by: 'package'` before this release); no authorization path reads it, and uninstalling the package removes it. + + - **Accepted package defaults follow every publish.** The `everyone` anchor is derived again after every landed `permission` mutation (publish, direct save or delete, through any door), so an accepted set whose new body no longer qualifies stops granting at once. Before this release the re-derivation ran only inside the publish materializer, and only when the published body carried an `isDefault` key. + - **`drift_status` and `drift_detail` are gone from `sys_permission_set`**, with the **Needs Attention** list view that listed them. Nothing computes them any more: the declared-versus-enforced diagnostic retired with the rows it compared. A Studio overlay that shadows a packaged set is still named at boot. + - **Uninstalling a package still revokes every grant of its permission sets** (ADR-0090 D5, "no ghost grants"). With no row standing for a package's set, both uninstall doors (`deletePackage` and `DELETE /api/v1/marketplace/install-local/:id`) capture the set names the package held just before they withdraw it from the registry, and the `security.package-permissions` cleanup revokes every `sys_user_permission_set` grant naming them. The withdrawal still runs before the cleanups. A grant naming such a set would otherwise survive the uninstall and be live again on reinstall. Grants written before the name column, and position bindings, are still reached through a legacy `sys_permission_set` row where an already-seeded database holds one. + - `bootstrapPlatformAdmin` no longer takes the `resync` option and no longer reports `seeded`, `resynced` or `resyncSkipped`. A refused read of the legacy `admin_full_access` row (the restrict-only reach to a grant written before the name column existed) withholds the promotion and says so once. + + Added (additive, nothing an author writes changes): + + - `@objectstack/metadata-protocol`: the optional `declared?: UninstallDeclared` argument of `UninstallCleanup` (and of `runUninstallCleanups`' request), the `UninstallDeclared` type, and `captureUninstallDeclared(registry, packageId, stored?)`, the one capture both doors call. A cleanup that ignores `declared` behaves as before. + - `@objectstack/objectql`: `SchemaRegistry.securityCatalogNamesHeldBy(packageId, type)`. + - `@objectstack/plugin-security`: `cleanupPackagePermissions` takes an optional fourth argument, the captured names. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `bootstrapDeclaredPermissions` + - `normalizeManagedByVocab` + - `computePermissionSetDriftDiagnostics`, `persistPermissionSetDriftDiagnostics`, `runPermissionSetDriftDiagnostics`, and the types `PermissionSetDriftStatus`, `PermissionSetDriftDiagnostic`, `DriftDiagnosticsOptions` + - `CAPABILITY_DECLARATION_UNOWNED`, `CAPABILITY_PLATFORM_NAME_REFUSED`, `PERMISSION_SET_DECLARATION_UNOWNED`, `capabilityDeclarationUnownedDiagnostic`, `capabilityPlatformNameRefusedDiagnostic`, `permissionSetDeclarationUnownedDiagnostic`, `reportCapabilityDeclarationUnowned`, `reportCapabilityPlatformNameRefused`, `reportPermissionSetDeclarationUnowned`, and the types `CapabilityDeclarationUnownedDiagnostic`, `CapabilityPlatformNameRefusedDiagnostic`, `PermissionSetDeclarationUnownedDiagnostic` +- 60bcfe7: feat(plugin-security,rest,spec)!: the permission-set "Discard Overlay" action, its REST route and its contract member are retired; no door creates the overlay it discarded, and a database holding one is refused at cold boot (ADR-0131 cutover, ADR-0048) + + Clause-②: no (narrowing) + + + + **BREAKING** — a narrowing of the public surface: one HTTP route, one Setup action and one optional contract member are removed. Shipped as `minor` under the launch-window convention for breaking changes. + + The action deleted the environment-wide `sys_metadata` row that shadowed a package-declared permission set, then re-projected the set's `sys_permission_set` row. Neither half has anything left to do: + + - No door creates such an overlay. The metadata door refuses a save over a packaged permission set on every topology, whatever `OS_METADATA_WRITABLE` says. The data door refuses an edit of one (clone it instead). The data API cannot write `sys_metadata`. A package that declares a name the environment holds is refused at install. + - An overlay stored before those refusals makes the cold boot refuse the deployment with `422 NAMESPACE_CONFLICT`, so a server that could answer the route never holds one. + - The authorization resolver reads the security catalog, not `sys_permission_set` rows, so the re-projected row granted nothing. + + ### What was removed + + | removed | what to use instead | + | --- | --- | + | `POST /api/v1/security/permission-sets/:id/discard-overlay` | `os migrate security-catalog-overlays` with the flags and environment the deployment boots with lists every overlay row with no server running; `--apply` deletes them (a history tombstone per row). | + | the **Discard Overlay** action on `sys_permission_set` in Setup | the same offline step. To keep a customization of a packaged set, clone it under a new name (the **Clone** action) and assign the clone. | + | `ISecurityService.discardPermissionSetOverlay` and `PermissionSetOverlayDiscardResult` (`@objectstack/spec/contracts`) | nothing: delete the call and the import. `tsc` names each one. | + | `discardPermissionSetOverlay`, `PermissionSetNotFoundError`, `PermissionSetOverlayStateError` and the `PermissionSetOverlayDiscardDeps` / `PermissionSetOverlayDiscardResult` types (`@objectstack/plugin-security`) | nothing: delete the import. | + + **The one-line fix:** stop calling the route or the member, and clear any overlay offline with `os migrate security-catalog-overlays --apply` before the first boot on this release. + + `ERROR_CODE_LEDGER['@objectstack/plugin-security']` no longer lists `INVALID_STATE` or `NOT_FOUND`: the removed action was their only producer in this package. Both codes stay registered under `@objectstack/rest`, and the union of codes is unchanged. + + The permission-set drift diagnostic no longer names the action in its `overlay_shadow` detail, and the boot reading of shadowed packaged sets names the offline step instead. +- 66949b7: feat(plugin-security)!: the four security-catalog row objects are retired — `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` (ADR-0131 D3) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Positions, permission sets and capabilities live in the security catalog — the environment registry, fed by package declarations and by environment metadata saved through `/api/v1/meta` (ADR-0131 D3). The four row objects that mirrored them are no longer registered, so their data and REST routes and their Setup object pages are gone. No table is dropped and no row is deleted. + + FROM → TO: + + - `GET|POST|PATCH|DELETE /api/v1/data/sys_permission_set` → `GET|PUT|DELETE /api/v1/meta/permission/:name`; to copy a set, `POST /api/v1/security/permission-sets/:name/clone` with `{ name, label }`. + - `… /api/v1/data/sys_position` → `/api/v1/meta/position/:name`. A position's permission sets are its definition's `permissionSets`. + - `… /api/v1/data/sys_position_permission_set` → the position definition's `permissionSets` (`PUT /api/v1/meta/position/:name`). + - `… /api/v1/data/sys_capability` → read-only `GET /api/v1/meta/capability`; a package declares a capability with `defineCapability` (`stack.capabilities`). + - Setup → Access Control → Positions / Capabilities / Permission Sets now open Setup's catalog lists (`/apps/setup/metadata/?scope=environment`) instead of the object pages; the `sys_position` record page (`SysPositionDetailPage`) is removed. + - An object permission on one of the four names in a permission set grants nothing and can be deleted. + + What else changes: + + - `sys_user_permission_set.permission_set_id` is a plain text column now, not a lookup: same column, same stored ids, same indexes. It is not written and grants nothing; a grant names its set by `permission_set`. + - A permission set saved through the metadata door is written to no row: the `permission` mutation projector, the `sys_permission_set` and `sys_position` data-door write-throughs and the row-only position backfill are no longer registered. + - On a fresh `single`-posture database the first human is promoted to platform administrator without reading any catalog row. An unscoped grant written before `sys_user_permission_set.permission_set` existed (it names no set) still withholds the promotion until the organization-ownership migration names it. + - Uninstalling a package still revokes every grant naming a permission set the package declared, by the names the uninstalling door captures before the withdrawal (`declared`, `captureUninstallDeclared`), and now by those names alone: the cleanup reads and removes no `sys_permission_set` or `sys_position_permission_set` row, so the `sets` and `positionBindings` counts on its outcome are always 0. A grant written before `sys_user_permission_set.permission_set` existed names no set and is not reached; the organization-ownership migration names it. + - On a `group` or `isolated` deployment the data-door half of the walled catalog refusal goes with the objects: a data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability` answers `404 OBJECT_NOT_FOUND` for every caller instead of `403 PERMISSION_DENIED` below the platform operator. The refusal at the `/meta` write doors, the permission-set clone door and the `/packages` write doors is unchanged. + - `capability-reference-unknown` no longer counts a `sys_capability` seed row as a declared capability — no runtime reader consults one. Declare the capability with `defineCapability`. + - On an existing database `os migrate plan`'s unmanaged-table sweep now lists the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` tables: they keep their rows and no object declares them any more. The sweep only reports; nothing is dropped, and the tables stay for the organization-ownership migration. + + Removed exports, no replacement: + + - `@objectstack/plugin-security`: the object definitions `SysPosition`, `SysPermissionSet`, `SysPositionPermissionSet`, `SysCapability` (from `./objects`); `securityObjects` lists the remaining three. + - `@objectstack/platform-objects/pages`: `SysPositionDetailPage`. + - `@objectstack/spec`: `SystemObjectName.POSITION`, `SystemObjectName.PERMISSION_SET`; the four names leave `PLATFORM_OBJECTS_BY_PACKAGE['plugin-security']` and `PLATFORM_PROVIDED_OBJECT_NAMES`. + - `@objectstack/core`: `isRowActive` and `ActivatableRow` — a catalog row's `active` column decides nothing; deactivation is the activation ledger's (`sys_metadata_activation`). + - `@objectstack/objectql`: the published `PLATFORM_OBJECT_TENANCY` record loses its `sys_permission_set` key (the other three names were never in it). A removed key of an exported constant is a narrowing, so `@objectstack/objectql` takes `minor` under the launch-window convention for breaking changes. +- d112087: feat(plugin-security)!: accepting a package's `isDefault` suggestion takes effect for the whole deployment + + Clause-②: yes (narrowing) + + **BREAKING.** A package's `isDefault: true` permission set now reaches every signed-in member through ONE deployment-level acceptance, and the acceptance takes effect. Before this release a confirm wrote a `sys_position_permission_set` row that granted nothing. + + - **What an accept does.** `POST /api/v1/security/suggested-bindings/:id/confirm` records the decision once for the deployment, and the security plugin derives the `everyone` anchor's definition again as the baseline plus every accepted set. The derivation also runs at boot, on uninstall and on a package-door publish. A member's reads of the package's objects change on the next request. `bindingCreated` is `true` when the definition did not name the set before. + - **Who may decide (narrowing).** All three routes now need the `manage_metadata` capability, and under a `group` or `isolated` tenancy posture the platform operator. Before this release a tenant administrator (the `*` wildcard holder) listed, confirmed and dismissed suggestions for their organization. That caller is now refused `403 PERMISSION_DENIED`. Remedy: a `manage_metadata` holder decides, once for the deployment. + - **The record is the deployment's.** `sys_audience_binding_suggestion` rows are owned by the Default Organization. A deployment with no Default Organization (several organizations, none with slug `default`) answers `409 SUGGESTION_STATE` with the remedy. + - **Rows written before this release are never promoted.** A Default Organization row a tenant administrator confirmed or dismissed is set back to `pending`, unless the baseline already names the set. A row another organization owns, or none, is not read. Boot logs a warning with the count. Such rows granted nothing before this release and grant nothing after it. + - **The high-risk gate is on the definition (ADR-0090 D7).** Accepting a set that carries a bit the `everyone` anchor forbids answers `403 PERMISSION_DENIED`. If a publish later adds such a bit to an accepted set, the derivation withholds it and logs an `error` that names the set. + - **Revoke.** `POST …/:id/dismiss` on an accepted suggestion revokes it, and `everyone` is derived again without the set. A set that only the baseline names (`confirmed`, no resolver) cannot be dismissed (`409`). + - **Only `everyone`.** A `guest`-anchor suggestion is refused `409`, because accepting it would record a grant that never takes effect. + - **The anchors stay sealed.** No caller writes `everyone`'s definition. An acceptance changes the derivation's input, and the plugin re-declares the definition. Reconciliation reads that definition and no binding row. + - **Declarations come from the security catalog.** A suggestion is offered for an `isDefault` set that the catalog serves with an owning package, which is the source the authorization resolver reads. The module no longer reads `sys_position`, `sys_permission_set` or `sys_position_permission_set`. It also no longer materializes a set. + - **New field:** `sys_audience_binding_suggestion.deployment_decision`, a boolean that is `true` when a `manage_metadata` holder resolved the row. + + Exported TypeScript surface (the compiler names each caller): + + - Removed: `reapOrganizationLessSuggestions`, `listSuggestionOrganizationIds` and the `SuggestionReconcileScope` type. Nothing replaces them, because the record is no longer per organization. + - `reconcileAudienceBindingSuggestions` and `syncAudienceBindingSuggestions` now take `(ql, logger)`. The metadata, scope and organization parameters are gone. + - `SuggestionReconcileOutcome` reports `unread` in place of `organizations` and `reaped`. `SuggestionSyncOutcome` gains `reopened`. + - `SuggestionDeps` drops `resolveSets` and takes `posture` and `rederiveEveryone`. + - New: `readAcceptedEveryoneSets(ql, metadata)` and the `WithheldAcceptance` type. + + +- 06cc45d: Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). + + **Why.** The catalog is environment-level, and every organization resolves it by name. Measured on a booted `isolated` and `group` stack: an organization owner granted `manage_metadata` inside their own organization saved `PUT /meta/permission/:name` over a permission set another organization's member holds, and that member's next resolution carried the change. One organization's write changed what another organization's members resolve. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for `position` or `permission`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. + - The same doors for `capability`: admitted where they were admitted. `capability` is a code-only type, so these doors already refused its writes before this change; only the sentence the caller reads changes, to the posture refusal. For `capability`, the accept set narrows at the data door on `sys_capability`, below. + - `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. + - A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. This includes an ADR-0090 D12 delegate whose `adminScope` carries `authorEnvironmentSets`: under a wall it can no longer author environment permission sets at the data door. Its assignment and binding paths are untouched. + + **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. + + **The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-tenant-writes-retired`. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. +- c8b062f: feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. + + **What changes.** + + - The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. + - `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. + - `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. + - `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. + - [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling instead: the reads do not decline it, a cold boot refuses a deployment that holds one (ADR-0048), and the offline `os migrate security-catalog-overlays` removes it. + - `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. + - Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. + - The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. + - The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | + | `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | + | `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | + | `SysMetadataRepository.listDrafts` row `organizationId` (`@objectstack/metadata-protocol`) | gone: every listed row is an environment draft, whichever repository asks | + | `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | + | `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | + | `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | + | `organizationId` on `projectPermissionMutation`'s `evt` (`@objectstack/plugin-security`) | drop it: the projection reads the environment → code layered read, whatever organization the event names | + | a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | + | an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | + + **What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. +- 6729e10: feat(plugin-audit,plugin-security)!: the compliance ledger `sys_audit_log` loses its injected organization column; the organization a row is about stays in `tenant_id`, and an organization reader is scoped on it by a platform row policy (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + + Some ledger rows are about deployment-level actions no organization owns: a change of platform-administrator standing at boot, a change to a global setting, plugin-auth's administrative user writes. An injected organization column made the tenant wall the ledger's anchor, so under a walled posture those rows were hidden from every reader, platform administrators included. ADR-0131 D7 takes the column off: the ledger is governed by object permission, and the organization a row is about is the plain attribution field `tenant_id`, which the tenant-field resolver does not claim. + + - **`sys_audit_log`** (`@objectstack/plugin-audit`) declares `systemFields: { tenant: false }`, so the registry injects no `organization_id` and a new table is provisioned without it. `tenant_id` (a lookup to `sys_organization`) is unchanged and is the only organization column. The record mirror, the record-view writer and the sign-in writer stamp it as before and no longer stamp `organization_id`. A write that still names `organization_id` on the ledger is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. + - **The read scope** (`@objectstack/plugin-security`, the shipped permission sets): a platform row policy, `sys_audit_log_org` (`tenant_id == current_user.organization_id`), in `organization_admin` (and its no-bypass variant), `member_default` and `viewer_readonly`. `organization_admin` also names `sys_audit_log` explicitly, read only and without `viewAllRecords` / `modifyAllRecords`: its wildcard's superuser bypass would otherwise skip the policy on an object with no tenant column. Under an organization wall an organization administrator or viewer reads the rows about its active organization; a platform administrator (`admin_full_access`) reads every row, the rows about no organization included. Under `single` the policy is stripped by provenance (ADR-0105 D3), as every platform tenant policy is. + - **`config_change` rows** (`@objectstack/service-settings`): a GLOBAL-scope settings change is about no organization, so its ledger row carries no `tenant_id`, whatever organization the writing session had active. Tenant- and user-scope changes keep the writer's organization. The settings writer and plugin-security's platform-admin standing writer no longer probe for or stamp `organization_id`. + - **Retention** (`@objectstack/objectql`): a tenant-scope `lifecycle.retention_overrides` window on `sys_audit_log` partitions the reaper's and the archiver's passes on `tenant_id`, and the global pass keeps the rows with no `tenant_id`. + - **`view_all_audit_log`**: its description (`@objectstack/spec/security`) now names the row scope it does not lift; the capability still lifts only the parent-record read gate. + + **What moves for consumers.** + + - **Authored references.** A filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` names `tenant_id` instead; the two held the same value on every row a writer wrote. + - **Who reads what, under a wall.** A platform administrator now also reads the rows about no organization, global settings changes included. An organization administrator reads exactly its active organization's rows. Under the `group` posture an organization reader is scoped to the ACTIVE organization's rows, not the union of its memberships. A permission set an application ships that grants `viewAllRecords` on the ledger (directly or through a wildcard) skips the row policy, as it skips Layer 1 on every object the wall does not cover. + - **Who reads what, under `single`.** The row policy is stripped under `single` (ADR-0105 D3), as every platform tenant policy is. So a deployment that holds more than one organization under `single` serves every organization's ledger rows to every ledger reader. The remedy is a walled posture. + - **Existing databases — nothing moves automatically** (ADR-0131 D14). Schema sync is additive: the physical `organization_id` column stays and the boot drift report names it orphaned. The v18 upgrade ceremony confirms its values equal `tenant_id` and drops it (`os migrate apply --allow-destructive`), reporting any row where they differ. +- a3bcbcf: feat(plugin-security)!: a data-engine context that carries no principal and is not a system context is refused (ADR-0096 D5 strict mode) + + Clause-②: no (narrowing) + + + + **BREAKING** for in-process code: an accept-set narrowing of the data engine's security middleware, shipped as `minor` under the launch-window convention for breaking changes. + + **What is refused now.** A data-engine operation whose execution context carries no principal (no user id, no position, no permission set) and is not a system context. That covers a call that passes no context at all, an empty one, one with only a tenant id, and one with only provenance fields. Every layer answers it the same way: + + - the engine middleware throws `PermissionDeniedError`, `403 PERMISSION_DENIED`, for every verb (find, findOne, count, aggregate, insert, update, delete), before the operation runs; + - `canReadObject`, `canExport` and `canWriteObject` answer `false`; + - `getReadFilter` answers the deny filter (zero rows). + + It used to be handed straight through, with no CRUD gate, no row-level security, no field mask and no tenant wall. That contradicted the published contract for an empty tool-execution context, "unauthenticated (RLS-on, sees-nothing)", which this change now keeps. The field projections (`getReadableFields` and its siblings) answer such a context as they answer any caller that resolves no permission set. + + **What is unchanged.** + + - A system context (`isSystem: true`) is admitted everywhere, as before. + - A context that carries a principal without a user id is decided by what it carries: a named permission set, the guest principal, or the public-form grant. + - An unauthenticated HTTP request is answered as before: a door that requires a session answers `401 UNAUTHENTICATED` before the engine is asked anything, and an endpoint an application declared open runs it as the guest principal. + + **What to do.** Code that calls the data engine in-process must state who it acts for. FROM: an engine call with no context, or with a context that names nobody. TO: either + + - pass the caller's execution context, so the caller's own permissions decide; or + - for platform plumbing whose own door already authorized the caller (a store read or write the platform owns), pass the explicit system opt-in, `context: { isSystem: true }`. + + The one-line fix: give every in-process engine call a context, the caller's or `{ isSystem: true }`. The refusal message names both. + + `@objectstack/metadata-protocol`: the protocol's own platform-store reads and writes behind the publish, history, audit, diff, commit, migration and code-only delete doors now pass the explicit system opt-in, like its other store calls. Nothing those doors answer changes. +- c6fe02d: `SecurityPlugin` declares the identity objects its authorization store reads, `sys_user` and `sys_member`, and refuses to boot a kernel that does not register them. The refusal names the missing objects and the plugin that registers them, where the kernel used to boot and then fail every authenticated request's permission read as an outage. + + Clause-②: yes (narrowing) + + + + **BREAKING**: a kernel that booted before is refused at boot, shipped as `minor` under the launch-window convention. + + **Why.** Permission resolution, `resolveUserAuthzGrants` in `@objectstack/core`, reads `sys_user` and `sys_member`, which `@objectstack/plugin-auth` registers. The engine refuses a read of an object its registry does not hold, so a kernel with `SecurityPlugin` and without those objects booted cleanly, and then every authenticated request's permission read failed with `AuthzStoreUnavailableError` (`SERVICE_UNAVAILABLE`, 503), which reads as an outage and names no missing dependency. + + **What is refused.** A kernel where `SecurityPlugin.start()` ran and the engine registry does not hold `sys_user` or `sys_member` at `kernel:ready`: `bootstrap()` rejects with an error named `AuthzIdentityObjectsMissingError`, whose `missingObjects` lists the absent names and whose message names `@objectstack/plugin-auth`, `AuthPlugin` and `createIdentityObjectsPlugin()`. Measured in this repository: three test harnesses that boot `SecurityPlugin` without `AuthPlugin` (one in `@objectstack/runtime`, two in `@objectstack/service-automation`), each now mounting `createIdentityObjectsPlugin()`. By reading the code, not by a run: `objectstack dev` through `DevPlugin` with `services: { auth: false }` and security left on is refused too. + + **What still boots, unchanged.** A kernel that mounts `AuthPlugin` (`os serve` pairs the two; `@objectstack/verify`'s `bootStack` mounts both); a kernel that registers the two objects any other way; and a boot that composes `SecurityPlugin` for its declarations only (`os migrate`, which suppresses `start()`). + + **The remedy.** on a kernel that mounts `SecurityPlugin` without `AuthPlugin`, such as a test kit, add `await kernel.use(createIdentityObjectsPlugin())` from `@objectstack/plugin-auth`. It registers plugin-auth's own identity list, so a hand-written plugin that registers `SysUser` and `SysMember` is no longer needed, though it still satisfies the check. A `DevPlugin` stack with `services: { auth: false }` either turns security off as well or passes `createIdentityObjectsPlugin()` in `extraPlugins`. Nothing in an app's metadata changes. +- ffb31fc: fix(plugin-security)!: `granted_by` on the grant tables is provenance: the platform stamps the writer, and a non-system caller can no longer name the granter + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `granted_by` records who wrote a grant. The platform already treated it that way: the delegated-admin gate stamped the writer, and the organization-admin reconcile writes the attributed human or null (ADR-0118 D1, an id or null and never a sentinel). But the column was a plain lookup, so a non-system caller could store any existing user as the granter, and the integrity audit filed a granter that no longer resolves as a broken business reference, which raised its `[integrity] stored references that resolve to nothing` warning on every run. + + **What moves for consumers.** + + - **Non-system inserts store the writer.** The delegated-admin gate now stamps the caller's user id into `granted_by` on every `sys_user_permission_set` and `sys_user_position` insert it admits, whatever the payload carried. A tenant-level admin's insert and a delegate's insert are stamped alike. Before, a supplied value was stored as sent, and a tenant-level admin's insert that supplied none stored `null`. + - **Non-system updates cannot change it.** Both columns are now `readonly: true`, so the engine's update strip drops a caller's `granted_by` and the row keeps its granter. Before, the update landed. A caller that passes `strictReadonlyWrites` is refused instead, as for every read-only column. + - **The integrity audit files it as provenance.** A `granted_by` that names a user who no longer exists is reported under the audit's `provenance` bucket, which does not raise the warning. A business reference that resolves to nothing is reported under `dangling` and warns, as before. + + **Unchanged.** + + - System-context writers keep the value they write: invitation placement stamps the issuer, the organization-admin reconcile stamps the attributed human or `null`, and the platform-admin promotion writes `null`. A server-side writer that must record someone other than its caller writes under a system context, as these do. + - Who may write a grant, and what a grant gives, are unchanged. The gate's authority decision runs first; a refused write is not stamped. + - Stored rows are not rewritten, and no stored row is refused on read. A row whose `granted_by` already names someone else, or holds a value that names no user, keeps it. + - `sys_record_share.granted_by` (plugin-sharing) is unchanged: its only writer is the sharing service, which already stamps the acting user under a system context. +- 81bd9fa: fix(plugin-security)!: a position assignment or permission-set grant scoped to an organization must name a member of that organization + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **What stops being accepted.** A `sys_user_position` or `sys_user_permission_set` row whose `organization_id` is set may name only a user who holds a `sys_member` row in that organization. A non-system insert or update that would store any other user is refused with `400 VALIDATION_FAILED`, one `fields[]` entry at `user_id` with `code: reference_not_found` — the envelope a `user_id` naming no user already receives. No new error code. It applies to every non-system caller, platform administrators included, and to every engine write: `POST` / `PATCH /api/v1/data/...`, batch inserts, predicate updates, and scripts and flows that run as a user or a service principal. The organization judged is the one the row is stored with: its own `organization_id`, or, when it names none, the caller's active organization stamped at write time. + + **What stays accepted.** + + - System-context writes: seed replay, invitation acceptance, the organization-admin reconcile and the platform bootstraps. + - A row stored with no organization: a global grant names no organization to be a member of. + - An update that changes neither `user_id` nor `organization_id`, so a stored row whose holder has since left the organization stays editable and can be end-dated. + - A stock `single`-posture deployment: under the default `auto` membership policy every user is a member of the default organization, so every assignment there names a member. + + **What changes for you.** Add the user to the organization first, then assign the position or grant the permission set. A script that assigned a grant before the user joined now receives the `400` above; reorder it. +- f2626c7: fix(plugin-security)!: the Layer 0 tenant write wall refuses a non-system update that would leave a row with no organization + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **What stops being accepted.** Under a walled tenancy posture (`isolated` or `group`), on an object the Layer 0 tenant wall covers, a non-system update that would leave the row with no organization is refused with `403 PERMISSION_DENIED`. That holds for the value the caller sends and for the value the stored row ends up with after the `beforeUpdate` hooks, on the by-id and the predicate update paths alike, and for every non-system caller the wall covers, platform administrators on public tenant objects included. The rule the wall already stated for an update, that a row's organization stays one the caller's organization scope admits, now covers "no organization" as well as "another organization". An organization-less row is not inert: on an ordinary tenant object it leaves every reader's scope, and on the organization-scoped grant tables (`sys_user_position`, `sys_user_permission_set`) it is read as a grant that applies in every organization. + + **What stays accepted.** + + - An update that does not touch `organization_id`, and one that keeps the row's own organization. + - System-context writes (seed replay, imports, migrations, platform bootstraps). + - A platform administrator on an object whose posture permits crossing the wall, as before. + - The `single` posture, where no tenant wall applies. + - Inserts: an absent or empty organization on an insert is still filled with the caller's active organization. + + **What changes for you.** A script or flow that cleared a row's organization through a user or service-principal context now receives the `403` above. Keep the row in its organization; if a row genuinely must carry no organization (a global grant, for example), write it from a system context. +- 00bee27: fix(plugin-security)!: a definition edit or delete of a position a code package declares is refused at the data door, as the metadata door already refuses it; its row now carries package provenance, and its row state stays switchable + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing of the `sys_position` data door, shipped as `minor` under the launch-window convention for breaking changes. It carries ADR-0131 D6 (no door edits a managed definition) and D3 (managed items are read-only and clonable). + + **What was wrong.** The declared-position seeder wrote the row of a package-declared position with no provenance stamp, so the row carried the object default, `managed_by: 'admin'`, and the system-row write gate did not protect it. A Setup or data API edit of its label answered `200`, and the next boot wrote the declared label back over it with no message. The metadata door already refused the same edit with `403 NOT_OVERRIDABLE`. + + **What is refused now.** A position is package-declared when the engine registry's artifact lookup answers a code package for its name, the same lookup the metadata door refuses a save from. On such a position's row, the admin door now answers `403 PERMISSION_DENIED`, with the message it already gives for a built-in position, to: + + - an update that touches a definition column (`name`, `label`, `description`, `delegatable`) or any column other than the two row-state columns below. That covers single-record edits, `updateMany`, and filtered updates. Until now a label or description edit answered `200` and the next boot wrote the declaration back over it; a `delegatable` edit answered `200` and persisted. + - a delete. Until now it answered `200` and the next boot created the row again. The other lifecycle writes (transfer, restore, purge) are refused as well. + + **What stays switchable.** A patch that touches only row state (`active` and/or `is_default`) still passes, so Setup's Activate, Deactivate and Set as Default keep working on a package-declared position and persist across restarts. This is the rule a packaged permission set already follows: switching it off is not an edit of its definition. A filtered update passes on the same terms, as long as its filter reaches no built-in position. + + System-context writes are unchanged: the boot seeder still refreshes the row's label and description from the declaration. + + **What is unchanged.** + + - A position an administrator created in Setup, and a position the environment authored through the metadata door, keep an unmanaged row (`admin`) and stay editable. + - The built-in positions are refused exactly as before, a bare `active` or `is_default` patch included. + - Assigning a position to users and binding permission sets to it are writes on other rows (`sys_user_position`, `sys_position_permission_set`), and this change does not touch them. + + **At the first boot after upgrading.** The existing row of each package-declared position is re-stamped `package` in place when it carries `admin`, no value, or the legacy `user`. Only `managed_by` changes: the `active`, `is_default` and `delegatable` values an administrator set before the upgrade are kept. The boot's `declared positions seeded` info line counts these rows as `restampedPackageProvenance`. The seeder matches a row by name, as its label refresh always has, so a position created in Setup whose name a package declares later becomes that package's position: its definition is refused the same way, and its row state stays switchable. + + **What to do.** To change a package-declared position's label, description or `delegatable`, change it in the package and publish a new version. To have a position you can edit in Setup, clone it under a new name with the Clone Position action, bind its permission sets, and assign the clone. +- ce3d0ad: The registered `security` service serves `checkControlledByParentWrite(object, recordId, context)`, the ADR-0055 master-detail write check that `ISecurityService` declares as optional. + + - **What it answers.** It answers what a by-id update of the record gets from the master-detail write check. It runs the write path's own composition and never re-derives it: the engine middleware's context prologue (a principal-less context, permission sets that cannot be resolved and a missing on-behalf-of delegator are refused with the same `403 PERMISSION_DENIED`), and then the check for the principal's permission sets and, on a delegated context, for the delegator's (ADR-0090 D10). The first refusal is the answer. A refusal resolves `deny` with the leg it came from, the three non-verdicts resolve `unresolvable` with their reason, and an object that is not `controlled_by_parent` resolves `not_applicable`. A system context resolves `allow`. A store fault rejects with the engine's own error, so a datasource outage keeps its `503`. + - **Where it differs from the write path's guard.** A principal whose permission sets resolve to none is judged over that empty list, so it answers `deny` on `object_permission` as the contract declares. A principal with positions or sets and no user id is also judged, where the write path skips the check. It answers `deny` when it holds no `update` grant on the master, and its own update is refused by the object-level gate anyway. + - **Who asks it.** The `sys_attachment` parent gate in `@objectstack/service-storage` and the `sys_comment` moderation gate in `@objectstack/plugin-audit` ask it, so they judge a `controlled_by_parent` parent through its master. + - The boot line that announces the `security` service now lists the members the registered service actually serves, read from the service object. The hand-kept list it replaced had fallen behind by five members. + + Nothing an author writes changes, and no by-id write is judged differently. The middleware's context prologue moved into one method that the middleware and the member both call. Its refusals, their order and their wording are unchanged. +- d303b3e: `security/explain` answers an update of a `controlled_by_parent` record the way that record's own by-id update is answered: its record verdict now comes from the master-detail write check (ADR-0055), which it never asked. + + - **What was wrong.** On such a record the sharing service's per-record gate abstains, because the effective model of `controlled_by_parent` is `public`, and explain read the abstention as "writable". It also kept the platform ownership floor (`owner_only_writes`) on the record, which the write path hands over to the master-detail write check. So `record.visible` for an update was decided by that floor (`decidedBy: 'rls'`) for every principal who did not create the record. That was refused, on the wrong layer and with no word about the master, for a principal who may not edit the master. It was also refused for a principal who may edit the master, beside a `PATCH` that answers `200`. + - **What it does now.** For every update of a record that exists, explain asks the security service's `checkControlledByParentWrite` with the context it is explaining (another user's, when `userId` names one). A `deny` or `unresolvable` outcome makes the record not writable, decided by the `sharing` layer, whose record attribution names the refusing leg (`object_permission`, `row_level_security`, `record_sharing` or `master_chain`) or the reason the check reached no verdict. A rejection of the check is reported fail-closed, as a dependency that throws already is. `allow` and `not_applicable` leave the report exactly as before. The update's row-level composition carries the write path's own master-gate coverage vouch, so the ownership floor is handed over to that check as the write path hands it, and never on behalf of a delegator. + - **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field, which answers whether the principal may update the object at all. `delete` is unchanged too: explain asks no master check for it and keeps its floor. + - **Public surface.** `ExplainEngineDeps` gains the optional `checkControlledByParentWrite` dependency. A deps bag wired without it explains an update as before, and claims nothing about a master. +- ad14957: fix(metadata-protocol): a create under a name a managed package or a built-in holds is told to choose another name, not to edit the source artifact (#22591) + + Clause-②: yes + + The public input surface widens: `packagedBaseRefusal` accepts `operation: 'create'`, and `tenantAuthoredWriteRefusal` takes an optional operation; no verdict, code or status moves. + + A write onto an item a managed package ships, on a type with no environment overlay, is refused with `403 NOT_OVERRIDABLE`. The refusal's remedy used to assume the write was an edit of that item: "Edit the source artifact and redeploy", or, for a flow, an action or a permission set, its clone or on/off switch. That remedy is wrong for a create. An administrator who creates a position of their own under a name a package or a built-in already holds (`manager`, `everyone`, `guest`, …), or renames one into such a name, cannot edit a source artifact they did not write, and did not ask to customize the package's item. + + The remedy now follows what the write did: + + - **A create, or a rename into the name**, is told: the name is taken; to author your own item, choose a name no package or built-in holds, and `GET /api/v1/meta/:type` lists the names in use. This is one sentence for every metadata type. + - **An edit** keeps the sentence it had, byte for byte. + + Which writes are refused does not change, and neither do the code and the status. + + Where the remedy is a create's: + + - **Setup** (`@objectstack/plugin-security`): a create of a `sys_position` row, or a rename into a name a package or a built-in holds, under the `single` posture. The data door relays the metadata door's refusal for a create. + - **The metadata door** (`PUT /api/v1/meta/:type/:name`): a save sent with `If-None-Match: *`, the first-write precondition (`parentVersion: null` in `saveMetaItem`). Any other save is an edit, as before. + - **`ObjectStackProtocolImplementation.packagedBaseRefusal`** takes `operation: 'create'` beside `'save'` and `'delete'`, for a door that knows its write takes a name. The verdict is the `'save'` verdict. `tenantAuthoredWriteRefusal` takes an optional `operation: 'create'` the same way. +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. +- 76815b9: feat(spec)!: a `controlled_by_parent` object derives its master from exactly one required `master_detail` — the parse refuses anything else, and the runtime's two fallback tiers retire (#23100) + + Clause-②: no (narrowing) + + ADR-0055 §1 says a `controlled_by_parent` object "must declare exactly one required `master_detail` field … Validation error otherwise (fail closed)". Until now neither the parse nor the runtime kept it. `ObjectSchema` accepted such an object with no master at all. The security runtime derived a master through a three-tier fallback the record never stated: a required `master_detail`, then any `master_detail`, then a required `lookup`, taking the first match by field order within a tier. The maintainer ruling (letter B) enforces the record, under ADR-0049 (enforce-or-remove). + + **BREAKING — what moves for consumers.** + + - **`@objectstack/spec`.** `ObjectSchema` (raw `.parse()` / `.safeParse()`, `ObjectSchema.create()`, `defineStack`, `os validate` and the metadata save door) refuses an object declaring `sharingModel: 'controlled_by_parent'` unless exactly one of its fields is a `master_detail` declared `required: true`. With none, the issue sits at `sharingModel` and names each field that relied on a retired tier. With two or more, it sits at `fields` and names them. A plain object literal is judged like a raw parse: an omitted `required` on its `master_detail` is refused, while `ObjectSchema.create()` keeps forcing an omitted `required` to `true`. The step-18 semantic migration entry `cbp-master-relation-tiers-retired` carries the prescription for `os migrate meta`. The step-18 conversion `cbp-master-detail-required-defaulted` covers the one lossless shape: a `controlled_by_parent` object whose one `master_detail` omits `required`, which Studio's field designer writes. It gets `required: true`, exactly what `ObjectSchema.create()` does. It runs on stored rows, built artifacts and `os migrate meta --from 17`. A live authoring parse still refuses the shape and names the fix. + - **`@objectstack/plugin-security`.** The relation resolver derives a master from that one field and nothing else. An object that declares a non-required `master_detail` only, a required `lookup` only, or two required `master_detail` fields resolves no master. Every read of it is denied, and every write and insert is refused with `422 INVALID_METADATA`. The plugin reports such an object once per object (`[security/controlled-by-parent] … resolves no master relation`), naming the shape it relied on. The `422` message now says "not exactly one required master_detail field". + - **`@objectstack/lint`.** `security-controlled-by-parent-no-relation` now refuses the non-required `master_detail` and required `lookup` shapes as well. `security-controlled-by-parent-ambiguous-relation` reports two or more required `master_detail` fields as "no master is derived", since the runtime no longer picks one by field order. Two required `lookup` fields are now a no-relation object, not a tie. `security-master-detail-ungranted` no longer treats a lookup-bound `controlled_by_parent` object as a detail. + + **FROM → TO — HotCRM's three objects.** These are the only producers measured on the retired third tier: the read-only HotCRM clone at `0f84c9c`, 8 `controlled_by_parent` objects, 5 of them already on one required `master_detail`. HotCRM's pinned engine is unaffected until it bumps past the release that carries this change. + + | object | FROM | TO | + |---|---|---| + | `crm_campaign_member` | `crm_campaign: Field.lookup('crm_campaign', { required: true })` | `crm_campaign: Field.masterDetail('crm_campaign', { required: true, deleteBehavior: 'restrict' })` | + | `crm_event_attendee` | `crm_event: Field.lookup('crm_event', { required: true })` | `crm_event: Field.masterDetail('crm_event', { required: true, deleteBehavior: 'restrict' })` | + | `crm_article_feedback` | `crm_knowledge_article: Field.lookup('crm_knowledge_article', { required: true, deleteBehavior: 'cascade' })` | `crm_knowledge_article: Field.masterDetail('crm_knowledge_article', { required: true, deleteBehavior: 'cascade' })` | + + `deleteBehavior: 'restrict'` keeps what the two lookups do today: a campaign or an event with members or attendees cannot be deleted. Without it a `master_detail` cascades, and a detail's rows are deleted with their master. An object whose rows must outlive their parent is not a detail. It leaves `controlled_by_parent` for `private`, `public_read` or `public_read_write`. + + **Remedy, in one line.** On every object with `sharingModel: 'controlled_by_parent'`, declare exactly one `master_detail` field with `required: true`, pointing at the master. Every other parent is a `lookup`. + + **Unchanged.** + + - An object with one required `master_detail` resolves and derives exactly as before, chain walk included. So does a required `master_detail` beside other `lookup` fields, or beside a `master_detail` that is not required. Every in-tree `controlled_by_parent` object is in that set: `examples/app-crm` (1), `examples/app-showcase` (2), the CLI's eval corpus (4) and the dogfood fixtures. + - Stored metadata whose one `master_detail` merely omits `required` keeps serving, converted as above. A stored row in any other retired shape is not rewritten: it still registers, under its `[metadata_spec_invalid]` diagnostic, and is denied until it is corrected and saved. Those shapes are an explicit `required: false`, two masters, or a required `lookup`. None of them is converted: turning a `lookup` into a `master_detail` changes what deleting the master does to every row, choosing between two masters is the pick ADR-0055 §1 refuses, and leaving `controlled_by_parent` is the author's decision. + - No export, signature or error code is added or removed. + + +- e790cfc: feat(security)!: an anonymous caller never owns a record and never sets its owner — the owner-anchor stamp's guest branch (ADR-0138 E3, D1) + + Clause-②: no (narrowing) — a guest-supplied owner is refused at the endpoints and the owner column never holds the guest or the system principal. That tightens what a guest write may carry and admits nothing refused today. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0138 D1 rules that the guest is a principal, never an owner. The owner-anchor stamp in `SecurityPlugin` (the step that stamps an absent `owner_id` with the acting user and refuses a forged one) now carries the guest branch the record names as D1's enforcement point. It applies to the guest envelope, an anonymous request served by an `authRequired: false` endpoint: + + - **Nothing is stamped for a guest.** An absent owner stays unset: never the guest, never the system principal. An empty `owner_id` (an empty string) is left unset too, where it used to be stored as an empty string. + - **A guest-supplied owner is refused**, on create and on update, with `PERMISSION_DENIED` (`403`), whatever the guest is granted. The transfer grant is never consulted for a guest. Before this change a guest whose bound permission set carried the transfer bit could create a row owned by any user, the system principal included. The anchor tier already refuses that bit on a guest binding, so no binding a deployment can make today reached it, and this branch does not depend on the tier. Without the transfer bit the refusal already happened before this change; its message now says that no grant admits a guest's owner, instead of naming the transfer grant as the remedy. + - **An owner the scenario sets stands.** A `beforeInsert` hook runs after the stamp, inside the engine's operation, and a record-change flow writes as itself, so neither is mistaken for a value the guest sent. + + Unchanged: the public form doors, which strip a submitted owner and leave it unset (`PUBLIC_FORM_SERVER_MANAGED_FIELDS`); every signed-in caller, whose absent owner is still stamped with its own id and whose foreign owner still needs the transfer grant; and system writes. + + What a guest client does: leave `owner_id` out of an anonymous write. The object's own hooks or record-change flows decide who owns the row, and the row stays unowned when none does. +- 14a13bc: fix(metadata-protocol)!: the metadata save, restore and publish doors refuse a permission set whose body `packageId` disagrees with the package it is saved into, and the audience-binding suggestion reader takes a set's owner from its binding only (ADR-0086 D3) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0086 D3 declares `PermissionSetSchema.packageId` as the owning package of a package-shipped set, absent for an environment-authored one. The owner every reader uses is the package the set is saved into: the stored row's `package_id`, served as `_packageId`. A body `packageId` that named another package was accepted and then silently overridden, so an author, an AI build above all, never learned that the owner it wrote was not the owner it got. + + **FROM.** A permission set saved and published into package `app.crm` with `packageId: 'app.other'` in its body answered `200` and `published`, and was owned by `app.crm` everywhere it was read. A set saved into no package with `packageId: 'app.other'` was stored as an environment-authored set, while the audience-binding suggestion reader offered it, accepted it and derived it into `everyone` as `app.other`'s. + + **TO.** + + - **`@objectstack/metadata-protocol`.** The save door (`PUT /api/v1/meta/permission/:name`, draft and publish mode, and every in-process caller) the restore doors (`POST /api/v1/meta/permission/:name/rollback` and a commit revert's restore limb) and the draft promotion (`POST /api/v1/meta/permission/:name/publish` and the package publish) refuse a permission set whose body `packageId` is not the package the row is written into, with `400 VALIDATION_ERROR`, the code and status the doors already answer for a body `name` that disagrees with its row. `issues[]` carries the key at `packageId`. A body `packageId` on a set saved into no package is refused too, the empty string included: a present key declares a package-shipped set. The message names both values and both remedies. The package judged is the one the write lands in: a draft saved without naming a package lands in the package of the item's active row, and is judged against it. A package publish that meets such a draft (one stored before this change) aborts the whole batch, `outcome: 'refused'`, with the draft in `failed[]` under `VALIDATION_ERROR`. A rollback to a stored version that carries such a body (one written before this change) is refused, and a commit revert reports it in `failed[]` under the same code; the remedy there is to save the set again with `packageId` dropped or set to its package, instead of restoring that version. Nothing is written, published or restored. Package duplication copies a set whose body names the source package as naming the target package. `os migrate meta --stored --apply` records a stored set it rewrites, and the door refuses, as that row's failure. + - **`@objectstack/plugin-security`.** The audience-binding suggestion reader takes a set's owner from its binding only, never from its body's `packageId`. A set bound to no package has no owner there, so no suggestion is created for it. An acceptance recorded under the package its body named is withheld from `everyone` at the next derivation. The withholding is reported at `warn`, naming the set and its reason. Accepting a pending suggestion of that kind is refused. + - **`@objectstack/spec`.** The `PermissionSetSchema.packageId` description states the refusal. The step-18 semantic migration entry `permission-set-package-id-binding-mismatch-refused` carries the upgrade sweep. + + **Unchanged.** A body `packageId` equal to the package the set is saved into is accepted, and an absent one is accepted everywhere. Stored rows are not rewritten. A stored set whose body disagrees with its binding keeps loading and keeps reading as its binding, and is refused on its next save or publish, as is a restore of a stored version that carries one. An installed package's own sets are owned by the package that ships them, as before. + + **Remedy, in one line.** Drop `packageId` from the permission set's body, or save the set into the package it names (`?package=` on the REST door, `packageId` in process). +- eeb95f7: fix(spec,objectql)!: a permission set whose body `packageId` disagrees with the package that ships it is refused at `defineStack` / `os validate` / `os build` and at package registration, and every reader takes a set's owner from its binding only (ADR-0086 D3) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing on a published authoring surface), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0086 D3 declares `PermissionSetSchema.packageId` as the owning package of a package-shipped set, absent for an environment-authored one. The owner every reader uses is the binding: the package that ships the set or that it is saved into, served as `_packageId`. The metadata save, restore and publish doors already refuse a body `packageId` that disagrees with it. Package source and package registration did not: a set whose body named another package was registered under the package that shipped it, with no refusal and no warning, so an author, an AI build above all, never learned that the owner it wrote was not the owner it got. + + **What is refused, and where.** One judge, `judgePermissionSetOwnerBinding` (new export of `@objectstack/spec/security`), serves every door: + + - **The stack schema** (`ObjectStackDefinitionSchema`), so `defineStack`, `objectstack validate`, `objectstack build`, the lint scorer and an artifact load all refuse a set whose body `packageId` is not the id of the package that ships it: the stack's `manifest.id`, each `packages[]` body's own `id`, and `manifest.id` for a top-level set no body declares. A stack with no `manifest` ships its sets from no package, so any key there is refused, the empty string included. The issue sits at the set's `packageId` (`permissions.0.packageId`); `defineStack` throws `STACK_SCHEMA_INVALID` (422) carrying it. + - **Package registration** (`ObjectQL.registerApp`, the manifest and a nested plugin, for a manifest no schema parse judged) throws `400 VALIDATION_ERROR` with `issues[]` at `packageId` before the set is filed, the envelope the same seam throws for a view container whose `name` disagrees with its key. + + The message prescribes first, names both values, and offers both remedies: drop the key, or ship the set from the package it names. No error code is minted. + + **Readers.** `@objectstack/plugin-security`'s packaged-set lock (`classifyPackagedPermissionSet`) and `@objectstack/cli`'s permission-set name-collision report take a set's package from its binding only. A set filed into no package whose body named one is no longer locked as that package's, and a stack with no `manifest` declares no owner for the collision report. + + **Unchanged.** A body `packageId` equal to the package that ships the set is accepted, and an absent one is accepted everywhere. A top-level copy of a set a `packages[]` body declares (an artifact built with both halves) is judged on its body, not against the artifact's own id. The metadata save, restore and publish doors answer in the same words as before (`@objectstack/metadata-protocol` now calls the shared judge). Stored `sys_metadata` rows are not rewritten. + + ## FROM → TO + + | door | FROM | TO | + |:--|:--|:--| + | `defineStack` / `objectstack validate` / `objectstack build` | `defineStack({ manifest: { id: 'com.acme.crm', … }, permissions: [{ name: 'crm_member', packageId: 'com.acme.other', … }] })` accepted; the set was owned by `com.acme.crm` | refused at `permissions.0.packageId`, `STACK_SCHEMA_INVALID` 422 | + | `ObjectQL.registerApp` | the same manifest registered the set under `com.acme.crm`, silently | `400 VALIDATION_ERROR`, `issues[]` at `packageId`, nothing filed | + | an artifact built before this change | loaded, the set owned by the shipping package | refused at load; rebuild it without the key | + + **Remedy, in one line.** Drop `packageId` from the permission set in your stack source (the package that ships a set is its owner), or move the set into the stack of the package it names, then build again. +- b9dbc3d: feat(plugin-security): under a walled posture, the deployment-level acceptance of package defaults can be host configuration (`acceptedDefaultPermissionSets`, ADR-0131 D5) (#23385) + + Clause-②: yes (widening) + + **What was wrong.** On a walled (`group` / `isolated`) deployment with several organizations and none of slug `default`, the audience-binding suggestion record has no organization to own it. Every boot logged `[security] audience-binding suggestion sync failed (non-fatal)`, and the accept door answered `409 SUGGESTION_STATE` with the remedy "create the Default Organization (slug 'default')". That is a reserved organization for deployment-level rows, the shape ADR-0131 §1.5 rejects. + + **What is new.** `SecurityPluginOptions.acceptedDefaultPermissionSets?: readonly string[]` is read only under a walled posture. When it is declared, possibly as `[]`: + + - the `everyone` anchor derives from the baseline plus the declared sets. Each name passes the same checks an accepted suggestion does: the security catalog serves it under some package, it declares `isDefault: true`, and it carries no bit the `everyone` anchor forbids (ADR-0090 D7). A name that fails a check grants nothing and is reported, at `error` for a forbidden bit; + - the `sys_audience_binding_suggestion` record is neither read nor written, so the boot no longer warns. Existing rows are counted once and reported, not deleted; + - the record's list, accept and dismiss doors answer `409 SUGGESTION_STATE` naming the option. + + ```ts + new SecurityPlugin({ acceptedDefaultPermissionSets: [] }); // nothing beyond the baseline + new SecurityPlugin({ acceptedDefaultPermissionSets: ['crm_readonly'] }); // every member also holds crm_readonly + ``` + + A walled deployment that declares nothing keeps today's behaviour. When it has no organization to own the record, the refusal now names `acceptedDefaultPermissionSets` instead of "create the Default Organization". + + **Refused loudly.** A value that is not a list of non-empty names is refused at construction. A declaration under the `single` posture is refused at `start()`: there the record owned by the Default Organization is the acceptance. The `single` posture is otherwise unchanged, including its refusal text. + + **Exported for callers of the module's functions.** `readAcceptedEveryoneSets`, `syncAudienceBindingSuggestions` and `reconcileAudienceBindingSuggestions` take an optional trailing `DeploymentAcceptance` (`{ posture, acceptedDefaultPermissionSets }`), and `SuggestionDeps` gains `acceptedDefaultPermissionSets`. Omitted, they read the record as before. `WithheldAcceptance` gains `source: 'record' | 'host-configuration'`. +- 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. + + +- 94e60c0: fix(plugin-security)!: the row-policy collection applies an object's declared `defaultRowScope` when no permission set the caller holds carries a row policy for the object + + Clause-②: no (narrowing) — the narrowing is real only for a principal with no platform baseline whose permission sets carry no row policy for an object that declares a default; no object in this release declares one, and no export, option, schema or stored shape moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `ObjectSchema.defaultRowScope` declares an object's default row-level security policy. Until now no server read it. The security plugin's row-policy collection (`collectRLSPolicies`), which every read, write and explain composes from, now applies it: + + - **When it applies.** When the policies the caller's permission sets carry for the object and the operation come out empty, the object's default is collected in their place, for the declaring object. Empty means what the collection already meant by it: a held policy for every object (`'*'`) counts, while a held policy that is switched off, outside the caller's positions, or for another operation does not. + - **How it is judged.** Exactly as a held policy: its `operation`, `positions` and `enabled` decide where it applies, and its `using` and `check` compile through the same compiler. So it scopes reads, the by-id and bulk write filters, and the post-image check, and `checkAuthoredRowWrite` reads it as the declaration it is. A `using` the compiler cannot lower is dropped with the same warning and fails closed to zero rows, as an authored policy's does. + - **Explain** names an applied default as the object's own declaration, with its `name`, `label` and `description`: the row-level layer's `detail` says so, and a record-grained explanation names the rule after the default, reached `via: 'object_default'`. + - **Unchanged:** a policy an authored set carries for the object is used as authored, with the default not added to it; a principal whose superuser bypass skips row policies never reaches the collection; an object that declares no default collects exactly what it collected before. A deployment that composes the platform baseline is unaffected for the identity objects, whose shipped sets carry their own policies. + + What changes for you: nothing until an object declares `defaultRowScope`. Once one does, a principal whose permission sets carry no row policy for it reads only the rows the default admits. To give such a principal a different scope, put a row policy for the object in a permission set it holds; that policy is then used instead of the default. + +### Patch Changes + +- d61ed6a: fix(plugin-security): the boot report's unnamed-grant line says what 18.0 supports for a v17 database, not a command 18.0 refuses + + Clause-②: no + + - **The error line for `sys_user_permission_set` grants whose `permission_set` is empty** used to end with a fix: plan with `os migrate organization-ownership --out plan.json`, then run `os migrate organization-ownership --apply --backup-taken --plan-file plan.json`, restart, with a warning that granting the set again by name before it runs leaves a second grant for the ceremony to report. 18.0 refuses that command on every database, so the operator was sent to a refusal. The line now ends with the sentence the drop-gate refusal of `os migrate apply --allow-destructive` carries: "18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path." + - **Unchanged:** the count, the "permission_set is empty" fact, the ADR-0131 D4 reason ("each confers nothing"), "Nothing was changed.", the structured meta, the log level and the report's result. Such a grant still confers nothing and is still listed. + - **Removed from the line:** the two command steps and the restart step, and the advice about granting the set again by name. They described the 18.x upgrade ceremony, which 18.0 does not carry. +- fec87e7: feat(spec)!: a package manifest's `permissions` no longer takes a flat list of permission strings — the structured `{ services, hooks, network, fs }` block is the only form (#13458) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not yet in on `main`). + + `ManifestPermissionsSchema` was a union of a flat `string[]` and the structured ADR-0025 §3.2 block. Nothing ever acted on the list: the loader registers the consented `grantedPermissions` set from the environment artifact with the permission enforcer, never the manifest's request, and the only code that met a list on a manifest was two reports saying it had been skipped. The marketplace install disclosure reads only the four lists, so a list was shown to an installer as "Requests no special permissions." (ADR-0049 enforce-or-remove). `ManifestPermissionsSchema` is now the structured block itself. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `permissions: ['system.user.read', 'system.data.write']` | `permissions: { services: [...], hooks: [...], network: [...], fs: [...] }`, naming the platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it reaches and the filesystem paths it touches. | + | `permissions: []` | delete `permissions`: absence is the spelling for "requests nothing". | + | a list on an app manifest that ships no code | delete `permissions`. An app's record access is its permission SETS, in the stack's own top-level `permissions` collection. | + + **The one-line fix: replace every manifest `permissions` list with the structured block, or delete it.** A permission string has no mechanical mapping onto the four lists, so the translation is done by hand. `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + + **What an author now sees.** Writing a list fails `tsc` (the key's type is the block), and `os validate`, `os build`, `os plugin build` and `defineStack` refuse it at `manifest.permissions` with the block's own answer: `Expected the plugin permission block { services?, hooks?, network?, fs? }, received a flat list.`, followed by the prescription. Every structured block that parsed before still parses, unchanged. + + ### The retirement kit + + - **Schema.** `ManifestPermissionsSchema` is `PluginPermissionsSchema`, by identity, and both export names stay. The block answers a list with its prescription on its own error map, the bare-array pattern `ListViewExportOptionsSchema` uses. The block also carries `EnvironmentArtifactSchema.grantedPermissions` values, so the answer is worded true there too, where a list was never legal. + - **D2 conversion `manifest-permissions-string-list-removed`** (step 18, retired from the load path): a lossless delete of an all-string list from the stack's `manifest` and every `packages[].manifest`. The notice carries the dropped strings. A built artifact replays it at the artifact door, so an artifact built while the list was legal still boots. It never touches the structured block, an array of objects, or the top-level ADR-0090 permission-set collection. + - **D3 entry `manifest-permissions-string-list-retired`** carries the judgement the delete cannot make: what each dropped string meant in services, hooks, hosts and paths. + - **Liveness.** `manifest.permissions` stays `live` on corrected evidence. Its consumer is the marketplace install disclosure, and it refuses nothing at load. The four keys are now drilled. + - **The two skip reports reworded.** `AppPlugin`'s security registrar and `@objectstack/plugin-security`'s audience-binding reconciler both report a manifest-stage `permissions` they cannot read as permission sets. They now name the flat list as the retired legacy form. They behave as before. + + **Measured producers: none outside tests.** On origin/main e67ba80049, no manifest in `examples/`, `apps/`, `packages/`, `skills/` or `content/docs/` writes a list. The exceptions are five `@objectstack/spec` `manifest.test.ts` fixtures, re-triaged here, and the skip-report tests of `@objectstack/runtime` and `@objectstack/plugin-security`, which hand a list to an unparsed bundle on purpose and still pass. The same instrument finds those five fixtures, which is its control. At the objectui pin `a58626c88dc8`, nothing reads `manifest.permissions` (control: 91 `manifest.(id|name|version)` reads), and the install disclosure reads the structured block alone. Deployed and cloud-held manifests NOT MEASURED. +- 17e4425: A position saved through the metadata door no longer stops an app's declared positions from being seeded + + Clause-②: no + + Every seeding pass copies the app's declared positions into the `sys_position` catalog: the boot's pass and, on a walled deployment, the pass each new organization gets. That seeder read one source or the other: the engine registry alone whenever it held any position besides the six built-ins, otherwise the metadata service. A position a platform administrator saves with `PUT /api/v1/meta/position/:name` lives in the registry. So after one such save, every later pass seeded that position and none of the app's own. Measured on the showcase, walled: an organization created after the save held 7 positions (the six built-ins and the saved one) instead of 17. + + The seeder now reads through the security catalog read (`createSecurityCatalogReader`). It reads both sources on every pass, and for a name both hold, the registry answers first. + + - **New organizations** get the built-ins, every declared position and every door-saved one. Measured on the same walled showcase: 17 positions. + - **Rows are written as before.** The door-saved position's row is unchanged: same name, label and description, `active: true`, `is_default: false`, no provenance stamp. For a name both sources hold, the registry's definition is still the one written. + - **The six built-in positions** are still written only by the built-in seeder, with `managed_by: 'platform'`. Some deployments saved a definition under a built-in name before the six were declared, and that definition still shadows the declaration when the catalog is read. This seeder skips it by name, so it never reaches a row. + - **A catalog read that fails** now writes nothing for that pass, and the boot reports it once as `[security] declared-position seeding failed`. That covers a source that cannot be read and a metadata list that lost a loader. Before, the seeder seeded whatever part it could read, without saying so. + - **No grant changes.** A principal's permissions move only if it holds a position that is newly seeded into its organization. +- 7d7943d: Grants written before `sys_user_permission_set.permission_set` existed now get their permission set's name, once, at boot (ADR-0131 D4) + + Clause-②: no + + - **What happens on the first boot after upgrading.** At `kernel:bootstrapped`, `@objectstack/plugin-security` fills `permission_set` on every grant that has no name yet. The name is the `name` of the `sys_permission_set` row that the grant's `permission_set_id` points at. The set row is read inside the grant's own organization: a grant of an organization may name that organization's set or an organization-less one, and an organization-less grant may name only an organization-less set. Each name is checked in the security catalog before it is written. Only the name column is written: no id changes, no grant is moved and no row is deleted. No principal's grants change, because readers still resolve grants from `permission_set_id`. + - **What is left unnamed, and reported in the boot log by count and grant id.** A grant whose id names no set row (`warn`). A grant whose id names another organization's set row (`warn`); nothing about that organization is logged. A grant whose set row carries a name the security catalog does not hold at that boot (`error`): register the permission set definition, or re-point the grant. + - **It runs once.** When the pass has nothing left that a later boot could decide differently, it records its verdict in `sys_migration` under the id `adr-0131-grant-permission-set-name-backfill`, and later boots skip it. A grant the catalog could not verify, or a write that did not land, leaves the verdict unrecorded, so the next boot tries again. Without a `sys_migration` table (no `PlatformObjectsPlugin` in the composition) the pass still runs on every boot, and renames nothing it already named. + - **Nothing to migrate.** No configuration is needed. +- 19bb55e: The permission-set grant readers in `@objectstack/plugin-security` read which set a grant holds from its name column, `sys_user_permission_set.permission_set` (ADR-0131 D4) + + Clause-②: no + + - **What reads the name now.** The explain engine's dropped-grant provenance (`buildContextForUser`: an expired grant, or a grant of a deactivated set), the platform-admin bootstrap's existing-holder check (`bootstrapPlatformAdmin`, and the seed-ownership claim's `findExistingPlatformAdmin`), the organization-admin reconcile (`reconcileOrgAdminGrant`, `backfillOrgAdminGrants`) and the delegated-administration gate's judgement of a stored grant it is asked to change or delete. Each reads the grant's `permission_set` instead of its `permission_set_id`. Deactivation is still read from the `sys_permission_set` row, now found by that name: the grant's own organization's row, else the organization-less one. The authorization resolver in `@objectstack/core` still reads the id, and nobody's resolved permissions change. + - **A grant whose name is empty.** A grant written before the name column existed has no name until the one-time backfill names it at `kernel:bootstrapped`, and the backfill leaves a grant unnamed when its id names no set row or another organization's set row. Such a grant grants nothing through these readers. Explain reports nothing for it. The organization-admin reconcile still finds it through its id when it revokes the grant or checks for a duplicate before inserting one. The delegated-administration gate refuses a delegate's change to it; a tenant administrator is not affected. The platform-admin bootstrap does not promote a second administrator while an unscoped grant on the `admin_full_access` row is still unnamed. It returns `reason: 'admin_grant_unnamed'` without an `adminUserId`, logs a warning, and the next boot reads the grant by its name. + - **Within the organization-admin reconcile,** a pair that already holds the organization-admin set by name, through any organization's copy of it, gets no second grant. + - The earlier release notes for the name column said no reader used it yet. That is no longer true of the readers listed above. + - **Nothing to migrate.** +- 6a49efb: fix(plugin-security): the `sys_user_position.position` refusal names the security catalog type it judged against, and reads no retired catalog table + + A `sys_user_position` write naming no position in the security catalog is still refused `400 VALIDATION_FAILED` with one `reference_not_found` entry at `position`. That entry's `constraint.target` now reads `position`, the security catalog type the value is judged against, instead of `sys_position`, a catalog table that is no longer registered. The sibling `sys_user_permission_set.permission_set` refusal already answers `target: 'permission'`. A client that read `constraint.target` should expect `position`. + + The refusal no longer looks a refused value up as a `sys_position` record id to suggest that row's name. That lookup could not succeed on a current deployment, so it never suggested a name. Every refused value now gets the same remedy: write the machine name of an existing position, or declare the position first. + + Also removed: the unused internal `per-organization-catalog.ts` module, which nothing imported. Nothing else changes. + + Clause-②: no +- 5c9bfb0: fix(plugin-security): the position write-through and the row-only position backfill are deleted, and the boot reference report stops reading position rows (ADR-0131 D3, C3 stage 7b) + + Clause-②: no + + Stage 8 stopped registering the `sys_position` row object, the `sys_position` data-door write-through and the row-only position backfill. This change deletes the modules behind them, `position-write-through` and `position-environment-backfill`. A position is written through the metadata door alone (`PUT /api/v1/meta/position/:name`). No export of the package's entry changes. + + What a deployment observes: + + - **The boot reference report reads no `sys_position` row.** It still lists, per organization, every position assignment and permission-set grant whose name the security catalog does not resolve, and every grant that names no permission set. It no longer has the two position-row lines ("position(s) … have sys_position rows and no definition" and "position name(s) are carried by more than one sys_position row"). No composition provisions that table any more, so those lines could not fire. Legacy position rows are converted and reported by the upgrade ceremony (ADR-0131 D10). + - The Setup-create refusal for a position name the catalog already holds (`409 UNIQUE_VIOLATION`, raised by the deleted write-through) goes with its module. A `sys_position` data-door create cannot reach the engine any more, so nothing raised it. +- 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`. +- 48cca4a: Setup's user page grants a position or a permission set by picking its name from the security catalog (ADR-0131 D4) + + Clause-②: no + + - `@objectstack/plugin-security`: `sys_user_position.position` declares `widget: 'position-ref'` and `sys_user_permission_set.permission_set` declares `widget: 'permission-ref'`, so the grant form offers a picker over the positions and permission sets the catalog serves, and stores the chosen name. A console without those widgets keeps the text input. `sys_user_permission_set.permission_set_id`, the legacy id that grants nothing, is now `hidden` and no longer appears on the grant form; the column stays until a later release drops its data. + - `@objectstack/platform-objects`: the user page's Positions and Permission Sets lists no longer carry the "Assign position" / "Grant permission set" buttons. Those buttons listed `sys_position` / `sys_permission_set` rows, which a deployment need not hold. Grant with the list's **New**, which opens the grant form; removing a grant is unchanged. +- 9e9e6f9: feat(cli)!: `os meta resync` is deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `os meta resync` reconciled the platform-default `sys_permission_set` rows (`admin_full_access`, `member_default`, …) to the shipped declaration. Since the authorization resolver reads permission sets from the security catalog (ADR-0131 D3), an edited default set is served from its declaration at the next boot, and the rows the command reconciled decide nothing. The command, its `--yes` / `--json` modes and its skip-count explanation are gone; a script that calls it gets oclif's unknown-command error. + + In `@objectstack/plugin-security`, `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments and one warning sentence (the permission-set backfill's re-provision list) no longer name the command. Their behaviour is unchanged. +- 2fbe1fd: Internal move with no behavior change: the organization-scoping helpers and the seed write-refusal accumulator leave `per-organization-catalog.ts` for two new internal modules, `organization-scope.ts` and `write-refusals.ts` + + Clause-②: no +- eebaf3b: feat(objectql,core): the driver's tenant read scope is the organization wall's verdict — one predicate for Layer 0 and every driver (ADR-0131 D8) + + Clause-②: yes (widening) + + - **What changed.** The engine no longer derives `DriverOptions.tenantId` / `tenantIds` beside the organization wall. `buildDriverOptions` projects the wall's own verdict onto them: the verdict plugin-security records on the operation (`OperationContext.tenantLayer0Verdict`) where its wall ran, and otherwise the same function over the engine's own facts (the posture in force, the context's organization and membership set). The function, `computeTenantLayer0Verdict` with its input type `TenantLayer0Input`, moves to `@objectstack/core` (new export); `@objectstack/plugin-security`'s `tenant-layer.ts` re-exports it and its Layer 0 is byte-identical. + - **FROM → TO, per posture, for a read, count, aggregate, update or delete on an object with an organization column:** + + | posture / caller | driver scope before | driver scope now | + |:--|:--|:--| + | `single`, any caller naming an organization | that organization (plus organization-less rows) | **none** | + | `isolated`, member | the active organization | unchanged | + | `group`, member | the membership set | unchanged | + | walled, exempt `PLATFORM_ADMIN` on a posture-permitting object, or the verified platform owner's read | the active organization | **none** — what Layer 0 already admitted | + | walled, system context naming an organization | that organization | unchanged | + + - **Not changed.** Inserts and upserts keep the active organization as `tenantId` in every posture: the row is stamped with it, its counter scopes by it, and an upsert that would merge into another organization's row is still refused. Both `orWhereNull` compatibility arms in `SqlDriver.applyTenantScope` stay (their retirement is a later stage of ADR-0131 D13). + - **Who notices.** A `single` deployment that holds more than one organization was kept apart by the driver only by accident; postures now differ only in enforcement (ADR-0131 D11), and `single` enforces no wall. To keep organizations apart, run the `isolated` or `group` posture. Code that relied on a context organization to narrow a read on `single` names the organization in its own `where`. + - **Migration.** ADR-0087 semantic entry `driver-read-scope-follows-tenant-wall`. A deployment's `platformGlobalObjects` carve-out already reaches the driver through the column plan (ADR-0131 D7): a declared object carries no organization column, so neither wall has anything to scope. +- e67ba80: The seed ownership claim no longer fires app hooks or record-change flows + + Clause-②: no + + On a freshly seeded database, the first sign-up promotes the first user to platform admin, and `claimSeedOwnership` hands every seeded row to that admin inside the same request. That write ran under a bare system context, so every claimed row went through the full write pipeline: hooks bound from app metadata fired, record-change flows ran, approvals opened on seeded records and notifications went to the new admin. Measured on hotcrm `56d98f7e` (17.7.0, a 354-row seed), the first sign-up took about 45 s, and in that time the claim fired 1,254 app hooks, ran 8 flows, opened 2 approvals and handed 8 emails to the transport. + + The claim's write now runs with `{ isSystem: true, skipAutomations: true }`. The seed itself is end-state data written without automation, and the claim keeps that rule for the write that completes it. + + - **App hooks no longer fire for the seed ownership claim.** No hook bound from metadata (an app's `hooks`, sandboxed bodies included) runs on the claim's owner change, and no record-change flow is dispatched for it. So the claim opens no approval and sends no notification. + - **Which claims.** Every pass of the claim: the promotion pass inside the first sign-up, and the pass that runs on `app:seeded` on every boot, the first and every later one. + - **Still runs.** Hooks that plugins register in code still run, so the claim still writes one audit row per claimed record (plugin-audit) and plugin-sharing still recomputes the grants the owner change earns. Every claimed row's `owner_id` is the admin and its `updated_at` still advances, exactly as before. The per-row hook ceiling and the paged fallback for very large objects are unchanged. + - **If an app relied on it.** An app hook or flow that reacted to the claim's owner change no longer sees it, just as it never saw the seed's own writes. None of the hooks or flows in ObjectStack's own example apps reads it. +- 4049cac: fix(plugin-security): the boot heal of drifted `sys_permission_set` rows converges on duplicated names, stops paying a discarded metadata read per row, and a refused existence read no longer inserts a duplicate (#22169) + + Clause-②: no + + **The heal writes the row it read, by its id.** `reconcilePermissionSetProjection` heals every overlay-less, env-authored record that drifted from its metadata definition. It used to hand each drifted record to a by-name upsert, which re-read the first record of that name (lowest id). On a table holding more than one record per name, only that one was ever written: every other record was logged "re-projected" and left drifted, on every boot. Now each drifted record is updated by its own id, facets only (label, description and the permission columns; identity, `active` and provenance are never touched). The drift line is logged after the write and names the record's id; a refused write is logged as refused instead of as re-projected. On a real engine with 8 names × 3 records, the boot after the healing one went from 116 statements and 16 drift warnings to 4 statements and none. + + **The layered metadata read is lazy, once per name.** The heal awaited `getMetaItemLayered` (four statements on the protocol) for every candidate record before deciding whether it needed it, and it does not need it while a `SchemaRegistry` is readable. It is now read only when no registry answers, and once per name per pass. + + **A refused existence read declines the insert.** `bootstrapPlatformAdmin` reads each default permission set by name and inserts when it finds none. A refused read answered as "none", so on a table without the per-organization unique index the boot inserted a second record of a set that already existed. A refused read now inserts nothing, and the pass says so once, naming the sets. When `admin_full_access` is one of them, the promotion half reports `admin_permission_set_unreadable` instead of `admin_permission_set_missing`. + + **Unchanged.** Existing duplicate records are healed, never deleted: removing them is an operator repair. The live single-mutation projection keeps its per-item read. +- 746637e: fix(service-automation)!: retire the `RunProvenanceContext` type; `RunDataContext` names only what `resolveRunDataContext` returns + + Clause-②: no (narrowing) + + + + **BREAKING** for TypeScript code that imports from `@objectstack/service-automation`: one type export is removed and one type narrows. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes in any package. + + **What is removed.** The `RunProvenanceContext` type export, the `{ flowRunId }`-only "provenance" envelope, and its arm of `RunDataContext`. No code in this repository builds or reads one. Since ADR-0096 D5, the data engine's security middleware refuses a context that carries no principal and is not a system context (`403 PERMISSION_DENIED`, for every verb). So the type described a context the engine refuses, while its docstring said such a context was "indistinguishable from passing no context at all" and was let through. + + **What `RunDataContext` is now.** Exactly what `resolveRunDataContext` returns: `isSystem: true` for a `runAs: 'system'` run, and the triggering user's principal for a `runAs: 'user'` run. A run that resolves no principal is refused before any data operation (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`), as before. + + **If your code named the type.** There is no replacement, because the context it described is refused. A custom data node builds its context with `resolveRunDataContext(context)`, typed `RunDataContext`. + + **`@objectstack/plugin-security`, `@objectstack/runtime`, `@objectstack/service-analytics`, `@objectstack/platform-objects`:** comment and docstring text only. Shipped comments that said the security middleware hands through, skips or falls open for a context with no principal now say that it used to, and that ADR-0096 D5 refuses such a context. Nothing these packages do changes. +- 86bf9ed: `security/explain` answers a delete and a transfer of a `controlled_by_parent` record the way each verb's own door answers it: their record verdicts now come from the master-detail write check (ADR-0055), as the update's already did. + + - **What was wrong.** The write path runs the master-detail write check on every by-id write of such a record (an update, a delete, a transfer), and hands the record's platform ownership floor over to that check. explain asked the check for an update only. + - A delete kept its `owner_only_deletes` floor. A principal who may edit the master but did not create the record was explained as refused (`decidedBy: 'rls'`) beside a `DELETE` that answers `200`. A principal who may not edit the master was refused on that floor, with no word about the master, beside a `DELETE` whose `403` names the master. + - A transfer asked only the sharing service's per-record gate, which abstains on such a record. A principal who may not edit the master was explained as allowed to transfer it, beside the `PATCH` that writes `owner_id` and answers `403`. + - **What it does now.** For an update, a delete or a transfer of a record that exists (and a restore or a purge, which the object gate still refuses to everyone), explain asks the security service's `checkControlledByParentWrite` with the context it is explaining. The check judges edit access to the master whatever the record's own verb, so its answer is each verb's answer. A `deny` or `unresolvable` outcome makes the record not writable, decided by the `sharing` layer, whose record attribution names the verb and the refusing leg (or the reason no verdict was reached). A delete's row-level composition carries the write path's master-gate coverage vouch, as an update's does, so the ownership floor is handed over as the write path hands it, and never on behalf of a delegator. + - **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field. A `create` asks no master check: the write path reads an insert's master from the request body, which an explanation does not carry. The `ExplainEngineDeps` type: the `checkControlledByParentWrite` dependency keeps its signature and is now asked for every by-id write, not only an update. +- 25be876: `security/explain` composes a transfer's row-level security as the update its door is, and a restore's and a purge's as the write class the by-id write path composes them as. + + - **What was wrong.** The by-id write path composes a lifecycle verb's row-level security for its nearest write class: transfer and restore as `update`, purge as `delete`. explain composed it for the verb as asked, which the RLS compiler reads as a read (`select`). So no update-class policy and no ownership floor reached a transfer's verdict. On a row an app-authored update policy excludes, explain answered `record.visible: true`, with "No business RLS policy applies to this record", beside the `PATCH` that writes `owner_id` and answers `403 PERMISSION_DENIED`. + - **What it does now.** The mapping is declared once, in plugin-security, and both readers read it: the by-id write pre-image gate (byte-identical to the expression it replaces) and explain. explain composes both of its row-level readings for the mapped operation: the record's row story and the object-level `rls` layer. A transfer of a row an update policy excludes is now `visible: false`, decided by `rls`, as an update of that row is. The object-level `allowed` reads the `rls` layer's deny-all verdict, so for a transfer it now reads the update class's. Restore and purge are still refused at the object gate for every principal, so `object_crud` still decides their verdict first; the `rls` layer under it now reports the update and delete classes. + - **What is unchanged.** Every other operation: `read`, `create`, `update`, `delete` and `export` are composed exactly as before. The RLS compiler's own default for an unlisted operation. The read path's row-level filter injection, which never sees a lifecycle verb. No export is added to the package. +- 18d9990: `security/explain` answers a transfer of a row the caller cannot read the way the transfer door does: as a record that does not exist. + + - **What was wrong.** On the write doors, a row the caller cannot read answers what a nonexistent id answers: the by-id update or delete the caller addresses gets `404 RECORD_NOT_FOUND`. A transfer's door is the `PATCH` that writes `owner_id`, an update, so a transfer of an unreadable row gets that 404 too. explain's matching rewrite (the missing-record shape, `record: { visible: false }` with no `decidedBy`) keyed on the verb as asked, `update` or `delete`, so it never ran for `transfer`. Where the caller's update policy admitted the hidden row, explain answered `visible: true`, decided by `rls`. Where the caller declared no write policy, it answered `visible: false` decided by `rls`, which is the shape of a 403 refusal, not of the door's 404. + - **What it does now.** The rewrite asks which operation the verb's door is through the same mapping the by-id write path reads (`transfer` and `restore` as update, `purge` as delete). So every write verb whose door is a by-id update or delete is asked the read question, and a transfer of a row the caller cannot read is `{ visible: false }` with no decider, beside the door's 404. + - **What is unchanged.** `update` and `delete` are answered exactly as before. `restore` and `purge` are still refused at the object gate for every principal, which answers first. A readable row keeps its verdict for every verb. The object-level `allowed` answers the object question and is not touched. No door changes, and no export is added to the package. +- 680a86b: `security/explain` answers a by-id write of a row the caller cannot read with the whole answer it gives for an id no row carries, layers included. + + Clause-②: no + + - The update, delete and transfer doors already answer such a row `404 RECORD_NOT_FOUND`, as they answer a nonexistent id, and explain's record verdict already took the missing-record shape. Every row-scoped layer's `record` block, and the tenant layer's verdict, now come from the same path a nonexistent id takes, so no layer names a status the door does not give. + - Nothing you author changes. The doors and the record verdict answer exactly as before, and an explanation of a row the caller can read is unchanged. +- b1d701b: `security/explain` decides whether a record-grained explanation may say a record exists by the read access of whoever asked for it, not by the explained principal's. + + Clause-②: no + + - **An asker who cannot read the record** gets, for every operation, exactly the answer an id no row carries gets: `record` is `{ recordId, visible: false }` with no `decidedBy`, and every layer's per-record attribution is the missing record's. + - An asker explaining another user (`manage_users`, or a delegated administrator's scope) cannot read a record its own by-id read finds no row for, or refuses (a declared 4xx, such as no read grant on the object). + - A principal explaining itself cannot read a record its own by-id read finds no row for. A refused read does not count, so the explanation shows what that principal's own doors answer: a principal that may edit a record it may not read is told what its edit does. + - Before, a principal explaining itself a `read` of a row its read finds no row for was told the row's verdict and why, and an administrator with no read grant on the object was told any row's story; the by-id write verbs already answered a self-explanation of such a row as missing. + - **An asker who can read the record** gets the explained principal's full record story. For a `read`, that is the principal's own verdict, decider included, so the `controlled_by_parent` read verdict on the master leg (not visible, decided by the `sharing` layer) is what such an asker is told. For a by-id `update`, `delete` or `transfer` of a row the principal cannot read, the verdict stays `visible: false`, the answer that principal's write gets, and now names the layer that decided the principal's read; each row-scoped layer's `record` carries that read's attribution, prefaced with the write's `404 RECORD_NOT_FOUND`. Before, such an asker was told the missing record's answer. + - A system asker reads every record. The batch form (`recordIds`) answers each id as the singular form does. + - `@objectstack/spec`: the `record` verdict's description and the `recordIds` "Missing records" clause in `explain.zod.ts` now say which ids an asker cannot read, by asker, and that those answer as missing. Description text only; no schema shape changes. + - Nothing you author changes, and no exported type or signature moves. Object-level explanations (no `recordId`) are unchanged. +- c11b758: `security/explain`'s record-grained `read` verdict for a `controlled_by_parent` record now equals the read door's answer on the master leg (ADR-0055). + + - **What changed.** The read door scopes such a record by its master as well as by its own row-level security. explain's read branch modelled the record's own row-level security only. It now asks the read door's own by-id read, with the context it is explaining, wherever every other leg of the read's row story admits the record. A record the door does not return is reported not visible, decided by the `sharing` layer. A record the door returns is reported exactly as before. A rejection of that read is reported fail-closed, as a dependency that throws already is. An `export` explanation, which streams the same find, answers the same way. + - **What is unchanged.** Every object that is not `controlled_by_parent`. Object-level reports (no `recordId`). The `allowed` field. Every write verb. The answer for an id no row carries. The `ExplainEngineDeps` type: the `recordAbsentToCaller` dependency keeps its signature and is now asked for such a read too. +- 7856110: The RLS deny sentinel and the composed `ReadableIds` leaf are declared once in `@objectstack/spec/data`, and every place in `@objectstack/plugin-security` that handed out a copy of the sentinel now returns the object itself (ADR-0139 D2) + + Clause-②: yes (widening: new exported members of the published spec `data` entry) + + - **`RLS_DENY_FILTER` moves into the spec.** FROM `@objectstack/plugin-security`'s own frozen literal TO `RLS_DENY_FILTER` exported by `@objectstack/spec/data`. The value is unchanged: `{ id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }`, an `id` equality no record satisfies, so a consumer that never heard of it still reads zero rows. `@objectstack/plugin-security` keeps exporting `RLS_DENY_FILTER` under the same name and type; it is now the spec's object, not a second copy. Nothing to change in code that imports it from either package. + - **One object, compared by identity.** `getReadFilter` (`ISecurityService`) now returns the sentinel itself, not a copy, for a principal-less caller, a sharing scope that cannot be resolved, an on-behalf-of caller, a caller with no permission set and a resolution failure. It also returns the sentinel itself when the only layer that constrains is the tenant wall's deny, the RLS compiler's deny (every applicable policy dropped), or the deny of a `controlled_by_parent` object whose master cannot be resolved. Before this release most of those returned a spread copy, which matched the same zero rows but was a different object. Not every zero-row answer is the sentinel: a deny ANDed with another layer is returned as the composed `$and`, with the sentinel inside it, and the record-sharing layer's own deny, `{ id: '__deny_all__' }`, is returned unchanged when sharing is the only layer that constrains. ADR-0139 D2 step 2 has the engine's `ReadableIds` composition recognise the deny by `scope === RLS_DENY_FILTER` and answer it `$in: []`; any other zero-row answer composes like any scope and still matches no row. The object is frozen: code that edited the filter `getReadFilter` returned now gets a `TypeError` instead of silently changing a deny. + - **The composed leaf.** `ReadableIds.compose(leaf, scope)` builds the frozen value `{ object, scope }` the engine hands a driver in place of a `ReadableIds` leaf (D2 step 3). It is branded under `COMPOSED_READABLE_IDS_BRAND` (`Symbol.for('objectstack.filter.readableIds.composed')`), so a JSON round-trip, a structured clone or a spread drops the brand. `isComposedReadableIds(value)` recognises it. The type `ComposedReadableIds` is exported. `scope` is `undefined` for "no row restriction"; `compose` throws a `TypeError` for anything but a `ReadableIds.of` leaf, for a scope that is neither `undefined` nor a non-null, non-array object, and for `RLS_DENY_FILTER` itself, which D2 step 2 answers `$in: []`. + - **`isReadableIds` answers as before.** It answers `false` for a composed leaf, so it still means "an uncomposed leaf", and a driver refuses on it. The comparand-shape door's `admitReadableIds` admits the uncomposed leaf only: a composed leaf handed to the engine from outside is refused `INVALID_FILTER` / 400 as a non-list comparand. +- dfb3ba2: A `controlled_by_parent` master that is itself a detail decides who may write its details the way its own record update is decided: whoever can edit a quote can edit its lines + + Clause-②: no + + In a two-level master-detail chain (a quote line under a quote under an account, every level below the account `controlled_by_parent`), one person got two answers about the same quote. An organization member who could edit a quote they had not created was allowed to update the quote itself, but every insert, update and delete of its lines was refused `403 PERMISSION_DENIED` ("requires edit access to its master record … not editable by this user (row-level security)"). Only the quote's creator could change its lines. The quote's own update hands the platform's creator-only floor (`owner_only_writes`, positions `['org_member']`) to the master-detail write check, by the 2026-08-15 ruling; the check that a line write runs on the quote kept that floor. + + Now the line write asks the same question the quote's own update asks. By the maintainer's ruling (letter A), a line write under a middle master is admitted exactly when the same person may update that middle master directly: + + - **Admitted now:** an organization member who can update the quote (and the account above it) may insert, update and delete its lines, whoever created the quote. + - **Still refused:** the account above is not editable or not readable; the quote is not editable through its object permission; or an app-authored update rule on the quote refuses the person. + - **Also refused now:** the quote's creator, writing the lines of a quote that an app-authored update rule refuses them. The quote's own update already refused them. The line write had admitted them, because the floor and the app's rule each admitted a row on its own. + - **Unchanged:** a write made on behalf of another user keeps both principals' floors at every level. A master that governs its own rows, a single-level detail, the cycle guard and the depth bound are untouched. + + `security/explain` and the parent gates of `sys_attachment` and `sys_comment` ask the same check, so they give the same answers. +- 920bc12: fix: the approval request actions' success toasts name the request, and Explain access names the principal as a person + + Clause-②: no + + Server-authored strings that the console renders verbatim now name what and who they are about. + + - `@objectstack/plugin-approvals`: every action on `sys_approval_request` had a bare-verb `successMessage` (`Approved.`, `Rejected.`, `Reassigned.` and so on). Each now names the request through `${result.request.record_title}`. Each action's route answers `{ request, … }` with the read-back request and its display enrichment, so `record_title` is the title the inbox shows: the target record's display field, else its payload snapshot. The new copies: + - Approve and Reject use the inbox row's inline wording: `Approved "TITLE"` and `Rejected "TITLE"`. + - The others read `Reassigned "TITLE"`, `Sent "TITLE" back for revision`, `Information requested on "TITLE"`, `Reminder sent for "TITLE"`, `Recalled "TITLE"`, `Resubmitted "TITLE"` and `Reply posted on "TITLE"`. + + The zh-CN, ja-JP and es-ES values of the same keys carry the same token. No key, route or response shape changes. + - `@objectstack/plugin-security`: the explain engine's principal layer said `Principal USER_ID holds position(s) …`. It now names the person first and the id second, for example `Principal Ada Lovelace (ada@example.com; id usr_1) holds position(s) …`. Three other users in the same layer are named the same way: + - an on-behalf-of delegator; + - the user an ADR-0091 D3 delegated position came from, in the clause; + - that same user in the position's contributor attribution (`delegation from …`). + + Each user row is read with the asker's own context, so row-level and field-level security apply. An asker whose read returns no row gets the bare id, as before, and a field its read masks is left out. As a result, two askers can now be shown different names for the same user. The decision's `principal` object and every verdict are unchanged. +- 37510aa: feat(security): the guest's grants channel — an anonymous request at a guest-entry door holds the sets bound to the `guest` anchor, in the one organization the deployment resolves for it (ADR-0138 E1, D3/D4) + + ADR-0087 disposition: none. No authorable key, accept set or stored shape moves: no spec schema, metadata type, stored row or published response field is added, removed, renamed or re-shaped. A declared binding that was not enforced becomes enforced (ADR-0090 D9, as ADR-0138 D3 amends it). + + **The bindings that start granting.** On the reference apps, none: `app-showcase`, `app-crm`, `app-multi-package` and `app-todo` declare no definition of the `guest` anchor that names a set. `app-showcase` ships `showcase_guest_portal` as a capability an administrator may bind and binds it to no anchor (its `client_portal_user` position is an ordinary position), and `app-crm`'s `guest_portal` is the public form doors' set, which keeps its own posture. No reference app declares a permission set named `guest`, so no grant stops either. A set starts granting only where a deployment holds an environment definition of the `guest` position that names it. + + What changes: + + - **`@objectstack/core`** — the anonymous branch of `resolveAuthzContext` resolves the guest's grants on a face that takes the explicit guest entry (the new `guestEntry` input). It reads the sets the `guest` position's definition names, through the same position-bound read every position uses, and the activation ledger as for any held set. It resolves them only in the organization the guest entry's organization step derives (`resolveGuestOrganization`), which asks ADR-0131 D9's predicate and no second spelling of it. With nothing bound, the guest holds no set and no read is issued. The predicate itself, `resolveSystemWriteOrganization`, moves to `@objectstack/core` so both of its askers share it. New exports: `resolveGuestOrganization`, `resolveSystemWriteOrganization` (with `SystemWriteOrganizationDecision` and `SystemWriteRefusalReason`), and `GuestOrganizationUnresolvedError` with `isGuestOrganizationUnresolvedError` and its constants. + - **`@objectstack/objectql`** — `resolveSystemWriteOrganization` and its two types are re-exported from `@objectstack/core` as the same bindings. Nothing a caller imports changes. + - **`@objectstack/plugin-security`** — the set resolution treats the guest's sets as any requested set, and no longer folds the guest's position into the request. A permission set that merely shares the anchor's name no longer reaches an anonymous caller, and the baseline and `everyone` never did. At the CRUD gate, a guest that reaches a data operation acting in no organization, on a deployment where the organization step refuses, is refused before the operation runs. + - **`@objectstack/plugin-sharing`** — the read filter answers deny-all for the guest on every object that record sharing scopes, before the sharing-model branch: the guest reaches rows only through share links, never through the object's sharing model (ADR-0138 D3 item 6, as the maintainer's ruling C amends it). `externalSharingModel` is still not read for any caller. Share links are redeemed at their own door and are unchanged. + - **`@objectstack/runtime`** — the runtime face, which serves the guest, asks for the guest's grants. + + What an anonymous caller sees at an `authRequired: false` endpoint of `type: 'object_operation'`: + + - **One organization, a set bound to `guest`:** the operation runs as the guest, under that set, in that organization. A read the set grants is admitted and returns no row, whatever the object's sharing model (`private`, `public_read`, `public_read_write` or `controlled_by_parent`): the guest reaches a record only through a share link. A create the set grants lands in that organization with no owner. + - **Nothing bound:** `403 PERMISSION_DENIED`, as before. + - **A walled deployment, several organizations, or none:** `501 NOT_IMPLEMENTED`, naming the missing site binding (ADR-0138 D5), where it used to be `403 PERMISSION_DENIED` with nothing granted. Nothing that was served stops being served. The operator's log names the reason once. + + Unchanged: share links, which still serve an anonymous caller the one record they name, with `publicSharing.eligibility` checked at the mint and at every redemption; the public form doors, which keep their own posture and organization; the flow door, which runs under the flow's own declared posture; every surface that answers an anonymous request `401`; and every signed-in caller. + + Level: `minor` for `@objectstack/core`, because new exported symbols widen its published index; `patch` for the other four, whose exports do not move. +- 09ef77c: fix(plugin-security): a permission set published into a runtime package names that package to the security catalog, so its `isDefault: true` records an `everyone` suggestion + + Clause-②: no + + - **What was wrong.** A writable runtime package's permission set marked `isDefault: true`, published through the package door with no `packageId` in its body, recorded no audience-binding suggestion on an environment kernel, so ADR-0090 D5's "packages suggest, admins confirm" never happened for it. On that kernel the protocol hydrates no stored row into the engine registry, so the security catalog finds the set in the metadata service. The only body there is the permission-set projection's in-memory copy, and that copy carried no `_packageId`. The catalog served the set as unowned while `GET /api/v1/meta/permission/NAME` named the package, and the suggestion collector skipped it. + - **What changed.** The projection's copy of a tenant-authored set now names the package its stored row is bound to. It reads that binding from the protocol's by-name read (the `_packageId` that read serves) and derives none of its own. The catalog, the by-name read and the layered read (`?layers=true`, whose `packageId` comes from the same copy) now name the same owner. `GET /api/v1/security/suggested-bindings` lists one pending suggestion under that package. A `manage_metadata` holder still has to accept it: the publish grants nothing. + - **When the body's `packageId` disagrees.** A set whose body declares a `packageId` other than the package it is published into is now owned, for the catalog and the suggestions, by the package it is published into, which is what the by-name read already served. On an environment kernel, a suggestion recorded under the body's package is pruned at the next sync while pending, and withheld from `everyone` with its reason logged if it was accepted. A pending suggestion under the publishing package replaces it. + - **Unchanged.** The packaged-permission-set lock and every edit door. (No `sys_permission_set` row is written for the set: C3 stage 7a retires the last writer.) A code package's set, whose owner the catalog reads from its artifact. A set saved into no package, which stays unowned and records no suggestion. A copy of a code-shipped set's legacy overlay, which carries no owner, as before. + - **If the binding cannot be read**, the copy is registered without an owner, as before, and one warning says so. It still enforces the new body, and the next save, publish or boot reads the binding again. +- 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. + + +- 6d96721: fix(plugin-security): an environment kernel's security catalog serves metadata-door permission sets and positions again + + Clause-②: no + + On an environment kernel (the per-project kernel a hosted tenant runs), a permission set or a position saved, published or deleted through the metadata door is in the security catalog again: + + - It resolves from the kernel's metadata service, under the package its stored row is bound to. + - A user granted it is authorized by it. + - An `isDefault: true` set in a runtime package records its pending `everyone` suggestion again (ADR-0090 D5). + + After a restart, the stored overlays are served again too. + + This regressed in C3 stage 8 (`feat(plugin-security)!: retire the four security-catalog row objects`). That change unwired the permission-set projector. On an environment kernel, the projector's in-memory echo was the only thing that put a stored set into the metadata service, because the metadata door hydrates no project row into the engine registry. Since stage 8, such a set resolved to nothing and granted nothing (fail-closed), and no suggestion was recorded. + + Positions never had an echo. On an environment kernel, a metadata-door position was unresolved before stage 8 as well, and it now resolves. + + `SecurityPlugin` now registers its own mutation projector for `permission` and `position` on the protocol, plus one pass over the stored environment-wide overlays at `kernel:ready`. Both only register the overlay body in the metadata service. They write no row, and they change nothing a host kernel resolves, since a host kernel reads these from the engine registry first. +- 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. +- be50633: refactor(plugin-security): the audience-anchor binding gate on `sys_position_permission_set` is deleted + + Clause-②: no + + `SecurityPlugin`'s private engine-middleware gate that refused binding a high-privilege permission set to the `everyone` or `guest` position guarded writes to `sys_position_permission_set` only. C3 stage 8 (#23222) unregistered that object, so no write has reached the gate since. The gate, its call in the middleware and its three unit cases are removed. + + No behaviour changes, and nothing exported changes. The anchor tier itself (`describeAnchorForbiddenBits` from `@objectstack/spec/security`) still applies wherever a binding is made: when an `everyone` suggestion is confirmed (`confirmAudienceBindingSuggestion`), and on every derivation of the `everyone` anchor. No door binds a set to `guest`. +- 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. +- 099cc9d: Clause-②: no + + The approval, security, automation and sharing objects describe their records in plain admin + sentences. Approval Delegations now reads "While someone is away, approvals assigned to them + personally go to their delegate.", Record Shares "Records shared beyond their owner, one row per + grant.", User Positions "Which positions each user holds, one row per assignment.", Audience + Binding Suggestions "A package's suggestion to give one of its permission sets to everyone who + signs in, and the decision taken on it." and Automation Runs "Paused automation runs waiting to + resume, and the recent history of finished runs." Field help on Approval Requests, Approval + Delegations, User Positions, User Permission Sets and Audience Binding Suggestions, the + descriptions of the `organization_admin_no_bypass`, `mcp_agent_data_read` and + `mcp_agent_data_write` permission sets, the flow-dispatch ledger's description and the + reserved-position refusal no longer carry design-record numbers or runtime vocabulary; those + references stay in the code comments. The zh-CN, ja-JP and es-ES translations follow. Wording + only: no field, permission or behaviour changes. +- 5fb8d15: Clause-②: no + + The Everyone and Guest built-in positions describe themselves in plain admin sentences. Setup's + Positions page now reads "Built-in audience anchor: every authenticated member holds this position + implicitly. Permission sets bound to it are the default grants for the tenant. High-privilege sets + cannot be bound here." for Everyone, and "Built-in audience anchor: unauthenticated principals hold + this position implicitly and exclusively. Bindings face the strictest checks — named objects only, + read-mostly, never a wildcard." for Guest; the design-record references stay in the code comments. + An upgraded deployment shows the same text: the page lists the declared positions, and a stored + position row an earlier release wrote is neither read nor rewritten. Wording only: no permission or + behaviour change. +- 2bcd406: fix(plugin-security): the security catalog echo keeps naming a disabled package's permission set or position as that package's + + The echo reads which package owns a stored overlay from the protocol's by-name read. That read now withholds a disabled package's item and keeps its protection envelope, so the echo reads the envelope's `packageId` instead of `item._packageId`. Before, a disabled package's set was echoed unowned, lost its ADR-0090 D5 audience-binding suggestion, and stayed unowned after the package was re-enabled until the next save, publish or boot. Enforcement is unchanged. +- 44a5011: Clause-②: no + + Refusals and Setup help text that a user reads inside the running system no longer call that system + "this environment". They say "this system", or they drop the noun. + + - `@objectstack/plugin-auth`: the two self-registration refusals drop the noun ("Self-registration is + closed (audience posture invite_only).", "Self-registration is limited to approved email domains, …"). + So do the OAuth self-service `503` ("OIDC provider is not enabled") and the SSO `400` + `DOMAIN_VERIFICATION_DISABLED` ("Domain verification is not enabled (set + OS_SSO_DOMAIN_VERIFICATION)."). The last-administrator guard's refusals and its two warning lines say + "this system", including "nobody able to administer the system". The OIDC authorize-gate refusal + (`403 ENV_ACCESS_DENIED`) keeps "this environment". Only the cloud control plane installs that gate, + and there the noun names the environment a cloud user is signing in to. + - `@objectstack/plugin-security`: the diagnostic `message` of the packaged-permission-set lock now reads + "… is declared by package '…' and is locked — editing it here would …". The provenance-unknown + refusal says "this system could not determine …". Both `userMessage` texts are unchanged. + - `@objectstack/service-settings`: the Mail and SMS provider groups read "Choose how this system sends + outbound email." and "… outbound SMS.". The Authentication audience group reads "Who may become a user + of this system's apps.". The zh-CN, ja-JP and es-ES bundles change only the noun, to 本系统, + このシステム and este sistema. + + Only the human-readable text moves. Every HTTP status, error code, settings key and response shape + stays as it was. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [4840720] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [077b75f] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/formula@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Minor Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 5026a95585c..139d30375c3 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Security Plugin for ObjectStack — RBAC, RLS, and Field-Level Security Runtime", "main": "dist/index.js", diff --git a/packages/plugins/plugin-sharing/CHANGELOG.md b/packages/plugins/plugin-sharing/CHANGELOG.md index 8350c2fddc9..fd64e860167 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,928 @@ # @objectstack/plugin-sharing +## 18.0.0 + +### Minor Changes + +- 1e8b628: feat(spec,metadata-protocol)!: the `/meta` door no longer authors sharing rules — `sharing_rule` is a code-only metadata type; Setup lists declared sharing rules + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D6, regime C). + + A sharing rule has two sources: a package declares it (the stack's `sharingRules`, switched off through the activation ledger), or an organization authors its own (`sys_sharing_rule`). A runtime metadata item was a third, and it bypassed the same-name guard and the organization wall. The narrowings, FROM → TO: + + - `PUT /api/v1/meta/sharing_rule/:name`, with a name nothing declares, in publish or `?mode=draft` — FROM `200`, with an environment-wide item stored and served by `GET /api/v1/sharing/rules`, TO `403 NOT_CREATABLE`, nothing stored. With a declared rule's name — TO `403 NOT_OVERRIDABLE`. + - `GET /api/v1/meta/types` — FROM `sharing_rule` advertised `allowRuntimeCreate: true`, TO `allowRuntimeCreate: false` and `allowEnvironmentOverlay: false`. + + **The fix:** author an organization rule, or clone the declared one. `POST /api/v1/sharing/rules` (Setup > Sharing Rules) creates an organization rule. To customize a declared rule, copy it to an organization rule under a new name, then switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/:name`, body `{ "enabled": false }`. A package declares its rules in the stack's `sharingRules`, as before. An item stored through the old door is still served until you remove it with `DELETE /api/v1/meta/sharing_rule/:name`. On an environment kernel, removing it needs `OS_METADATA_WRITABLE=sharing_rule`. That hatch is an explicit operator opt-in, not an authoring route: as for any code-only type, it reopens every write on the type, including creates of new names, and a sharing rule grants access. Unset it after the removal. + + Also in this change: + + - **`@objectstack/spec`.** `MetadataTypeSchema` (so the `MetadataType` union) and `DEFAULT_METADATA_TYPE_REGISTRY` gain `sharing_rule`. Its schema binding (`SharingRuleSchema`) moves from the non-kind collections into the kind map. `META_URL_TO_SINGULAR` gains the `sharing_rules` spelling, so `/api/v1/meta/sharing_rules` now folds to `sharing_rule`; before, that plural was not a recognised spelling. + - **`@objectstack/metadata-protocol`.** The refusal names the two sanctioned paths, not a source glob. `sharing_rule` gains its ADR-0126 Regime C row (clone, and the ledger switch). + - **`@objectstack/plugin-sharing`.** Setup's Access Control group gains **Declared Sharing Rules** (`nav_declared_sharing_rules`). It opens `/apps/setup/metadata/sharing_rule?scope=environment` beside **Sharing Rules**, which lists the organization's own rules. + - **`@objectstack/platform-objects`.** That entry's label, in `en`, `zh-CN`, `ja-JP` and `es-ES`. +- e7e6544: feat(plugin-sharing)!: a sharing rule reads a position's deactivation from the activation ledger, as the authorization resolver does (ADR-0131 D3, ADR-0126 §4) + + Clause-②: yes (narrowing: a position switched off in the activation ledger stops receiving sharing-rule shares in every organization; widening: a position whose `sys_position` row reads `active: false` receives them again) + + + + **BREAKING**, shipped as `minor` under the launch-window convention for breaking changes. A position's deactivation now comes from `sys_metadata_activation` instead of the `sys_position` row's `active` column, so the shares a sharing rule materializes follow the same switch the authorization resolver reads. + + - A sharing rule whose recipient is a position materializes no share while that position is switched off. Switched off means a `sys_metadata_activation` row of type `position` naming it, with `active` false (a driver `0` included). The switch is deployment-wide, like the ledger, so it no longer depends on the rule's organization. + - The `sys_position` row's `active` is no longer read. A deactivation made in Setup writes that column, so until the upgrade ceremony converts it into a ledger row it stops neither the resolver nor sharing rules. + - A name with no ledger row keeps sharing, including a membership-derived position name. A composition that registers no ledger object issues no ledger read. A ledger read that fails still grants for the pass, as the row read did. +- 78652c0: feat(cli,plugin-sharing)!: in the 17 → 18 ownership ceremony, a row whose owner cannot be derived blocks certification, and a declared-rule mirror's grants are renamed only onto a rule plugin-sharing serves + + Clause-②: yes (narrowing) + + + + **BREAKING** (narrowings), shipped as `minor` under the launch-window convention for breaking changes. + + - **FROM** (`@objectstack/cli`): `os migrate organization-ownership --apply` on a database holding a row whose owner cannot be derived exited `0` with `blocking: 0`, counted the row in `advisory`, and verified the `sys_migration` marker. This covers a reported (fate 4) row, and a row whose anchor is missing under a wall (fate 3). **TO:** it exits `1`, counts the row in `blocking`, and leaves the marker unverified, so a v18 `os serve` / `os start` does not start on the database. **Fix:** give each row its organization (write its `organization_id`, or repair the anchor its reason names), or delete it by hand, then re-run `--apply` with the same plan file. + - **FROM** (`@objectstack/cli`): a database an earlier version of this command certified while it held such a row was certified again by the next `--apply`. **TO:** that run leaves its marker unverified, for the same rows. **Fix:** the same as above. + - **FROM** (`@objectstack/cli`): `os dev`'s in-place upgrade of a development database started the server again whenever every ceremony step finished. **TO:** it starts it only when the post-check also holds nothing (`blocking` 0); otherwise it prints each table that holds, with its count and remedy, and the command that resumes the run. **Fix:** resolve the rows it names, then run the printed command. + - **FROM** (`@objectstack/cli`): the `mirror-deletion` step renamed the grants of a declared-rule mirror onto its rule's name, and deleted the mirror, even when plugin-sharing does not serve that rule (a condition that does not translate, an unmappable recipient, a retired owner-based rule); the next boot's orphaned-grant sweep then revoked those grants. **TO:** while `sys_record_share` grants name such a mirror, the step blocks, naming the rule, its rows and the reason, and writes nothing. **Fix:** correct the rule's declaration in its package so it is served, or delete each mirror row and the grants that name it, then re-run `--apply`. + + `@objectstack/cli` — **unattributable rows hold certification (ADR-0131 D10 fate 4).** The ceremony still never guesses an owner for such a row and never deletes it. The post-check now holds each one against the verdict: + + - Each table that holds one carries `held: { rows, remedy }` in the apply report, and counts those rows in its `remaining`. The terminal output names each table, its row count and the remedy. + - `advisory` now counts only the rows of an advisory departure and the reported rows of a table whose `organization_id` is not the tenancy anchor (`sys_sso_provider`, the retired `sys_scim_provider`). A row there that is bound to no organization is legitimate, is reported, and does not block. + - So a verified marker means no organization-less row on any table that the tenancy anchor governs. + - The plan's summary line says that such rows will hold `--apply`'s certification until they are resolved. + + `@objectstack/cli` — **the ceremony asks plugin-sharing which declared sharing rules it serves.** The port the `mirror-deletion` step reads (`DeclaredSharingRulesPort.rules()`, formerly `names()`) judges each declared rule through plugin-sharing's own translation. A mirror's grants are renamed only onto a rule that is served. A mirror of a refused rule that no grant names is still deleted. + + `@objectstack/plugin-sharing` — **exports `translateDeclaredSharingRule`** with its `DeclaredSharingRule` and `DeclaredSharingRuleSkip` types. It is the one translation enforcement serves a declared rule through. It is pure, and it is unchanged. +- 5cfd866: feat(sharing): a share-link password can be sent in the `X-Share-Password` header whatever its characters, under a declared encoding (`X-Share-Password-Encoding: utf-8`) + + Clause-②: yes (widening) + + - **What was missing.** A browser cannot put a character above U+00FF in a request header (`Headers` throws a `TypeError` before the request leaves), and it strips leading and trailing spaces. `createLink` accepts any password, so a link whose password has a CJK character or an emoji could not be opened through the header. + - **What is now accepted.** A new companion request header, `X-Share-Password-Encoding`, declares how `X-Share-Password` is encoded. Its one value is `utf-8`, compared case-insensitively. Under it, `X-Share-Password` carries the password's UTF-8 bytes percent-encoded, as `encodeURIComponent(password)` produces them, and both public routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) decode it on both mounts: the sharing plugin's routes and the runtime dispatcher's `/share-links` domain. Both read the pair through one helper, `readSharePasswordHeader`, exported from `@objectstack/types` with the header-name constants. + - **Unchanged.** Without `X-Share-Password-Encoding`, `X-Share-Password` is read raw, exactly as before, so every value a client sends today resolves as it did. That includes a Latin-1 password and a raw password containing `%`; the server never percent-decodes a value nobody declared encoded. The `?password=` query parameter is still read first, and when it is present the header pair is not read. + - **What is refused.** `X-Share-Password-Encoding` naming any other value, or a password header that is not percent-encoded UTF-8 under `utf-8` (a `%` without two hex digits, octets that are not well-formed UTF-8, a character outside visible ASCII), answers `400 VALIDATION_FAILED` before the token is looked up. It is never compared raw instead. The message names the headers and the rule, never the presented value. + - **Response headers.** Both public routes now answer `Vary: X-Share-Password, X-Share-Password-Encoding`, still beside `Cache-Control: no-store`. + - **Cross-origin clients.** `X-Share-Password-Encoding` is in the default CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS` in `@objectstack/plugin-hono-server`, which the `@objectstack/hono` adapter also applies). A deployment that passes its own `allowHeaders` must add `X-Share-Password-Encoding` beside `X-Share-Password` to let a cross-origin client send an encoded password. +- 08b38cb: fix(services)!: the datasource admin, share-link, webhook redeliver and storage families refuse a session the ADR-0069 authentication policy holds + + Clause-②: no (narrowing) — a session ADR-0069 holds out of protected resources is refused by families that answered it; core's existing exports only, no published schema or export moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate. REST and the runtime dispatcher already refuse such a session. Four families of routes mounted straight on the host server never asked, so they served it as an ordinary signed-in caller: + + - **`@objectstack/service-datasource`**: the datasource admin family (`registerDatasourceAdminRoutes`). Its door now asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) about the session it already reads, before the anonymous floor and the capability check. + - **`@objectstack/plugin-sharing`**: the share-link family (`registerShareLinkRoutes`). Every route, the two capability-token routes included, is now mounted through one door that refuses a context carrying `authGate`. `SharingServicePlugin`'s resolver now puts the session's posture on that context, as REST does. + - **`@objectstack/plugin-webhooks`**: the redeliver door (`WebhookOutboxPlugin`), on both of its faces: the route the plugin mounts, and the `webhooks` service's `handleRedeliver`. + - **`@objectstack/service-storage`**: the storage family, the two capability-token raw-transfer routes included. Every route is now mounted through one door. The posture is bound where every other storage gate is bound, so both the plugin's own mount and the host door `mountStorageRoutes` carry it. When a gate feature is active, the door reads the session and refuses a held one. When the gate is active but the session cannot be read, the door answers the declared `503` (`SERVICE_UNAVAILABLE`), as REST does. A direct call of `registerStorageRoutes` mounts the family exactly as before, because like the other storage gates this one is bound by the package's composition. No exported declaration changes. + + - **Refused now:** a held session gets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message`, in each family's declared error envelope, before the family does anything else. This is the answer REST gives the same session. + - **Unchanged:** a session the policy does not hold is served exactly as before, and an API-key caller is unaffected because it has no session to hold. A CORS preflight is answered as before. + + What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before these routes answer them, as on every other protected route. If you mount the storage routes through `mountStorageRoutes`, the gate is bound from the kernel's `auth` service with nothing to configure. If you mount `registerShareLinkRoutes` yourself with your own `contextFromRequest`, set `authGate` on the context it returns for a held session; the door refuses on that field. +- 345d1be: fix(spec,plugin-sharing)!: `sys_record_share` no longer offers a `guest` recipient, which no grant ever honoured (ADR-0138 D8) + + Clause-②: no (narrowing: a declared recipient value no grant honours is refused) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A record share to the anonymous principal was declared in two places and honoured in none. Both declarations are gone: + + - `RecordShareRecipientType` (`@objectstack/spec/contracts`) is now `'user' | 'group' | 'position' | 'unit_and_subordinates'`. Passing `recipientType: 'guest'` to `ISharingService.grant`, or typing a `RecordShare.recipient_type` as `'guest'`, is a compile error. + - The `recipient_type` select on `sys_record_share` (`@objectstack/plugin-sharing`) offers those same four options, and its `guest` label is gone from the four translation bundles. + + Why: the grant refused `guest` (ADR-0078), and no gate reads such a row, because the read and write gates match `recipient_type: 'user'` only. ADR-0090 D11 routes guest access through the guest position and share links, so a record share to the guest had nothing to gain. The sharing-rule vocabulary retired the same recipient in v17. + + What a caller sees now: + + - **The grant door** (`POST /data/:object/:id/shares`, or `grant()` from untyped code). A body naming `recipientType: 'guest'` is still refused with `VALIDATION_FAILED` (`400`), as before. The sentence now says the value was removed and that guest access flows through the guest position and share links. It used to say that sharing rules deliver non-`user` recipients, which was never true of a guest. To let a guest reach a record, use a public form or a share link. + - **The row door** (a system-context engine write: a seed, a plugin, a migration script). A row carrying `recipient_type: 'guest'` is now refused with `VALIDATION_FAILED`, an `invalid_option` error on `recipient_type` that lists the four values a row may carry. Before this change such a write stored a row that looked granted and enforced nothing. + - **Unchanged:** every `user` grant, the other three row values, the sharing-rule service, and the gates. A `guest` row that a system write stored before this release is read by no gate, both before and after. +- 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. +- 5e97907: feat(plugin-sharing,cli)!: declared sharing rules are read from the registry — the boot writes no `sys_sharing_rule` row for them, and the 17 → 18 ownership ceremony deletes the copies the retired seeder wrote + + Clause-②: no (narrowing) + + + + The narrowings, FROM → TO, each with its one-line remedy: + + - `DELETE /api/v1/sharing/rules/:idOrName` on a declared rule — FROM `204`, the seeded row deleted (and written again by the next boot), TO `403 PERMISSION_DENIED`. Remedy: change or remove the rule in its declaration in the package; an organization's own rule is deleted as before. + - `sys_sharing_rule` — FROM one `managed_by: package` row per declared rule, TO none: the boot writes none and the ownership ceremony deletes the copies the seeder wrote (Setup's Sharing Rules object list, which lists rows, no longer shows declared rules). Remedy: read declared rules through `GET /api/v1/sharing/rules`, which lists them beside the organization's own; on an upgraded database run `os migrate organization-ownership --apply` with a fresh plan. + + The retirement note for the seeder is the ADR-0087 semantic entry `sharing-rule-declared-mirror-retired`. + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `@objectstack/plugin-sharing` no longer copies a stack's declared `sharingRules` into `sys_sharing_rule` at boot. ADR-0131 D2 retires that seeder (`bootstrapDeclaredSharingRules`). The sharing-rule service reads each declared rule from the registry whenever it reads the rule set: listing, getting, evaluating, the per-record hooks, the boot backfill and the orphaned-grant sweep. Rules an organization authors keep their `sys_sharing_rule` rows. + + - **A declared rule still grants, and stops granting when its declaration goes.** It is translated from the registry's copy exactly as the seeder translated it: the CEL `condition` is lowered to a `criteria` filter, and `sharedWith` becomes the recipient. A condition that does not lower is not served, and never as a match-all. It says so in one WARN line per rule per process. A declared rule's id is its NAME, and the grants it materialises carry the name as `sys_record_share.source_id`. Remove the declaration and the next boot's orphaned-grant sweep revokes those grants. + - **Scopes follow the posture, as the seeder's passes did.** Under `single` a declared rule is served once, organization-less, so every organization reads it. Under a wall it is served once per organization — every organization, read page by page. An organization created after boot is served from its creation on. If the organizations cannot be read at boot, no declared rule is served until the next boot, and the boot's orphaned-grant sweep revokes none of their grants meanwhile: a failed read is never a revocation. + - **A stored row of the same name answers for it.** Under `single` that is a row of that name in any organization, and under a wall that organization's own row. Such a row is an organization's own rule, a seeded copy an administrator customized, or a seeded copy the ceremony has not deleted yet. The declared rule is not served beside it, the same as while the seeder ran: it never overwrote an admin-authored or customized row. + - **`DELETE /api/v1/sharing/rules/:idOrName` refuses a declared rule** with `403 PERMISSION_DENIED`. It has no row: it is changed or removed in its declaration. Before this release a platform administrator could delete the seeded row, and the next boot wrote it again. An organization's own rule is deleted as before. + - **Setup's Sharing Rules object list no longer shows declared rules.** It lists `sys_sharing_rule` rows. `GET /api/v1/sharing/rules` lists both. + + `@objectstack/cli` — **`os migrate organization-ownership --apply` deletes the declared-rule mirrors.** The `declared-rule-mirrors` rows on `sys_sharing_rule` are the seeder's uncustomized copies (`managed_by` `package` or `platform`, `customized` not true). They now block certification until the ceremony's `mirror-deletion` step removes them. It works in the ruled order. It renames every grant naming a mirror by row id (`sys_record_share.source_id`, `source = 'rule'`) to the rule's name and verifies that no grant names a mirror any more. Only then does it delete the mirrors. Under a wall it deletes the organization-stamped copies too. + - A mirror whose rule no installed package declares, and that grants still name, blocks with nothing written. The blocker names the rows and the remedy. A mirror no grant names is deleted. + - The step also runs on a database whose ceremony already completed while an earlier release still seeded the rows, where they were advisory. Re-run `--apply` with a fresh plan. + - `DECLARED_SHARING_RULE_SEEDER_RUNS` is now `false`. A customized copy and an organization's own rule stay. Under `single` they take the Default Organization, as before. + + `@objectstack/lint` — the sharing-rule enforceability verdicts and `os explain` say what now happens to a declared rule whose condition does not lower. It is "never served", not "never seeded", because it is never written to `sys_sharing_rule`. The rule ids, severities, paths and what each rule accepts or refuses are unchanged. +- 964a237: fix(plugin-sharing,runtime)!: a declared sharing rule is read-only and clonable — no rule, new or renamed, can take its name, and it is switched off through the activation ledger, which revokes its grants at once + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0131 D6 charts declared sharing rules under regime C: a rule a package declares is read-only, it is customised by a clone under another name, and it is switched off through the deployment-level activation ledger (`sys_metadata_activation`), like flows, actions, positions and permission sets. Same-name shadowing is retired. + + What is refused now: + + - `POST /api/v1/sharing/rules` (and `SharingRuleService.defineRule`) creating a rule under a declared rule's name — FROM `201`, the new row replacing the declared rule in its scope, TO `409 RESOURCE_CONFLICT` naming the declared rule. Fix: create your rule under another name, and switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/` and `{ "enabled": false }`. + - A `/data` insert of a `sys_sharing_rule` row under a declared rule's name, or an update that renames a row onto one — FROM `201` / `200`, TO `409 RESOURCE_CONFLICT`. Fix: the same. + + What is added: + + - **The switch.** `POST /api/v1/security/_activation/sharing_rule/:name` writes the declared rule's activation row, behind the same authority as the other activation doors: `manage_metadata`, and the platform operator under a wall, because the row covers every organization. A declared rule switched off is served nowhere. The write revokes every grant that carries the rule's name in the same request, and switching it back on re-derives them. The name must be a declared rule (`404` otherwise; a rule your organization authored keeps its own `active` field), and with no sharing-rule service bound the door answers `503`. + - **A declaration over a stored row is reported, never made an overlay.** When a package declares a rule whose name a `sys_sharing_rule` row already holds in a scope (a package upgrade, or a copy customised before this release), the row keeps answering as its own rule. The declaration is not served beside it, and that is logged at `error` once per name and scope per process, with the remedy. + + Unchanged: rules whose name no declaration holds are created, renamed and switched as before. A stored row that already holds a declared name stays editable as itself. `DELETE /api/v1/sharing/rules/:idOrName` on a declared rule is still `403 PERMISSION_DENIED`, and is now answered the same way when the rule is switched off. + +### Patch Changes + +- 92ce3af: fix(plugin-approvals, plugin-audit, plugin-sharing): list views no longer show the target object's API name as an Object column + + Clause-②: no + + - **The Object column leaves these lists.** These list views showed `object_name`, the target object's API name (such as `showcase_announcement`), as plain text beside the `record_id` record pointer. They no longer list it: + - `sys_approval_request`: `all_requests`, `my_pending`, `submitted_by_me` and `completed`; + - `sys_approval_action`: `recent` and `all_actions`; + - `sys_audit_log`: `recent`, `writes_only`, `record_views` and `all_events`; + - `sys_record_share`: all six views; + - `sys_share_link`: all four views. + - **The Record column names the target record.** `record_id` is the id half of the `record_id` / `object_name` pointer pair. The console draws it as the target record, with its object's icon and the record's name, and fetches `object_name` for that cell without a column. This relies on a console that fetches a pointer's type half for its cell, as the console bundled with this release does; an older console draws the raw record id in that column. + - **Unchanged:** `object_name` stays a declared field on all five objects, and the pointer pair, its indexes and every query on it are untouched. `sys_audit_log`'s `config_changes` view keeps its Object column, because its rows carry no `record_id`. + + A patch: entries removed from list view `columns` on shipped platform objects. No export, type, accepted value, served key or stored column changes. +- 66949b7: feat(plugin-security)!: the four security-catalog row objects are retired — `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` (ADR-0131 D3) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Positions, permission sets and capabilities live in the security catalog — the environment registry, fed by package declarations and by environment metadata saved through `/api/v1/meta` (ADR-0131 D3). The four row objects that mirrored them are no longer registered, so their data and REST routes and their Setup object pages are gone. No table is dropped and no row is deleted. + + FROM → TO: + + - `GET|POST|PATCH|DELETE /api/v1/data/sys_permission_set` → `GET|PUT|DELETE /api/v1/meta/permission/:name`; to copy a set, `POST /api/v1/security/permission-sets/:name/clone` with `{ name, label }`. + - `… /api/v1/data/sys_position` → `/api/v1/meta/position/:name`. A position's permission sets are its definition's `permissionSets`. + - `… /api/v1/data/sys_position_permission_set` → the position definition's `permissionSets` (`PUT /api/v1/meta/position/:name`). + - `… /api/v1/data/sys_capability` → read-only `GET /api/v1/meta/capability`; a package declares a capability with `defineCapability` (`stack.capabilities`). + - Setup → Access Control → Positions / Capabilities / Permission Sets now open Setup's catalog lists (`/apps/setup/metadata/?scope=environment`) instead of the object pages; the `sys_position` record page (`SysPositionDetailPage`) is removed. + - An object permission on one of the four names in a permission set grants nothing and can be deleted. + + What else changes: + + - `sys_user_permission_set.permission_set_id` is a plain text column now, not a lookup: same column, same stored ids, same indexes. It is not written and grants nothing; a grant names its set by `permission_set`. + - A permission set saved through the metadata door is written to no row: the `permission` mutation projector, the `sys_permission_set` and `sys_position` data-door write-throughs and the row-only position backfill are no longer registered. + - On a fresh `single`-posture database the first human is promoted to platform administrator without reading any catalog row. An unscoped grant written before `sys_user_permission_set.permission_set` existed (it names no set) still withholds the promotion until the organization-ownership migration names it. + - Uninstalling a package still revokes every grant naming a permission set the package declared, by the names the uninstalling door captures before the withdrawal (`declared`, `captureUninstallDeclared`), and now by those names alone: the cleanup reads and removes no `sys_permission_set` or `sys_position_permission_set` row, so the `sets` and `positionBindings` counts on its outcome are always 0. A grant written before `sys_user_permission_set.permission_set` existed names no set and is not reached; the organization-ownership migration names it. + - On a `group` or `isolated` deployment the data-door half of the walled catalog refusal goes with the objects: a data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability` answers `404 OBJECT_NOT_FOUND` for every caller instead of `403 PERMISSION_DENIED` below the platform operator. The refusal at the `/meta` write doors, the permission-set clone door and the `/packages` write doors is unchanged. + - `capability-reference-unknown` no longer counts a `sys_capability` seed row as a declared capability — no runtime reader consults one. Declare the capability with `defineCapability`. + - On an existing database `os migrate plan`'s unmanaged-table sweep now lists the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` tables: they keep their rows and no object declares them any more. The sweep only reports; nothing is dropped, and the tables stay for the organization-ownership migration. + + Removed exports, no replacement: + + - `@objectstack/plugin-security`: the object definitions `SysPosition`, `SysPermissionSet`, `SysPositionPermissionSet`, `SysCapability` (from `./objects`); `securityObjects` lists the remaining three. + - `@objectstack/platform-objects/pages`: `SysPositionDetailPage`. + - `@objectstack/spec`: `SystemObjectName.POSITION`, `SystemObjectName.PERMISSION_SET`; the four names leave `PLATFORM_OBJECTS_BY_PACKAGE['plugin-security']` and `PLATFORM_PROVIDED_OBJECT_NAMES`. + - `@objectstack/core`: `isRowActive` and `ActivatableRow` — a catalog row's `active` column decides nothing; deactivation is the activation ledger's (`sys_metadata_activation`). + - `@objectstack/objectql`: the published `PLATFORM_OBJECT_TENANCY` record loses its `sys_permission_set` key (the other three names were never in it). A removed key of an exported constant is a narrowing, so `@objectstack/objectql` takes `minor` under the launch-window convention for breaking changes. +- 9847f87: fix(plugin-sharing): a minted share link carries an organization, never a NULL one (ADR-0131 D9) + + Clause-②: no + + - **What changed.** `ShareLinkService.createLink` stamps `sys_share_link.organization_id`. It takes the organization of the linked record, read under the system context. When the record names none, it takes the acting context's organization (`tenantId`). When neither names one, it applies the system-write rule: under `single`, the Default Organization (the only one there is); under a wall (`group` or `isolated`), the mint is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500) and no link is written. The same refusal answers a `single` install that holds no organization or several. Before, every link was written with `organization_id` NULL. + - **A link on another organization's record.** Under `group`, a member active in one organization can mint on a visible record of another. The link carries the record's organization, not the session's. + - **What stays the same.** Under `single` nothing a user sees changes. The mint response keeps the `ShareLink` shape and carries no organization field. A composition that registers no `sys_organization` object writes links as before. + - **Who can meet the refusal.** No in-repo caller reaches it. Both share-link routes need a signed-in caller. Under a wall, an organization-less API key is refused at admission, and an organization-less session cannot see a record that has a tenant column, so its mint is refused before the stamp. The refusal is reached by a walled, organization-less caller minting on an object with no tenant column, or by a system caller that threads no `tenantId`. The fix is to pass the organization on the context (`{ isSystem: true, tenantId }`). +- 1c563af: Setup → Record Shares opens on every share, not on the shares granted to the administrator. Before this, the entry named no view, and `sys_record_share` declared the caller-scoped "Granted to Me" view (`recipient_id = {current_user_id}`) first. + + Clause-②: no + + - `sys_record_share` now declares its unscoped "All" view (`all_shares`) first. "Granted to Me" and "Granted by Me" follow it, still as tabs. No view is added, removed or changed. + - The Setup entry `nav_record_shares` now names `all_shares` with `viewName`, so it does not depend on the declared order. + - The generated translation bundles follow the new view order. No translated text changed. + - ⛔ No schema, parse, export or accept-set change. +- 4f4c4ed: fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`). + + **The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`: + + - **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. + - **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none. + + **Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`. +- b3a3634: docs: four contract docblocks now describe what the code decides. No type, schema or runtime change. + + Clause-②: no + + - **`ControlledByParentWriteDenialLeg` (`@objectstack/spec`).** The `master_chain` leg has three resolution refusals: no relation to derive from, a row that is not present, and an empty master reference. Each names the master whose own master the walk could not derive. That master is the record's own master, when it is itself `controlled_by_parent`, or any master above it. The docblock said only a master above the record's own master. The next sentence now says "past the record itself" where it said "above the first hop", so the two agree. The `record_not_found` bullet of `ControlledByParentWriteUnresolvedReason` had the same first-hop gap for a missing master row: it now says an absent row of a master that is itself `controlled_by_parent` is the `master_chain` leg, on the first hop or above it, and the legs judge a missing row only where the master governs its own rows. + - **`ISharingService.canEdit` (`@objectstack/spec`) and `SharingService.canEdit` (`@objectstack/plugin-sharing`).** The caller lists no longer name the `sys_attachment` and `sys_comment` parent gates. Those gates read `checkEdit` and the master-detail write check. The `ISharingService.canEdit` sentence on how they reach the service now names `checkEdit`. + + `ControlledByParentWriteDenialLeg`, `ControlledByParentWriteOutcome`, `canEdit` and `checkEdit` keep their declared shapes, and no verdict, outcome or refusal changes. +- 3c1a046: fix: a route's catch-all `500` answers its own generic text, never the caught error's message, and `@objectstack/types` exports the writer that does it, `sendServerFault` + + Clause-②: yes (widening) — `@objectstack/types` gains one export, `sendServerFault`; nothing is removed, renamed or narrowed, and no response field changes shape. + + A declared 5xx does not carry the text of the error it caught: that text can name server internals, such as a path, a host or a table. The REST layer already withheld it. The storage, settings and i18n route families and the share-link routes answer in the nested `{ success: false, error: { code, message } }` envelope and did not: their catch-all `500` sent the caught error's message to the caller. + + - **What a caller now reads.** The same `status` and `code` as before, with the route's own generic text as `error.message`: `Internal error` on the storage and i18n routes, the route's message on the settings routes (`Failed to list manifests`, `Failed to read namespace`, `Failed to write namespace`, `Action failed`), and the route's `Failed to …` message on the share-link routes. On the storage routes this includes the metadata store's outage sentence, which an operator now reads in the server log. + - **What the operator reads.** The original error, stack included, in one `[5xx]` server-fault line. The storage byte routes, which already withheld the text, logged two lines per fault and now log one. A line names the mounted route pattern when the route passes one, never the request URL. + - **Unchanged.** Every `4xx` answer. The settings `SETTINGS_CRYPTO_UNAVAILABLE` refusal, which keeps its prescription. The authorization-store outage `503`, which keeps its declared code and message. The storage `501 NOT_IMPLEMENTED` answers. On the share-link routes, any error that declares its own `status` keeps the envelope it declares. + - **`sendServerFault(res, status, code, message, thrown, request?)`** (new, `@objectstack/types`): writes `message` in the declared envelope, and hands `thrown` to the server-fault log with its stack, as the fault's one line. Use it for an arm that answers a caught fault. Keep `sendError` for a `4xx` and for a `5xx` whose text you wrote for the reader. `request` is the mounted route pattern, never the request URL. +- 37510aa: feat(security): the guest's grants channel — an anonymous request at a guest-entry door holds the sets bound to the `guest` anchor, in the one organization the deployment resolves for it (ADR-0138 E1, D3/D4) + + ADR-0087 disposition: none. No authorable key, accept set or stored shape moves: no spec schema, metadata type, stored row or published response field is added, removed, renamed or re-shaped. A declared binding that was not enforced becomes enforced (ADR-0090 D9, as ADR-0138 D3 amends it). + + **The bindings that start granting.** On the reference apps, none: `app-showcase`, `app-crm`, `app-multi-package` and `app-todo` declare no definition of the `guest` anchor that names a set. `app-showcase` ships `showcase_guest_portal` as a capability an administrator may bind and binds it to no anchor (its `client_portal_user` position is an ordinary position), and `app-crm`'s `guest_portal` is the public form doors' set, which keeps its own posture. No reference app declares a permission set named `guest`, so no grant stops either. A set starts granting only where a deployment holds an environment definition of the `guest` position that names it. + + What changes: + + - **`@objectstack/core`** — the anonymous branch of `resolveAuthzContext` resolves the guest's grants on a face that takes the explicit guest entry (the new `guestEntry` input). It reads the sets the `guest` position's definition names, through the same position-bound read every position uses, and the activation ledger as for any held set. It resolves them only in the organization the guest entry's organization step derives (`resolveGuestOrganization`), which asks ADR-0131 D9's predicate and no second spelling of it. With nothing bound, the guest holds no set and no read is issued. The predicate itself, `resolveSystemWriteOrganization`, moves to `@objectstack/core` so both of its askers share it. New exports: `resolveGuestOrganization`, `resolveSystemWriteOrganization` (with `SystemWriteOrganizationDecision` and `SystemWriteRefusalReason`), and `GuestOrganizationUnresolvedError` with `isGuestOrganizationUnresolvedError` and its constants. + - **`@objectstack/objectql`** — `resolveSystemWriteOrganization` and its two types are re-exported from `@objectstack/core` as the same bindings. Nothing a caller imports changes. + - **`@objectstack/plugin-security`** — the set resolution treats the guest's sets as any requested set, and no longer folds the guest's position into the request. A permission set that merely shares the anchor's name no longer reaches an anonymous caller, and the baseline and `everyone` never did. At the CRUD gate, a guest that reaches a data operation acting in no organization, on a deployment where the organization step refuses, is refused before the operation runs. + - **`@objectstack/plugin-sharing`** — the read filter answers deny-all for the guest on every object that record sharing scopes, before the sharing-model branch: the guest reaches rows only through share links, never through the object's sharing model (ADR-0138 D3 item 6, as the maintainer's ruling C amends it). `externalSharingModel` is still not read for any caller. Share links are redeemed at their own door and are unchanged. + - **`@objectstack/runtime`** — the runtime face, which serves the guest, asks for the guest's grants. + + What an anonymous caller sees at an `authRequired: false` endpoint of `type: 'object_operation'`: + + - **One organization, a set bound to `guest`:** the operation runs as the guest, under that set, in that organization. A read the set grants is admitted and returns no row, whatever the object's sharing model (`private`, `public_read`, `public_read_write` or `controlled_by_parent`): the guest reaches a record only through a share link. A create the set grants lands in that organization with no owner. + - **Nothing bound:** `403 PERMISSION_DENIED`, as before. + - **A walled deployment, several organizations, or none:** `501 NOT_IMPLEMENTED`, naming the missing site binding (ADR-0138 D5), where it used to be `403 PERMISSION_DENIED` with nothing granted. Nothing that was served stops being served. The operator's log names the reason once. + + Unchanged: share links, which still serve an anonymous caller the one record they name, with `publicSharing.eligibility` checked at the mint and at every redemption; the public form doors, which keep their own posture and organization; the flow door, which runs under the flow's own declared posture; every surface that answers an anonymous request `401`; and every signed-in caller. + + Level: `minor` for `@objectstack/core`, because new exported symbols widen its published index; `patch` for the other four, whose exports do not move. +- 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. +- 28e294f: fix(plugin-sharing, runtime): a share link that has a password and is limited to signed-in users answers an anonymous visitor `401 SIGN_IN_REQUIRED`, whatever password was sent + + Clause-②: no — every request refused before is still refused; only which refusal code an anonymous caller reads changes. + + A share link can carry a password and the `signed_in` audience together. For such a link, the share-link resolve door answered an anonymous visitor from the password before the audience: `NEEDS_PASSWORD` with no password, and `WRONG_PASSWORD` with any password, the correct one included. The console's shared-record page then told a visitor who typed the right password "Wrong password.", and never offered sign-in. + + - **Anonymous caller, `signed_in` link:** `401 SIGN_IN_REQUIRED` with no password, a wrong one or the correct one. The answer is the same for all three, so it never tells an anonymous caller whether a password is right. Once signed in, the visitor is asked for the password as before. + - **Signed-in caller:** unchanged. `401 NEEDS_PASSWORD` with no password, `401 WRONG_PASSWORD` with a wrong one, and the record with the correct one. + - **Unchanged:** links with any other audience (a `public` link with a password still answers `NEEDS_PASSWORD` / `WRONG_PASSWORD` to an anonymous caller), the expired-or-revoked `410 EXPIRED_OR_REVOKED`, the `404 INVALID_OR_EXPIRED` for an unknown token or an object with public sharing switched off, and every link that resolves. + - Both mounts of the resolve door answer alike: plugin-sharing's share-link routes and the runtime dispatcher's share-links domain. +- 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 +- 099cc9d: Clause-②: no + + The approval, security, automation and sharing objects describe their records in plain admin + sentences. Approval Delegations now reads "While someone is away, approvals assigned to them + personally go to their delegate.", Record Shares "Records shared beyond their owner, one row per + grant.", User Positions "Which positions each user holds, one row per assignment.", Audience + Binding Suggestions "A package's suggestion to give one of its permission sets to everyone who + signs in, and the decision taken on it." and Automation Runs "Paused automation runs waiting to + resume, and the recent history of finished runs." Field help on Approval Requests, Approval + Delegations, User Positions, User Permission Sets and Audience Binding Suggestions, the + descriptions of the `organization_admin_no_bypass`, `mcp_agent_data_read` and + `mcp_agent_data_write` permission sets, the flow-dispatch ledger's description and the + reserved-position refusal no longer carry design-record numbers or runtime vocabulary; those + references stay in the code comments. The zh-CN, ja-JP and es-ES translations follow. Wording + only: no field, permission or behaviour changes. +- 6dede7a: fix(runtime, plugin-sharing): on the standalone server, an anonymous caller of any `/api/v1/notifications` verb or any `/api/v1/share-links` path off the two token faces is answered `401 UNAUTHENTICATED` in the dispatcher's standard envelope + + Clause-②: no + + The v18 text for these two doors reads "`/notifications`: `401 UNAUTHENTICATED` on every face" and "`/share-links`: `401 UNAUTHENTICATED` on the create, list and revoke faces and on every path the domain does not serve", in "the dispatcher's standard envelope … with the shared message every other dispatcher domain answers with". A host that reaches the dispatcher through `@objectstack/hono`'s catch-all already answered that way. The standalone server (the one `os dev` and `os serve` run) did not: + + - `/api/v1/notifications`, `/api/v1/notifications/read` and `/api/v1/notifications/read/all` were each mounted for the one verb they serve, so any other verb never reached the `notifications` domain and the HTTP adapter answered `405`, naming the allowed methods, to an anonymous caller as well. + - `/api/v1/share-links` is served there by `@objectstack/plugin-sharing`'s routes, not by the dispatcher. They registered five routes and nothing else, so another verb or path got the adapter's `405` or `404`, and the create, list and revoke faces answered `401` with a sentence of their own ("Sign in to list share links") and no `error.httpStatus`. + + What the standalone server answers now: + + - **Anonymous caller.** `401 UNAUTHENTICATED`, `{ success: false, error: { code: 'UNAUTHENTICATED', message: 'Authentication is required to access this endpoint.', httpStatus: 401 } }`, on every verb of the three notifications paths, and on every verb and path under `/api/v1/share-links` except `GET /share-links/:token/resolve` and `GET /share-links/:token/messages`, which the capability token still admits. A body the server cannot parse does not outrank this answer anywhere under `/api/v1/share-links`, nor on the notifications verbs no face serves. + - **Signed-in caller of a verb or path that is not served.** Still refused. On the notifications paths the domain answers it: `404 ROUTE_NOT_FOUND` from the dispatcher (or the `501` capability answer on a deployment with no inbox), where the adapter used to answer `405`. Under `/api/v1/share-links` the share-link routes answer it themselves: `404 ENDPOINT_NOT_FOUND`, naming the request, where the adapter used to answer `405` or `404`. No `Allow` header is sent on either. + + Every request that was served is served as before. A notifications sub-path no face names, such as `/api/v1/notifications/`, is still not mounted on the standalone server and is answered `404`. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [5a871e4] +- Updated dependencies [f8ef144] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [913fd6d] +- Updated dependencies [8eac321] +- Updated dependencies [de43524] +- Updated dependencies [a5a781b] +- Updated dependencies [04669a4] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [4840720] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [7879391] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [0b997ea] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [018b077] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [87cd458] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [4663801] +- Updated dependencies [3071d3e] +- Updated dependencies [d294951] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [24e064f] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- 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 [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cd86e41] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [76560a1] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [abb6df0] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [a33f3d5] +- Updated dependencies [d050a8f] +- Updated dependencies [b25d98a] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [077b75f] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [aa31569] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [d537a31] +- Updated dependencies [1bf5f6e] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [ec749fb] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [2f569a9] +- Updated dependencies [69147ab] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [9112bcd] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf16a0d] +- Updated dependencies [cf540a7] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [45bec43] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/objectql@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/formula@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Minor Changes diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 2b97f28fe9d..8efce0cac93 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Record-level sharing for ObjectStack — sys_record_share + middleware that enforces sharingModel + ISharingService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-webhooks/CHANGELOG.md b/packages/plugins/plugin-webhooks/CHANGELOG.md index c2eb2e604c0..0ad477fb4f0 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,505 @@ # @objectstack/plugin-webhooks +## 18.0.0 + +### Minor Changes + +- 191e043: feat(plugin-webhooks,runtime)!: declared webhooks are read from the registry — the boot seeder retires, no `sys_webhook` row is written for one, and it is switched through the activation ledger + + Clause-②: yes (narrowing) + + + + **BREAKING** (a narrowing of where a declared webhook can be edited), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0131 D2 says no seeder writes declared items into a table, and D6 charts behavioural types under regime C. A webhook declared with `defineStack({ webhooks })` now follows both, as declared sharing rules already do: the dispatcher reads the declaration from the metadata registry on every subscription-cache refresh, and `bootstrapDeclaredWebhooks` no longer runs. + + What changes: + + - **No `sys_webhook` row for a declared webhook.** Before: every boot wrote or refreshed one `managed_by: 'package'` row per declared webhook, shown and editable in Setup → Integrations → Webhooks. Now: a fresh install writes none, and Setup lists only the webhooks an organization authors. The declared webhook still delivers, with the same URL, method, triggers, `timeoutMs`, signing secret and headers; its secret and headers are code, held in memory, and never stored. Its deliveries carry `ref_id = declared:`. + - **A declared webhook is read-only, and switched through the activation ledger.** Before: an administrator edited the seeded row in Setup (`customized: true`). Now: switch it on or off for the deployment with `POST /api/v1/security/_activation/webhook/` and `{ "enabled": true | false }` — one `sys_metadata_activation` row of type `webhook`, which overrides the declared `isActive` in either direction, so a webhook shipped inactive is switched on the same way. The door is the declared-sharing-rule door with `webhook` admitted: `manage_metadata`, then the platform operator under a wall; `404` for a name nothing declares; `503` when no declared-webhook reader is bound or it cannot be read. The dispatcher refreshes in the request that wrote the row. Remedy for a change beyond on/off: author a copy under a new name as a `sys_webhook` row, and switch the declared one off. + - **`label` and `description` on a declared webhook are no longer shown anywhere.** The seeded row was their only reader. They are display-only, so no delivery changes; the liveness ledger marks them dead, owed enforce-or-remove. + + On a database an 18.0 install created (expand first — nothing is moved or deleted): + + - The rows the 18.0 seeder wrote stay where they are. A pristine one (`managed_by: 'package'`, not `customized`) is ignored in favour of its declaration, so a declared webhook is never delivered twice; it follows the declaration's URL and switch, as the seeder's rewrite on every boot made it do. + - A seeded row an administrator edited (`customized: true`), or a row an administrator authored under a declared name, answers as itself, and the declaration is not dispatched beside it — an administrator's `active: false` on a noisy webhook keeps holding. This is logged at `warn` once per name per process, with the remedy. + - A seeded row whose declaration was removed keeps delivering, as it did on 18.0. + - Redelivering an old delivery of a pristine seeded row is judged by the declaration that now answers for it, so a webhook switched off through the ledger is not re-sent. + + Deleting the seeded rows, and re-pointing their `sys_http_delivery` history at the declaration, is a step of the ownership migration in a later 18.x release. +- 0984817: feat(plugin-webhooks): the `webhooks` service serves the redeliver door from a web-standard `Request` (`IWebhookService.handleRedeliver`), and the redelivery veto no longer waits for realtime (#22756) + + Clause-②: yes + + - **What is new.** The plugin registers the `webhooks` service and implements its optional `IWebhookService.handleRedeliver(request)` member: the webhook redeliver door, `POST /api/v1/webhooks/redeliver`, from a `Request` to a `Response`, with no raw app, no Hono context and no `http.server`. A hosted tenant kernel owns no socket, so this is how the door reaches it; its one caller is the runtime HTTP dispatcher's `POST /webhooks/redeliver` domain, which lands separately. + - **One door, two faces.** The member and the self-hosted route run the same code: the same session check, the same active-organization tenant, the same replay through `MessagingService.redeliverHttp` and the same answer for every outcome. For the same request they answer the same status, the same `Content-Type: application/json` and the same bytes: `401 UNAUTHENTICATED`, `400 INVALID_REQUEST`, `400 MISSING_REQUIRED_FIELD`, `404 RESOURCE_NOT_FOUND`, `409 DELIVERY_NOT_ELIGIBLE`, `409 DELIVERY_NEVER_SENT`, `500 INTERNAL_ERROR`, or `200` with the row's id and new status. + - **The redelivery veto is installed wherever the door is.** The veto that refuses to replay a webhook delivery whose subscription is gone, or whose signing secret cannot be recovered, now needs only the data engine and the messaging service. It used to be installed only when realtime was present and `autoEnqueue` was not `false`, so on other kernels such a delivery was replayed. It is now refused with `409 DELIVERY_NOT_ELIGIBLE` there too. The `webhooks` service is registered only where the veto is installed. + - **The route is found under `http.server` too.** The plugin reads `http.server` first, then the `http-server` alias. A host that registers its server only as `http.server` now gets the self-hosted route; one that registers it under both names gets it once. + - **The self-hosted route is otherwise unchanged.** Where it was mounted before, it answers the same status, headers and bytes for the same request, and it is never mounted twice. +- 08b38cb: fix(services)!: the datasource admin, share-link, webhook redeliver and storage families refuse a session the ADR-0069 authentication policy holds + + Clause-②: no (narrowing) — a session ADR-0069 holds out of protected resources is refused by families that answered it; core's existing exports only, no published schema or export moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate. REST and the runtime dispatcher already refuse such a session. Four families of routes mounted straight on the host server never asked, so they served it as an ordinary signed-in caller: + + - **`@objectstack/service-datasource`**: the datasource admin family (`registerDatasourceAdminRoutes`). Its door now asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) about the session it already reads, before the anonymous floor and the capability check. + - **`@objectstack/plugin-sharing`**: the share-link family (`registerShareLinkRoutes`). Every route, the two capability-token routes included, is now mounted through one door that refuses a context carrying `authGate`. `SharingServicePlugin`'s resolver now puts the session's posture on that context, as REST does. + - **`@objectstack/plugin-webhooks`**: the redeliver door (`WebhookOutboxPlugin`), on both of its faces: the route the plugin mounts, and the `webhooks` service's `handleRedeliver`. + - **`@objectstack/service-storage`**: the storage family, the two capability-token raw-transfer routes included. Every route is now mounted through one door. The posture is bound where every other storage gate is bound, so both the plugin's own mount and the host door `mountStorageRoutes` carry it. When a gate feature is active, the door reads the session and refuses a held one. When the gate is active but the session cannot be read, the door answers the declared `503` (`SERVICE_UNAVAILABLE`), as REST does. A direct call of `registerStorageRoutes` mounts the family exactly as before, because like the other storage gates this one is bound by the package's composition. No exported declaration changes. + + - **Refused now:** a held session gets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message`, in each family's declared error envelope, before the family does anything else. This is the answer REST gives the same session. + - **Unchanged:** a session the policy does not hold is served exactly as before, and an API-key caller is unaffected because it has no session to hold. A CORS preflight is answered as before. + + What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before these routes answer them, as on every other protected route. If you mount the storage routes through `mountStorageRoutes`, the gate is bound from the kernel's `auth` service with nothing to configure. If you mount `registerShareLinkRoutes` yourself with your own `contextFromRequest`, set `authGate` on the context it returns for a held session; the door refuses on that field. + +### Patch Changes + +- 4f4c4ed: fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`). + + **The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`: + + - **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. + - **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none. + + **Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`. +- bb0db13: The webhook redeliver veto now refuses a delivery whose `sys_webhook` subscription is inactive. The `active` field declares that an inactive webhook is skipped by the dispatcher, and an operator's explicit replay is the dispatcher too, so the declaration now holds on both paths. + + Clause-②: no + + - FROM: the redeliver door (`POST /api/v1/webhooks/redeliver`, and the `webhooks` slot's `handleRedeliver`) replayed a finished delivery whose subscription had `active` switched off. The veto refused only a subscription that was gone, or whose signing secret could not be recovered. + - TO: the door answers `409 DELIVERY_NOT_ELIGIBLE` for that delivery and leaves the row as it is. The message names the webhook, says it is inactive, and names the remedy. The test is the dispatcher's own predicate, `active === true`, so a row whose `active` is null or absent is refused exactly as the dispatcher skips it. Every row the engine writes carries a boolean there: the field is required, with default `true`. + - Fix, for an operator who means to replay: re-activate the webhook, then redeliver. + + The gone-subscription and unrecoverable-secret refusals are unchanged, in text and in order. The inactive check runs after the gone check and before the secret check, so an inactive webhook whose key is also unrecoverable is refused as inactive, the first remedy to try. No code, status, field or export changes. +- af284b0: feat(spec): the remaining `getSession` readers read `AuthSessionApi`'s declared result, and none reads a top-level `activeOrganizationId` + + Clause-②: yes (widening) + + - **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains three optional `user` members**, each declared for a named reader: `isPlatformAdmin` (`boolean`, the ADR-0068 D2 rung plugin-auth's `customSession` derives) and `role` (`string | null`, better-auth's `admin` plugin scalar), both read by `judgePlatformAdmin` in `@objectstack/plugin-auth`; and `name` (`string`), read by the runtime dispatcher's AI-route `req.user` resolver as the display name. Every member is optional, so every implementer and caller typechecks unchanged. + - **The readers read through the declared type instead of `any` or a cast** (types only, runtime behaviour unchanged): `judgePlatformAdmin` and the four platform-admin mounts in `@objectstack/plugin-auth` (better-auth's own session type now has to fit the declaration at those reads), the REST server's two session reads (`@objectstack/rest`), the dispatcher's posture read, `resolveSessionData` and both `HttpDispatcher` reads (`@objectstack/runtime`), the cloud-connection and install-local session reads (`@objectstack/cloud-connection`), the webhook redeliver door (`@objectstack/plugin-webhooks`), and the storage session bridge (`@objectstack/service-storage`). + - **Fix — the top-level `activeOrganizationId` fallback is removed** from the storage upload stamp (`@objectstack/service-storage`) and from install-local's `resolveActiveOrgId` (`@objectstack/cloud-connection`). The active organization is read from `session.activeOrganizationId` alone, where the contract declares it. No in-process producer answers a top-level `activeOrganizationId` (better-auth's read is `{ user, session }`), so for the shipped auth plugin nothing changes. An auth service that answered only the flattened shape now gets no organization: the upload is not stamped, and install-local reports no active organization. To fix it, answer the active organization on `session.activeOrganizationId`, as `AuthSessionApi` declares it. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [a7a48b7] +- Updated dependencies [b88c356] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [0db5ad5] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [d294951] +- Updated dependencies [6a6a4d3] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [5d63c37] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [24e064f] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/service-messaging@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index 3db7510509f..5ca409d90ad 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Persistent, cluster-aware webhook dispatcher. Durable outbox + per-partition cluster.lock for exactly-once-ish delivery across nodes. See content/docs/concepts/webhook-delivery.mdx.", "type": "module", diff --git a/packages/qa/dogfood/CHANGELOG.md b/packages/qa/dogfood/CHANGELOG.md index 52452810e3a..752620202ff 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,763 @@ # @objectstack/dogfood +## 0.0.48 + +### Patch Changes + +- Updated dependencies [f8ef144] +- Updated dependencies [5b14b7f] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [666283a] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [913fd6d] +- Updated dependencies [8eac321] +- Updated dependencies [de43524] +- Updated dependencies [a5a781b] +- Updated dependencies [04669a4] +- Updated dependencies [f7eb860] +- Updated dependencies [92ce3af] +- Updated dependencies [b61353f] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [4840720] +- Updated dependencies [23dae89] +- Updated dependencies [c395405] +- Updated dependencies [29527f2] +- Updated dependencies [7c7741d] +- Updated dependencies [1e8b628] +- Updated dependencies [65a8eeb] +- Updated dependencies [5018fc3] +- Updated dependencies [bfc1539] +- Updated dependencies [3b8e2b1] +- Updated dependencies [14ccb1d] +- Updated dependencies [7879391] +- Updated dependencies [a789148] +- Updated dependencies [d61ed6a] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [959c209] +- Updated dependencies [17e4425] +- Updated dependencies [7d7943d] +- Updated dependencies [aa71c4d] +- Updated dependencies [19bb55e] +- Updated dependencies [19bb55e] +- Updated dependencies [ba8af87] +- Updated dependencies [6a49efb] +- Updated dependencies [99801d8] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [5c9bfb0] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [e7e6544] +- Updated dependencies [e7e6544] +- Updated dependencies [cd3d391] +- Updated dependencies [149294c] +- Updated dependencies [990341a] +- Updated dependencies [9646991] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [2fbe1fd] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [d112087] +- Updated dependencies [e078506] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [9847f87] +- Updated dependencies [78652c0] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [1eae21f] +- Updated dependencies [54f062b] +- Updated dependencies [06dbc4e] +- Updated dependencies [9f4b652] +- Updated dependencies [965a1bf] +- Updated dependencies [eab7236] +- Updated dependencies [f93bf12] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [a7a48b7] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [8a399b2] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [f0022c4] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [0db5ad5] +- Updated dependencies [93125ae] +- Updated dependencies [29678f2] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [56bf27a] +- Updated dependencies [e67ba80] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [4935c66] +- Updated dependencies [c6fe02d] +- Updated dependencies [c6fe02d] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [6ff6ed6] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [036bf1d] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4049cac] +- Updated dependencies [7ebbc01] +- Updated dependencies [0e9371f] +- Updated dependencies [0b7ba4d] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [ffb31fc] +- Updated dependencies [0b997ea] +- Updated dependencies [3513ac7] +- Updated dependencies [81bd9fa] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [0ee2d15] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [b15f8a1] +- Updated dependencies [345d3f3] +- Updated dependencies [f2626c7] +- Updated dependencies [e175479] +- Updated dependencies [59d993c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [fbb065f] +- Updated dependencies [27f0d83] +- Updated dependencies [a8f24b0] +- Updated dependencies [37c7114] +- Updated dependencies [b4ce5e2] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [ca135dc] +- Updated dependencies [e36ee53] +- Updated dependencies [1884948] +- Updated dependencies [3599fef] +- Updated dependencies [8a995b8] +- Updated dependencies [54c3ce1] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [00bee27] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [3403be8] +- Updated dependencies [f66c440] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [f8566a0] +- Updated dependencies [94379f4] +- Updated dependencies [dd986d8] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [ce3d0ad] +- Updated dependencies [ce3d0ad] +- Updated dependencies [e148ca9] +- Updated dependencies [557ae7c] +- Updated dependencies [f782f17] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [5495331] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d1d42ec] +- Updated dependencies [d303b3e] +- Updated dependencies [018b077] +- Updated dependencies [0fea05f] +- Updated dependencies [e194ab4] +- Updated dependencies [86bf9ed] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [25be876] +- Updated dependencies [ed1de8c] +- Updated dependencies [b98e984] +- Updated dependencies [76bc1e0] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [18d9990] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [3d0eeef] +- Updated dependencies [cb3bb93] +- Updated dependencies [83b8b80] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [a6f3a13] +- Updated dependencies [f53f14b] +- Updated dependencies [e8c6666] +- Updated dependencies [a800912] +- Updated dependencies [87cd458] +- Updated dependencies [86da194] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [3b5475a] +- Updated dependencies [7399080] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [896a434] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [0984817] +- Updated dependencies [322ff1e] +- Updated dependencies [680a86b] +- Updated dependencies [8bd0fcd] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [f313fbc] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [c11b758] +- Updated dependencies [f9ebb7d] +- Updated dependencies [bb0db13] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [6a40a09] +- Updated dependencies [1959aac] +- Updated dependencies [117817e] +- Updated dependencies [4663801] +- Updated dependencies [8e4a230] +- Updated dependencies [3071d3e] +- Updated dependencies [d294951] +- Updated dependencies [6a6a4d3] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [8024e12] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [a3d47c2] +- Updated dependencies [de24373] +- Updated dependencies [21c8644] +- Updated dependencies [dfb3ba2] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [d4aaeaf] +- Updated dependencies [5d63c37] +- Updated dependencies [dc93781] +- Updated dependencies [4cc3ff9] +- Updated dependencies [3d6392f] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [3490bf5] +- Updated dependencies [920bc12] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [6f4c518] +- Updated dependencies [08b38cb] +- Updated dependencies [87d0619] +- Updated dependencies [917b1a0] +- Updated dependencies [d9d7edd] +- Updated dependencies [4e0838d] +- Updated dependencies [d45d0e9] +- Updated dependencies [cb9841a] +- Updated dependencies [60f9baf] +- Updated dependencies [8d3dd6f] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [01d5034] +- Updated dependencies [f24574a] +- Updated dependencies [e790cfc] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [09ef77c] +- Updated dependencies [5d3018d] +- Updated dependencies [404fc47] +- Updated dependencies [be16feb] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [94924e0] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [24e064f] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [6d96721] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [c245986] +- Updated dependencies [28e294f] +- Updated dependencies [775ed65] +- Updated dependencies [0be162c] +- Updated dependencies [02a691a] +- Updated dependencies [e4ec6ec] +- 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 [b9dbc3d] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- Updated dependencies [1692cd1] +- Updated dependencies [0d784da] +- Updated dependencies [be50633] +- 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 [a35f7a6] +- 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 [9fcea20] +- Updated dependencies [0fb55fc] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [ee0e115] +- Updated dependencies [a6d0715] +- Updated dependencies [c2bb302] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [9f81260] +- Updated dependencies [e611e2b] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [b513343] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [30c33e3] +- Updated dependencies [7e6e4e1] +- Updated dependencies [462dff3] +- Updated dependencies [cd86e41] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [69351e9] +- Updated dependencies [395ca21] +- Updated dependencies [76560a1] +- Updated dependencies [4c21e57] +- Updated dependencies [9e0945c] +- Updated dependencies [23d0bc7] +- Updated dependencies [7428da0] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [b55be42] +- Updated dependencies [abb6df0] +- Updated dependencies [5b6c257] +- Updated dependencies [0b1179f] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [74b4362] +- Updated dependencies [f7a5ebe] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9d4fb3c] +- Updated dependencies [c5cf546] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [34b740f] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [94114c1] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [0cfd0c4] +- Updated dependencies [ae4dfaf] +- Updated dependencies [099cc9d] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [67e8792] +- Updated dependencies [2e15620] +- Updated dependencies [0cb45e9] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [b74434c] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [605aa01] +- Updated dependencies [a33f3d5] +- Updated dependencies [d050a8f] +- Updated dependencies [9e797a8] +- Updated dependencies [b25d98a] +- Updated dependencies [5fb8d15] +- Updated dependencies [a237d7f] +- Updated dependencies [03ac4ec] +- Updated dependencies [33228c5] +- Updated dependencies [5e97907] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [44fe210] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b75129f] +- Updated dependencies [b0a1df0] +- Updated dependencies [6e43b7b] +- Updated dependencies [6dede7a] +- Updated dependencies [727091c] +- Updated dependencies [077b75f] +- Updated dependencies [62f959d] +- Updated dependencies [ab3ba2c] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [62c4441] +- Updated dependencies [f792290] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [544b8d7] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [d702b18] +- Updated dependencies [a06f0d4] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [5a9a80b] +- Updated dependencies [1489952] +- Updated dependencies [8eccf51] +- Updated dependencies [8a88901] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [aa31569] +- Updated dependencies [9040945] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [d537a31] +- Updated dependencies [1bf5f6e] +- Updated dependencies [1bf5f6e] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [2bcd406] +- Updated dependencies [58a2faa] +- Updated dependencies [2bcd406] +- Updated dependencies [e74ec56] +- Updated dependencies [af284b0] +- Updated dependencies [cbfa5e4] +- Updated dependencies [14a8bd2] +- Updated dependencies [77e9f79] +- Updated dependencies [83dfbc0] +- Updated dependencies [9822b05] +- Updated dependencies [889bf12] +- Updated dependencies [7a8733f] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [e15897c] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [d79dc48] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [5da2e88] +- Updated dependencies [51ad8e4] +- Updated dependencies [ec749fb] +- Updated dependencies [c4526a7] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [2f569a9] +- Updated dependencies [69147ab] +- Updated dependencies [3a734e0] +- Updated dependencies [6868514] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [9112bcd] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [94e60c0] +- Updated dependencies [538f923] +- Updated dependencies [9524666] +- Updated dependencies [d65826f] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf16a0d] +- Updated dependencies [cf540a7] +- Updated dependencies [cf540a7] +- Updated dependencies [d8e11b5] +- Updated dependencies [44a5011] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [71fa166] +- Updated dependencies [4d12b23] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [e22defb] +- Updated dependencies [45bec43] +- Updated dependencies [964a237] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] +- Updated dependencies [cdf3777] + - @objectstack/objectql@18.0.0 + - @objectstack/runtime@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/plugin-approvals@18.0.0 + - @objectstack/formula@18.0.0 + - @objectstack/service-analytics@18.0.0 + - @objectstack/verify@18.0.0 + - @objectstack/plugin-audit@18.0.0 + - @objectstack/plugin-sharing@18.0.0 + - @objectstack/plugin-webhooks@18.0.0 + - @objectstack/plugin-email@18.0.0 + - @objectstack/plugin-auth@18.0.0 + - @objectstack/metadata@18.0.0 + - @objectstack/service-storage@18.0.0 + - @objectstack/plugin-security@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/service-messaging@18.0.0 + - @objectstack/service-datasource@18.0.0 + - @objectstack/types@18.0.0 + - @objectstack/mcp@18.0.0 + - @objectstack/connector-mcp@18.0.0 + - @objectstack/example-showcase@0.3.22 + - @objectstack/connector-openapi@18.0.0 + - @objectstack/connector-rest@18.0.0 + - @objectstack/plugin-pinyin-search@18.0.0 + - @objectstack/trigger-record-change@18.0.0 + - @objectstack/trigger-schedule@18.0.0 + - @objectstack/example-crm@4.0.100 + - @objectstack/example-multi-package@0.0.7 + ## 0.0.48-next.5 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 7685dcba5de..7e48fa66e03 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.5", + "version": "0.0.48", "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 8882d1f621a..4d460f605b1 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,344 @@ # @objectstack/downstream-contract +## 0.0.46 + +### Patch Changes + +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0 + ## 0.0.46-next.5 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index 3f5af324c1d..a10a3806107 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.5", + "version": "0.0.46", "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 ac8400352e5..9a5870389fe 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,61 @@ # @objectstack/http-conformance +## 0.1.8 + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [9a7838e] +- Updated dependencies [b2e7870] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [66949b7] +- Updated dependencies [41d0d40] +- Updated dependencies [eebaf3b] +- Updated dependencies [4578c56] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [3089848] +- Updated dependencies [db9cf80] +- Updated dependencies [35ef501] +- Updated dependencies [874a38d] +- Updated dependencies [91f46d0] +- Updated dependencies [d7b26df] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [b7cd1af] +- Updated dependencies [51f36e7] +- Updated dependencies [87e902d] +- Updated dependencies [1e62f71] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [b4d573a] +- Updated dependencies [f5ef054] +- Updated dependencies [37510aa] +- Updated dependencies [0f05907] +- Updated dependencies [086fa46] +- Updated dependencies [1d79556] +- Updated dependencies [daafae6] +- Updated dependencies [be48c5e] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [4ffdebe] +- Updated dependencies [e246f06] +- Updated dependencies [2ac0616] +- Updated dependencies [b036ac5] +- Updated dependencies [62e2b6d] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + ## 0.1.8-next.5 ### Patch Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index ed4ab5f254f..ca69eec7c1d 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.5", + "version": "0.1.8", "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 d0ca834fc84..23db8a5d248 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,1399 @@ # @objectstack/rest +## 18.0.0 + +### Minor Changes + +- 60bcfe7: feat(plugin-security,rest,spec)!: the permission-set "Discard Overlay" action, its REST route and its contract member are retired; no door creates the overlay it discarded, and a database holding one is refused at cold boot (ADR-0131 cutover, ADR-0048) + + Clause-②: no (narrowing) + + + + **BREAKING** — a narrowing of the public surface: one HTTP route, one Setup action and one optional contract member are removed. Shipped as `minor` under the launch-window convention for breaking changes. + + The action deleted the environment-wide `sys_metadata` row that shadowed a package-declared permission set, then re-projected the set's `sys_permission_set` row. Neither half has anything left to do: + + - No door creates such an overlay. The metadata door refuses a save over a packaged permission set on every topology, whatever `OS_METADATA_WRITABLE` says. The data door refuses an edit of one (clone it instead). The data API cannot write `sys_metadata`. A package that declares a name the environment holds is refused at install. + - An overlay stored before those refusals makes the cold boot refuse the deployment with `422 NAMESPACE_CONFLICT`, so a server that could answer the route never holds one. + - The authorization resolver reads the security catalog, not `sys_permission_set` rows, so the re-projected row granted nothing. + + ### What was removed + + | removed | what to use instead | + | --- | --- | + | `POST /api/v1/security/permission-sets/:id/discard-overlay` | `os migrate security-catalog-overlays` with the flags and environment the deployment boots with lists every overlay row with no server running; `--apply` deletes them (a history tombstone per row). | + | the **Discard Overlay** action on `sys_permission_set` in Setup | the same offline step. To keep a customization of a packaged set, clone it under a new name (the **Clone** action) and assign the clone. | + | `ISecurityService.discardPermissionSetOverlay` and `PermissionSetOverlayDiscardResult` (`@objectstack/spec/contracts`) | nothing: delete the call and the import. `tsc` names each one. | + | `discardPermissionSetOverlay`, `PermissionSetNotFoundError`, `PermissionSetOverlayStateError` and the `PermissionSetOverlayDiscardDeps` / `PermissionSetOverlayDiscardResult` types (`@objectstack/plugin-security`) | nothing: delete the import. | + + **The one-line fix:** stop calling the route or the member, and clear any overlay offline with `os migrate security-catalog-overlays --apply` before the first boot on this release. + + `ERROR_CODE_LEDGER['@objectstack/plugin-security']` no longer lists `INVALID_STATE` or `NOT_FOUND`: the removed action was their only producer in this package. Both codes stay registered under `@objectstack/rest`, and the union of codes is unchanged. + + The permission-set drift diagnostic no longer names the action in its `overlay_shadow` detail, and the boot reading of shadowed packaged sets names the offline step instead. +- 06cc45d: Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). + + **Why.** The catalog is environment-level, and every organization resolves it by name. Measured on a booted `isolated` and `group` stack: an organization owner granted `manage_metadata` inside their own organization saved `PUT /meta/permission/:name` over a permission set another organization's member holds, and that member's next resolution carried the change. One organization's write changed what another organization's members resolve. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for `position` or `permission`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. + - The same doors for `capability`: admitted where they were admitted. `capability` is a code-only type, so these doors already refused its writes before this change; only the sentence the caller reads changes, to the posture refusal. For `capability`, the accept set narrows at the data door on `sys_capability`, below. + - `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. + - A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. This includes an ADR-0090 D12 delegate whose `adminScope` carries `authorEnvironmentSets`: under a wall it can no longer author environment permission sets at the data door. Its assignment and binding paths are untouched. + + **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. + + **The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-tenant-writes-retired`. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. +- 7895671: feat(metadata-core,rest,runtime,plugin-email,spec)!: the `/meta` doors carry no organization; organization-admin metadata authoring closes and `manage_org_presentation` retires (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio belongs to the whole deployment. The `/meta` doors of both transports (`@objectstack/rest` and the runtime dispatcher's `/meta` branch) used to thread the caller's active organization into writes and reads of the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`). They no longer do, for any type, and the read and the write flip together. + + **What changes.** + + - **Writes land environment-wide.** `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` on `/api/v1/meta/:type/:name` hand the protocol no organization, so the row and its audit and history rows carry `organization_id` NULL, whatever the caller's active organization. + - **Reads are environment → code.** The item read (both cache arms), the list, the layered view (`/layers` and `?layers=true`), `/published`, `?state=draft`, `GET /meta/_drafts`, `/history`, `/diff`, `/audit` (the environment rows, `organizationId: null`), `GET /meta/diagnostics` and `/references` name no organization. + - **An organization admin's metadata write is refused.** `metaWriteCapabilityVerdict` admits `isSystem` or `manage_metadata` only. A caller holding `manage_org_presentation` and not `manage_metadata` is answered `403` on all four item doors (`FORBIDDEN` on REST, `PERMISSION_DENIED` on the dispatcher) with `… requires the \`manage_metadata\` capability.`, for every type and whatever its active organization. + - **`manage_org_presentation` retires** from `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`). A permission set naming it still parses and loads, and its other grants still apply; the grant itself admits nothing. The `sys_capability` row seeded for it earlier is not pruned (the seeder upserts only); an operator may delete it in Setup. + - **The email-template boot sweep** (`@objectstack/plugin-email`) reads the effective templates environment → code, as the door serves them; it no longer reads in the Default Organization. + + **What moves for consumers.** + + - FROM `import { organizationIdForMetaWrite } from '@objectstack/metadata-core'` TO nothing: a `/meta` write carries no organization. Delete the call and the `organizationId` it fed. + - FROM `import { ORG_PRESENTATION_AUTHORING_CAPABILITY } from '@objectstack/metadata-core'` TO nothing: delete the import. + - FROM `metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType, activeOrganizationId, operation })` TO `metaWriteCapabilityVerdict({ isSystem, systemPermissions, operation })`: drop the two members. + - FROM `import { metaReadOrganizationId } from '@objectstack/rest'` TO nothing: a `/meta` read carries no organization. `metaCallerOrganizationId` stays. + - FROM `bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy })` TO `{ protocol }`: the `tenancy` source is gone. + - A permission set granting `manage_org_presentation`: grant `manage_metadata` to whoever must author those five types, and delete the stale grant. + + **What a deployment observes.** An overlay row an earlier release stored under an organization stays in `sys_metadata` untouched, and is no longer served by any `/meta` read or projected by the email-template sweep until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Under the `single` posture that is every earlier Studio save of the five types, because it was filed under the Default Organization: on the `/meta` doors such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. + + **What does not change.** Saves of every other type were already environment-wide. Flow saves, the capability gate's answer for `manage_metadata` holders and `isSystem`, the protocol's own organization-scoped refusals, and the `/packages` doors are untouched by this change. +- c8b062f: feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. + + **What changes.** + + - The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. + - `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. + - `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. + - `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. + - [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling instead: the reads do not decline it, a cold boot refuses a deployment that holds one (ADR-0048), and the offline `os migrate security-catalog-overlays` removes it. + - `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. + - Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. + - The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. + - The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | + | `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | + | `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | + | `SysMetadataRepository.listDrafts` row `organizationId` (`@objectstack/metadata-protocol`) | gone: every listed row is an environment draft, whichever repository asks | + | `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | + | `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | + | `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | + | `organizationId` on `projectPermissionMutation`'s `evt` (`@objectstack/plugin-security`) | drop it: the projection reads the environment → code layered read, whatever organization the event names | + | a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | + | an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | + + **What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. +- 8f2e808: feat(meta): the `/meta` item read serves the ADR-0008 version token, `If-None-Match: *` pins a save that expects no row, and the 409 `METADATA_CONFLICT` body carries the current version as data + + Clause-②: yes (narrowing) + + **BREAKING** for two request shapes on `PUT /api/v1/meta/:type/:name` that were answered `200` before this release and are now refused `400 VALIDATION_ERROR`, with nothing written: an `If-None-Match` header whose value is anything but `*` (an entity-tag, a list, a weak `W/"*"`, an empty value), and an `If-None-Match` header sent beside `If-Match`. Before, the door did not read `If-None-Match` at all and wrote the body, unguarded or under the `If-Match` alone. The remedy: send `If-None-Match: *` alone to save only where no row exists, or `If-Match` with the version you read alone to save only over that version. No first-party client sends `If-None-Match` on a `PUT`: the SDK sends it only on its cached `GET`, and objectui's ETag hook has no caller. + + ADR-0008's optimistic lock on `PUT /api/v1/meta/:type/:name` takes the version a client read as `If-Match`, but a client can only send a token it was served, and only a save receipt served one. So the first save after a load could not be pinned: two editors who each loaded an item and saved once overwrote each other. Three additions close that. + + - **The read serves the token.** `GET /meta/:type/:name` now carries `version` (declared on `GetMetaItemResponseSchema`): the keyed token of the stored row a save to this item would compare against, at the read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` for the draft row, the plain read for the active row). It is the same producer and the same row as the save receipt's `version`, so a read after a save serves the receipt's token byte for byte. Send it back as `If-Match`. `null` means no stored row is there (an item served from code or a package artifact, or from a row in a scope the save does not write), so the next save is a create. The cached published-value branch (the default plain read) and `?preview=draft` publish no `version`, as they publish no `lock`; their `ETag` stays the cache validator and is not the token. + - **"Expect no row" can be said.** `If-None-Match: *` on `PUT /meta/:type/:name` (with `?mode=draft` for a draft) saves only where no row of that lifecycle exists, and is refused `409 METADATA_CONFLICT` once one does. This is the `parentVersion: null` pin `SaveMetaItemRequestSchema` already declared, now reachable over HTTP. The header takes `*` alone: any other value, or `If-None-Match` beside `If-Match`, is refused `400 VALIDATION_ERROR` and nothing is written. No first-party client sends `If-None-Match` on a `PUT`. A save with neither header is last-writer-wins, as before. + - **The conflict body names the current version as data.** The 409 of the `/meta` item write doors (save, publish, rollback, reset) now carries `currentVersion` beside the unchanged `error` sentence: the token the sentence names, or `null` when no row of the target lifecycle exists (declared as `MetadataConflictErrorSchema`). On the save door it equals the token the next read at the same address serves, so a client offering "reload" or "overwrite" re-pins without parsing prose. The SDK already exposes it as `err.details.currentVersion`. + - **The receipts' `version` describes are corrected.** `SaveMetaItemResponseSchema.version`, `PublishMetaItemResponseSchema.version` and the package publish door's `published[].version` said "Content hash … currently emitted as `sha256:`" and "409 `metadata_conflict`". The token has been the keyed `hmac-sha256:` digest of the stored content hash since the doors stopped serving the raw hash, and the conflict code on the wire is `METADATA_CONFLICT`. Only the description text moves. + + +- 3aa0c7b: `metaSaveRequestOptions` is exported: the one mapping from a `PUT /meta/:type/:name` request's `If-Match`, `If-None-Match` and `?mode=draft` to `saveMetaItem`'s `parentVersion` and `mode` + + Clause-②: yes (widening) + + - `RestServer`'s `PUT /api/v1/meta/:type/:name` door now reads its precondition and its lifecycle through this function. The runtime dispatcher's `/meta` door, the one behind the `@objectstack/hono` catch-all, reads them through it too, so the two doors cannot answer the same save differently. It accepts a `Headers`-like object (`get`) or a plain header record, plus the query. It answers `{ ok: true, request }`, where `request` holds only the members the caller asked for, or `{ ok: false, message }`, the sentence of a `400`. + - Unchanged: `RestServer`'s answers. The token still has ETag quotes stripped, `If-None-Match: *` still pins "no row of this lifecycle", and the two `400` refusals keep their wording. `?mode=draft`, matched case-insensitively, still stages a draft. Any other `mode` value is still an active save. +- fbcbcf1: `metaItemPackageBinding` is exported, and every `/meta` door that reads `?package=` reads it through that function, so `?package=all` names no package on the layered read, the list, the book tree and the diagnostics sweep + + Clause-②: yes (widening) + + - `metaItemPackageBinding(raw)` answers the package a request's `?package=` names, or `undefined` for `all` (the metadata list's "show everything" scope), the empty value or a non-string. The item read, the save and the publish already read `?package=` through it. The runtime dispatcher's `/meta` domain now reads it through the same function. + - `GET /meta/:type/:name/layers?package=all` answered `404` for an item stored in a package, and now serves the layers that the read without `?package=` serves. The Studio editor sends this read when it is opened from the list's "show everything" scope. + - `GET /meta/:type?package=all` answered `[]`, and now lists what `GET /meta/:type` lists: the items of every package and the env-local ones. + - `GET /meta/book/:name/tree?package=all` served the empty implicit book of a package called `all`, and now resolves the declared book as the read without `?package=` does. + - `GET /meta/diagnostics?package=all` swept nothing, and now sweeps what the plain sweep sweeps. + - Unchanged: a real package id still scopes each of these reads. The item read's cache bypass still reads the raw parameter, so `?package=all` keeps the uncached read and its served `version`. +- f4069c5: fix(metadata): the object read's `sortability` follows the caller's field permission for every caller class + + Clause-②: yes (widening) + + - **What was wrong.** ADR-0106 D4 serves a caller holding `manage_metadata`, `studio.access` or `setup.access` (a platform admin, and an organization admin through `setup.access`) the object schema UNMASKED, because Studio and Setup authoring need the whole definition. The `sortability` projection served beside it on `GET /meta/object/:name` (and the runtime dispatcher's `/metadata/object/:name`) was derived from that unmasked document, so it marked a field `sortable: true` that the same caller's field permission marks unreadable, while the data route refuses a sort or filter on that field with `403 PERMISSION_DENIED`. A client building its sort picker from the projection offered a click the server refuses. + - **What changes.** `sortability` is now derived from the caller's runtime view: the served document for every caller the mask projects (unchanged), and for a D4-exempt caller the fields their own field permission reads. The definition (`item.fields`) is unchanged for every class: whole for an exempt caller, masked for everyone else. The data route and `/auth/me/permissions` are unchanged. + - **Caching.** On the cached `GET /meta/object/:name` branch an exempt caller's ETag folds a fingerprint of the fields withheld from their projection, and the conditional request is judged after it is folded. An exempt caller whose permission withholds nothing keeps the byte-identical ETag. An exempt caller and a projected caller denied the same field never share a validator. + - **Failure posture.** The exempt caller's field permission is asked lazily, only by the item read that serves `sortability`, and never turns their definition read into a fault: if the security service throws or cannot answer, `sortability` is derived from the served document as before, with one `warn`. + - **New in `@objectstack/metadata-core`.** `resolveObjectSchemaRuntimeView(document, posture)` returns `{ document, withheld, fingerprint }`, the view the runtime projections beside an object schema are derived from. The types `ObjectSchemaRuntimeView` and `ObjectSchemaRuntimeFieldPosture` are exported with it, and the `passthrough` member of `ObjectSchemaMaskPosture` gains an optional `runtime` (set on an `exempt` posture for a capability-exempt caller in a deployment with a `security` service). + - **New in `@objectstack/rest`.** `translateMetaEnvelope` takes an optional fifth argument, `runtimeDocument` (default: the served document), and `MetaItemAnswerSources.translateEnvelope` an optional third. Nothing is removed, renamed or narrowed. +- 166a94f: fix(rest)!: the API-description endpoints refuse an anonymous caller + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `RestServer.registerOpenApiEndpoints` mounts the API-description endpoints: the document and its viewer page. Until now they served a caller with no session, while the data and metadata routes beside them refused one. ADR-0056 D2 denies anonymous callers by default. + + - **Refused now:** a caller with no session, with `401` and code `UNAUTHENTICATED` — the same body the data routes answer. Both handlers check before any other work, so the refusal does not say whether a document is bundled. + - **Also refused:** a session held by an auth policy (an expired password, enforced MFA) gets that policy's `403`, as on every other protected route. + - **Unchanged:** a signed-in caller is served the same document and the same viewer page as before. The viewer page loads the document from the browser with the browser's session, so a signed-in browser sees the viewer work as before. + + What changes for you. If you published the API description to readers without an account, have them sign in first, or publish a static copy of the document instead. +- 7806a14: fix(rest)!: an anonymous public-form submit answers the created record's id, and nothing the insert stored + + Clause-②: no (narrowing) + + + + **BREAKING** (a response narrowing): this ships as `minor` under the launch-window convention for breaking changes. + + `POST /api/v1/forms/:slug/submit` used to answer `201` with the protocol's whole create answer, `{ object, id, record, droppedFields? }`. The `record` was the row as stored after the insert pipeline. So the anonymous caller was shown every field it never sent: a `defaultValue`, a field a `beforeInsert` or `afterInsert` hook stamped, and a value a hook running elevated (`runAs: 'system'`) derived from existing records the caller's grant may never read. The form's field whitelist filtered what the caller could write. Nothing filtered what it was then shown. + + The answer is now the created id alone: + + ```json + { "id": "r7p8cUZoBJbFWudt" } + ``` + + - **Still `201`**, as a bare JSON object with no envelope. The created id stays at the top-level `id`, where the console's public form page reads it, so its confirmation and `redirect` behaviors keep working. + - **What the submitter typed is not echoed back either.** The caller already holds it. On the console's `redirect` behavior, a `{{record.field_name}}` token over a submitted field still resolves from the submitted values, and `{{record.id}}` from the answer. A token over a field only the server fills in now resolves empty. That value is exactly what this change stops serving. + - **The write is unchanged**: the same whitelist, the same server-managed anchors stripped, the same grant, the same hooks. Only the answer shrank. + + **If your host read the record off this answer**, read it through an authenticated read instead: `GET /api/v1/data/:object/:id` with the id from the answer, as a principal allowed to see that record. +- 5081090: feat(rest): the `/meta` read gate enforces a list view's and a dashboard's `requiredPermissions` (#22639) + + Clause-②: yes (widening) + + - **What the server now does.** `requiredPermissions` on a list view and on a dashboard is a list of capabilities a user must ALL hold, the same key and meaning as on an app, a navigation item and an action. The `/meta` read gate applies it through the one predicate the app arm uses (`holdsRequiredPermissions`), on both transports (`RestServer` and the runtime dispatcher's `/meta` domain): + - `GET /api/v1/meta/view` and `GET /api/v1/meta/dashboard` list only what the caller may open. A view the caller does not hold is left out, so the console's view switcher (`?object=`) lists only the views the caller may open. A view container is listed minus the `list` / `listViews` entries the caller does not hold. + - `GET /api/v1/meta/view/:name` and `GET /api/v1/meta/dashboard/:name` refuse a view or a dashboard the caller does not hold with `403 PERMISSION_DENIED`, the same refusal and envelope an app the caller may not open gets. Every alternate door of the read refuses it too (`/published`, `/layers`, `?layers=`, `?state=draft`, `/diff`, `/history`, `/audit`), to an author as to anyone. + - `GET /api/v1/meta/object` and `/meta/object/:name` serve an object without the `listViews` entries the caller does not hold. A caller who may edit the object (the ADR-0106 D4 exemption the field mask already honours) reads every list view, so a save of what they loaded keeps them. + - A view container is served whole to a caller who may write it on the stored-version doors (`/layers`, `?layers=`, `/diff`, `?state=draft`), as an app is, so a designer's save keeps the entries withheld from its author elsewhere. + - **Caching.** The plain read's cached arm runs the same gate for `view` and `object`. Its `ETag` folds in what the gate withheld, and the conditional request is judged against that validator, so a caller who no longer holds a capability is never answered a `304` for the body that carried the gated view. When nothing is withheld the `ETag` is the same as before. + - **Type surface.** `MetaItemReadRefusal` gains the `audience-permission` reason (`403 PERMISSION_DENIED`). A consumer that switches on `reason` writes it the way it writes `app-permission`. + - **Nothing to migrate.** Only a list view or a dashboard that sets `requiredPermissions` is affected. The key was not accepted on either before the release that declared it. +- 038d4c9: feat(metadata-protocol): one language of an email template is read, reset, published, viewed in history and reverted by name — the by-name `/meta` doors accept a bundle member selector (#22877) + + Clause-②: yes + + **What an administrator can now do.** An email template kept in several languages is one item per language in the store. Each language can now be addressed by name on every by-name door, with `?member=` naming the language: + + - `GET /api/v1/meta/email_template/:name?member=zh-CN` reads the `zh-CN` wording: the stored customization when there is one, else the package's `zh-CN` template. A language that nothing stores or ships answers as an absent item (404). + - `DELETE /api/v1/meta/email_template/:name?member=zh-CN` resets the `zh-CN` customization alone; the other languages keep theirs. + - `POST /api/v1/meta/email_template/:name/publish?member=zh-CN` publishes the `zh-CN` draft. + - `GET /api/v1/meta/email_template/:name/history?member=zh-CN` lists the `zh-CN` saves. All languages of one template share one version counter, so the version numbers of one language are not consecutive. + - `POST /api/v1/meta/email_template/:name/rollback?member=zh-CN` reverts the `zh-CN` customization to one of its own versions. A version saved for another language answers `404 VERSION_NOT_FOUND`. + + With no `?member=`, every door addresses the canonical member (`en-US`), exactly as before; naming `en-US` is the same request. This closes the window the previous release's changeset stated: a non-`en-US` language could not yet be reset, published, listed or reverted by name. + + **The selector.** It is named `member`, after what the store keys a row by, and not `locale`: on `/meta`, `?locale=` already chooses the language the response is translated into. The protocol requests gain one optional field, `member`, on `GetMetaItemRequest`, `DeleteMetaItemRequest`, `PublishMetaItemRequest` and `HistoryMetaItemRequest`. A wrong value is refused with `400 VALIDATION_ERROR`, naming the parameter: the selector on a type whose items have no languages (every type except `email_template` today), a blank value, or a value longer than a stored member can hold (100 characters). + + **Each of the five routes now declares its query parameters.** `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` refuse a query parameter they do not read with `400 VALIDATION_ERROR`, naming the parameter and listing the supported ones, instead of ignoring it. A misspelled `?memeber=zh-CN` is therefore refused, not answered with the `en-US` wording. The supported names are the ones each route has always read, plus `member`: + + - `GET /meta/:type/:name`: `layers`, `state`, `preview`, `package`, `locale`, `member`. + - `DELETE /meta/:type/:name`: `state`, `dropStorage`, `member`. + - `GET /meta/:type/:name/history`: `sinceSeq`, `limit`, `member`. + - `POST /meta/:type/:name/publish`: `package`, `member`. + - `POST /meta/:type/:name/rollback`: `toVersion`, `member`. + + A client that sent any other parameter to these routes was having it ignored; it now gets a 400 and can drop it. The deprecated `?layers=` view reads no language, so `?layers=` together with `?member=` is refused rather than answered with the `en-US` layers. A read naming a member skips the shared read cache. + + **What else now follows the language** (`@objectstack/objectql`, `@objectstack/metadata-protocol`): + + - A by-name read that names no language answers the `en-US` template even when only another language is customized. Before, the registry's read of the name could answer the other language's customization. + - Resetting one language keeps the other languages' customizations served at once, not only after the next restart. + - Publishing, discarding and reverting a package's changes (`POST /packages/:id/publish-drafts`, discard, and commit revert) address each language's draft or change as its own item. Before, a package whose only pending draft was a second language failed to publish (`NO_DRAFT`), and a discard reported that draft discarded while it stayed. The draft list and the commit timeline keep the fields they declare. + - In a cluster, a save, reset or revert of one language converges the other servers on that language. + + **For code that uses these packages directly.** Every addition below is optional; code that does not pass it behaves as before, except the bare-name read noted in the first item. + + - `SchemaRegistry` (`@objectstack/objectql`) takes an optional last argument, `member`, on `getItem`, `getArtifactItem`, `removeRuntimeShadow` and `removeOverlayEntry`. It is the discriminator value of one bundle member, such as `'zh-CN'`. + - `getItem(type, name, packageId, member)` answers that member, in the usual order (customization, then the requested package, then any package), or `undefined`. With no `member`, `getItem` now answers the canonical (`en-US`) member of a bundle. A bundle with no `en-US` member still answers its first member. + - `getArtifactItem(type, name, packageId, member)` answers that member's packaged definition. + - `removeRuntimeShadow(type, name, member)` and `removeOverlayEntry(type, name, member)` remove that member's customization entry only. With no `member`, they remove the whole bundle's customization entries, as before. + - A type whose items have no languages ignores the argument. + - `MetadataMutationEvent` (`@objectstack/metadata-protocol`) has an optional `member`: the language the mutated row belongs to. It is present only for a non-`en-US` language. Listeners registered with `onMetadataMutation` receive it. It also rides `ClusterMetadataMutationPayload`, and a server that receives it re-reads that language's row. + - The entries that `SysMetadataRepository.listDrafts` returns have an optional `member`, present for a draft of a non-`en-US` language. The protocol's draft list (`GET /meta/_drafts`) and a package's commit timeline serve it too, on every entry of a template, with the canonical `en-US` spelled out. +- 8024e12: fix(runtime)!: the runtime dispatcher's by-name `/meta` doors ask the query decision `RestServer` asks, so `?member=` is honoured on the item read and refused where it is not read, never dropped + + `@objectstack/rest` exports one portable decision for the query a by-name `/meta` door accepts, `metaItemQueryRefusal(door, query)`, and the bundle member selector's one reading, `metaMemberSelector(query)`. Both transports call them, so a door's closed set, its sentences and their order are one answer. On a host whose `/meta` is answered by the runtime dispatcher alone (the `@objectstack/hono` `createHonoApp` catch-all), the item read, `PUT`, `/layers` and `/published` declared no closed query set: `GET /meta/email_template/x?member=zh-CN` was answered with the canonical member, a misspelling such as `?memeber=` the same, and `PUT ?member=zh-CN` beside an `en-US` body wrote `en-US`. + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What each runtime dispatcher door accepts now, and what to do when it refuses. The answers are `RestServer`'s, in the dispatcher's own envelope, `{ success: false, error: { code: 'VALIDATION_ERROR', message } }`, and the message names the refused parameter: + + - `GET /meta/:type/:name` accepts `layers`, `state`, `preview`, `package`, `locale` and `member`, the six names `RestServer`'s item read accepts. `?member=` now reads that member of a bundled type (an `email_template`'s `zh-CN`); a member the bundle does not hold is `404`, and a selector on a type with no members is the protocol's located `400`. A single-valued name supplied twice is refused, and `?member=` beside `?layers=` is refused (the layered view shows the canonical member's layers). Read one member with `?member=` alone. + - `PUT /meta/:type/:name` accepts `package` and `mode`. It takes no `force`: this door never honoured one, and its `409` remedy already says so. Drop `?force=`, and send a body that keeps what the stored item carries. A save is addressed by its body: to save one member of a bundled type, put that member's own discriminator in the body and drop `?member=`. + - `GET /meta/:type/:name/layers` accepts `package`. Read one member with `GET /meta/:type/:name?member=…`. + - `GET /meta/:type/:name/published` accepts no query parameter. Send none. + + A request that sends only names the door reads is answered as before, except that `?member=` on the item read is now honoured. `RestServer`'s item read asks the same decision, and its answers are byte for byte what they were. Every name the `@objectstack/client` SDK and the Studio metadata client send to these doors is accepted, except `force` on a save: the SDK sends it only when its caller passes `force: true`, and Studio only to retry a save after a `409 DESTRUCTIVE_CHANGE`. On a dispatcher-only host that retry already failed with the same `409`, and it now answers the located `400` that names `force`. +- c4b84ad: fix(rest)!: `PUT` and the five by-name `/meta` sub-doors declare closed query sets, so `?member=` is refused with a located `400 VALIDATION_ERROR`, never dropped + + Six by-name `/meta` doors now refuse a query parameter they do not read, with a located `400 VALIDATION_ERROR`, instead of dropping it. Each read the names it knew and ignored the rest, so a request carrying the bundle member selector `?member=` — which `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` accept — was answered for the canonical member with a `200`, and a save wrote the body's member. + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What each door accepts, and what to do when it refuses: + + - `PUT /meta/:type/:name` accepts `force`, `package` and `mode`. A save is addressed by its body: to save one member of a bundled type, put that member's own discriminator in the body (an `email_template`'s `locale`) and drop `?member=`. + - `GET /meta/:type/:name/layers` accepts `package`. It shows the canonical member's layers. Read one member with `GET /meta/:type/:name?member=…`. + - `GET /meta/:type/:name/audit` accepts `limit`. The trail is the item's, with every member's attempts in it. Drop `?member=`. + - `GET /meta/:type/:name/diff` accepts `from`, `to`, `fromVersion` and `toVersion`. It compares the canonical member's versions. Read one member's lineage with `GET /meta/:type/:name/history?member=…`. + - `GET /meta/:type/:name/published` and `GET /meta/:type/:name/references` accept no query parameter. Send none. Their refusal ends "This endpoint takes no query parameters." where the other doors list the names they accept. + + The refusal body is the nested envelope the five member doors already answer for a name they do not know, `{ error: { code: 'VALIDATION_ERROR', message } }`, and the message names the refused parameter. A request that sends only names the door reads is answered exactly as before. The `@objectstack/client` SDK and the Studio metadata client send only accepted names. +- 543b33d: fix(metadata-protocol)!: a metadata save or delete that names no package addresses the row of the one package the item belongs to, and a name two or more packages hold is refused `409 RESOURCE_CONFLICT`; the delete door can name its 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 one item can hold one row per package. A save that names a package (`?package=` on the REST door, `packageId` in process) writes that package's row. A save that named none always wrote the package-unbound row, even for an item a package owns, and a delete named no package at all: it removed whichever row of the item the store returned first. The draft save already resolved this: a draft that names no package is stored in the package of the item's active row. + + **FROM.** Save an item into a package (`PUT /api/v1/meta/app/my_app?package=com.acme.app`), then save it again without `?package=`: the second save answered `200` "Saved app 'my_app' (env-wide, state=active)" and wrote a second, package-less row beside the package's row. The item read kept serving the package's row, so nothing the save carried took effect, and a console "Disable" reported success while the app stayed enabled. The same held for the API, the CLI and agents, and for an overlay row bound to a code package. A `DELETE /api/v1/meta/app/my_app` of an item two packages hold removed one of the two rows, the other was then served, and the receipt said "it no longer exists". Uninstalling a package, or discarding its drafts, deleted each of its items by name the same way, so it could remove another package's row of the same name and leave its own. + + **TO.** A direct save, or a delete, that names no package resolves its address from the item's rows (the active rows for a save and a delete, the draft rows for a draft discard): + + - **Bound to one package.** The save writes that package's row, exactly as a save naming the package does, and the item read serves it next; the delete removes that row. The receipts name the package: "Saved app 'my_app' (env-wide, package=com.acme.app, state=active)" and "Deleted app 'my_app' (package=com.acme.app) — it no longer exists." Every check on the row the save writes reads that package: its stored lock, a stored credential carried forward, the optimistic lock, and the writable-base rule. The item read that names no package serves that row's `version`, so a read followed by a save or a delete with the served `If-Match` is accepted. + - **Bound to two or more packages.** The save or delete is refused `409 RESOURCE_CONFLICT` and changes nothing. The message names the packages and the remedy: name one with `?package=`. + - **Bound to none.** The save writes, and the delete removes, the package-unbound row, as before, and the receipts read as before. + + The delete door gains the address a save has: `DELETE /api/v1/meta/:type/:name?package=` (`packageId` in process, `DeleteMetaItemRequestSchema.packageId`) removes that package's row and no other; `all` and an empty value name no package, as on the save door. A repeated `?package=` is refused `400`. A delete receipt now says when another row of the item is still stored, naming it, instead of claiming the item is gone or back at its artifact default. Uninstalling a package and discarding its drafts name the package, so each removes only its own rows. + + **Also refused now**, each as the same save naming the package is refused: a package-less save of an item held by a read-only package (a code package's runtime-only item, or an installed `system`/`cloud` package's) answers `422 WRITABLE_PACKAGE_REQUIRED`; and a package-less save asserting a create (`If-None-Match: *`) of an item a package holds answers `409 METADATA_CONFLICT`. + + `packageId: null` in process names no package too, as the request contracts state. The server-side rewrites keep the row they name: `os migrate meta --stored --apply` and the stored flow credential move pass `null` for a package-unbound row they rewrite, and that row is still the one written; package duplication names its target package. + + **Unchanged.** A save or a delete that names a package exactly. A draft save, which keeps its own inheritance. An item a code package ships with no stored row: the package-less save still writes the package-unbound overlay, and the read serves it. Package-less rows already stored beside a package's row stay as they are; none is converted. A package-less delete of such an item removes the package's row and its receipt says the package-less row remains. + + **Remedy.** For an item two packages hold, send the save or the delete with `?package=` naming the package whose row it edits or removes. A receipt now names the package when the write addressed a package's row; a client that compares receipt text should expect `, package=` after `env-wide` on a save and ` (package=)` after the item on a delete. + + `@objectstack/spec`: the `SaveMetaItemRequestSchema.packageId` description now states what an `active` save that names no package writes, where it said the env-local overlay; `DeleteMetaItemRequestSchema` declares the optional `packageId`. `@objectstack/rest`: `package` joins the reset door's closed query set (`META_ITEM_RESET_PARAMS`). +- d9d7edd: fix(runtime,rest,cloud-connection)!: the declared-endpoint fallback, REST's direct-mounted publish and federation routes, and cloud-connection's session doors refuse a session the ADR-0069 authentication policy holds + + Clause-②: no (narrowing) — a session ADR-0069 holds out of protected resources is refused by doors that answered it; core's existing exports only, no published schema or export moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate. REST's `enforceAuth` and the runtime dispatcher already refuse such a session. Three groups of doors that resolve a session on their own never asked, so they served it as an ordinary signed-in caller: + + - **`@objectstack/runtime`**: the declared-endpoint fallback (app-declared `apis:` endpoints, served by the dispatcher plugin's fallback seam). Its policy chain now asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) before `authRequired` and before the endpoint's target runs, on every declared endpoint, an `authRequired: false` one included, because the target runs as the caller. While a gate feature is active the chain reads the session for this, and when an active gate cannot read the session the request is answered `503` (`SERVICE_UNAVAILABLE`), as REST answers it. No declared path is handed to the allow-list, so no endpoint can exempt itself through the prefix it sits under. + - **`@objectstack/rest`**: the direct-mounted package publish door (`registerPackageRoutes`) and the external-datasource federation family (`registerExternalDatasourceRoutes`). Each refuses on the `authGate` the REST resolver already puts on the execution context it hands them. + - **`@objectstack/cloud-connection`**: `CloudConnectionPlugin`'s seven session doors (bind, poll, unbind, install, and the three installed-state reads) and all five `MarketplaceInstallLocalPlugin` doors (install, list, uninstall, reseed, purge). Each plugin asks the gate once, where it resolves the session, and every door that resolves one refuses on it. + + - **Refused now:** a held session gets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message`, in each door's declared error envelope, before the door does anything else. This is the answer REST gives the same session. + - **Unchanged:** a session the policy does not hold is served exactly as before, an API-key caller is unaffected because it has no session to hold, and an anonymous caller gets the same `401`. `CloudConnectionPlugin`'s status read and `MarketplaceProxyPlugin`'s catalog proxy resolve no session and answer every caller alike, so they are unchanged. + + What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before these doors answer them, as on every other protected route. Nothing needs configuring: the posture comes from the kernel's `auth` service, as it does for REST and the dispatcher. +- d45d0e9: fix(runtime)!: the runtime dispatcher's read points refuse a repeated single-valued query name as their `RestServer` twins do, instead of reading one value, a joined value or an array + + The runtime dispatcher's `/meta`, `/ui`, `/i18n`, `/security`, `/packages` and `/data` read points each read a query name as ONE value. None of them guarded against the name arriving twice, because the only door in front of most of them, the `@objectstack/hono` `createHonoApp` catch-all, has always flattened a repeat to its last value. Handed an array, they answered wrongly: `GET /meta/object/:name/state/:field?from=a&from=b` answered a dead end for the joined state `'a,b'`; `_drafts`, `/ui/view` and the `/data` filter slot passed the array into the protocol call; both `/i18n` query-locale arms threw, which surfaced as a `500`; and the `/meta` list, the book tree, `/security/suggested-bindings` and `/packages` answered an empty or unscoped `200`. Each now refuses the repeat before any protocol or service call. + + `@objectstack/rest` publishes the lists and rules both transports read, so the two cannot drift: `META_QUERY_ARITY_DOORS` (the single-valued names of the `/meta` list, book tree, `_drafts` and state machine doors, which `RestServer`'s twins of those doors now read from), the decision `metaQueryArityRefusal(door, query)`, and `assertFilterParamSuppliedOnce`, the `/data` list's filter-slot rule, on the package entry. + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What changes for you: a request that repeats one of the names below to the runtime dispatcher is now refused `400` in the dispatcher's own envelope, `{ success: false, error: { code, message } }`, instead of being answered with one value, a joined value or an array. The `code` is `VALIDATION_ERROR` with the sentence `RestServer` answers ("The "NAME" query parameter was supplied N times…"), except on the `/data` filter slot, which answers `INVALID_FILTER` with `RestServer`'s repeated-filter sentence. Send each name once. + + - `GET /meta/:type`: `package`, `preview`, `object`, `include`, `id`. + - `GET /meta/book/:name/tree`: `package`. + - `GET /meta/_drafts`: `packageId`, `type`. + - `GET /meta/object/:name/state/:field`: `from`. + - `GET /ui/view/:object`: `type`, when the path names no type. + - `GET /i18n/translations` and `GET /i18n/labels/:object`: `locale`, when the path names no locale. + - `GET /security/suggested-bindings`: `status`, `packageId`. A repeated `status` was already refused `400`, as an unknown status; it now gets the multiplicity sentence. + - `GET /packages`: `status`, `type`, beside `enabled`, which was already refused. + - `GET /data/:object`: every spelling of the filter slot (`filter`, `where` and their aliases, `filters`, `$filter`). Genuinely multi-valued names such as `select` and `expand` are untouched. + + A name sent once is answered as before, and one sent once as a one-element array now reads as that one value everywhere. Where a caller meets this today: `GET /packages` through `@objectstack/plugin-hono-server`, whose adapter already keeps repeats as arrays. Every other point is reached on a shipped host only through `createHonoApp`, which still flattens a repeat to its last value, so until that adapter keeps repeats the refusals are visible only to a caller of `HttpDispatcher.dispatch()` that passes an array. The `@objectstack/client` SDK sends each of these names once. +- 430e5f5: fix(security)!: under a walled posture every environment-metadata write is the platform operator's, whatever its type (ADR-0131 D12 items 7–8, D11) + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. ADR-0131 D12 item 7: a walled deployment is "one environment, one schema, one catalog … The metadata-authoring capability (`manage_metadata` / `studio.access`) is what headquarters IT holds and plant admins do not." Item 8: a plant "cannot create a position, a permission set, a capability, a template or any metadata … A plant that needs its own catalog is given its own environment." D11: the rules are identical in every posture; only whether the wall is enforced differs. + + **Why.** The environment definition ledger carries no organization column, so a write lands for every organization. Measured on booted `isolated` and `group` stacks before this change: an organization owner granted `manage_metadata` inside their own organization created an object, a view and a flow at `/api/v1/meta`, edited, published, rolled back and reset the operator's view, ran `POST /api/v1/meta/_migrate-stored`, passed all thirteen `/api/v1/packages` write doors, installed, upgraded and uninstalled packages through `install-local`, and created a flow at `/api/v1/automation`, each with `2xx`; another organization's owner read every result. The security catalog was refused at these doors already; every other type was not. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` of `/api/v1/meta/:type/:name` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for a type outside the security catalog (`object`, `view`, `flow`, `app`, `dashboard`, `translation` and every other type): **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher answers `403 PERMISSION_DENIED`). + - Every `/api/v1/packages` write on the runtime dispatcher — create, the manifest `PATCH`, enable, disable, publish, publish-drafts, discard-drafts, revert, rollback, `commits/:commitId/revert`, duplicate, adopt-orphans and delete — for a package carrying no position, permission set or capability: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is read or written. The refusal no longer reads the package's items. + - `POST /api/v1/marketplace/install-local` (install and upgrade) and `DELETE /api/v1/marketplace/install-local/:id` (uninstall) on `@objectstack/cloud-connection`, for a package carrying no catalog item: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`. The refusal's `error.details` no longer carries `catalogItems`; it carries `manifestId`. + - `POST /api/v1/automation`, `PUT /api/v1/automation/:name`, `DELETE /api/v1/automation/:name` and `POST /api/v1/automation/:name/clone`: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the automation service is touched. + - `POST /api/v1/meta/_migrate-stored`, the install-wide rewrite of the stored metadata, preview and apply alike: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` on `@objectstack/rest` and `403 PERMISSION_DENIED` on the runtime dispatcher, before a row is read. + - Every one of these refusals carries one sentence: it names the written type (`a package` at the package doors, `the environment's stored metadata` at the rewrite), the posture and "ADR-0131 D3, D12 items 7–8, D11", and points at an environment of the organization's own. The catalog refusals that shipped before keep their envelopes and now read the same sentence. + - The activation refusals under a wall (`POST /api/v1/automation/:name/toggle`, `POST /api/v1/actions/_activation/…`, `POST /api/v1/security/_activation/permission/…`) no longer recommend cloning a flow or a permission set, or authoring a sibling action, since those writes are the operator's under a wall too; they name the platform operator. + + **Unchanged.** Reads. Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows. The platform operator and the engine's own writes (seeders, installers, migrations) write as before. Under `single` (and with no `tenancy` service), a `manage_metadata` holder writes exactly as before. + + **The remedy.** The platform operator authors the environment's metadata, in Studio or through a managed package; an organization that needs metadata of its own is given an environment of its own. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-environment-metadata-writes-retired`. `walledCatalogWriteVerdict` (`@objectstack/metadata-core`) now judges every type alike: a custom door that writes environment metadata asks it after its capability gate, with no `isSecurityCatalogType` pre-filter. +- 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. +- d89f491: A stored flow the automation engine refuses is no longer served as running: the flow meta read serves the engine's verdict beside the item as `registration`, an envelope sibling of `lock`, and the flow list serves it as `registrations` keyed by flow name, so a refused flow reads `{ state: 'refused', reason: { code?, message } }` beside its stored `status`, which stays as authored. + + Clause-②: yes (widening) + + - **The shape** (`@objectstack/spec`). `FlowRegistrationSchema` is a closed set: `registered` (the engine holds the flow), `refused` (it refused the stored flow and holds nothing under the name; `reason` carries the engine's own `message` and, when the thrown error set one, its `code`, verbatim), and `unregistered` (it holds nothing and refused nothing: a read during boot, between a save and the engine following it, or after a deliberate unregister). `GetMetaItemResponseSchema` gains `registration` and `GetMetaItemsResponseSchema` gains `registrations`, both optional. New exports: `FlowRegistrationSchema`, `FlowRegistrationRefusalSchema` and the types `FlowRegistration` and `FlowRegistrationRefusal`. `IAutomationService` gains the optional member `getFlowRegistration(name)`. + - **Where the verdict comes from** (`@objectstack/service-automation`). The `kernel:ready` cold-boot bind records each flow it refuses, with what the engine threw, after it withdraws the flow; the `metadata:reloaded` re-sync and the metadata-change sync record a refusal that leaves the name unregistered. A later successful registration of the name, or its withdrawal, ends the record. `AutomationEngine` answers `getFlowRegistration(name)` and takes `recordFlowRefusal(name, error)`. The refusal's warn line, its level and the withdrawal are unchanged. + - **Where it is served** (`@objectstack/metadata-protocol`). The protocol reads the engine through the kernel's service table by name, as it already reads the engine's stored-flow canonicalizer; it does not import the automation service. The field is absent where no automation service declares the member, on every type other than `flow`, on a draft read and on the execution face the engine binds from: absence means no engine judged the flow, never `registered`. The verdict is the engine of the process that answered the read. + - **The default door** (`@objectstack/rest`, `@objectstack/metadata-protocol`, `@objectstack/spec`). `GET /api/v1/meta/flow/:name` on a default deployment (`metadata.enableCache: true`) is answered by the cached item branch, which now carries `registration` too. `getMetaItemCached` returns it beside `data`, read live from its delegate read on every call, and `GetMetaItemCachedResponseSchema` declares it. It is never part of the ETag, so a cached read that carries it is never answered `304 Not Modified`: a judged flow is always served in full. The cached branch's other envelope keys are unchanged. +- 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. +- aefab7c: fix(rest)!: the `/meta` item and list reads serve a view stored with no `label` without the `label` the translator invents from its name + + Clause-②: no (narrowing) + + + + **BREAKING** (a `/meta` read stops serving a value nobody authored), shipped as `minor` under the launch-window convention for breaking changes. + + A view's `label` is optional in the spec, with no default. When the translation catalog carried no string for a view, the read-side translator answered the view's own name, so a view stored without a `label` was served one: `GET /api/v1/meta/view/NAME` and `GET /api/v1/meta/view` returned `label: "OBJECT.KEY"`, on the standalone server and on the runtime dispatcher alike. The console drew that machine name as the view tab. The view-container read already dropped this value; the item read and the list read now drop it too. + + What a client reads now: + + - A view stored with no `label` is served with no `label` key, in every locale, with or without a translation catalog. + - A view with an authored `label` is served that label, unchanged, including a label that happens to equal the view's name. + - A catalog translation (`objects.OBJECT._views.KEY.label`) is still served, for a view with a `label` and for one without. + - A read that resolves no locale is served the stored document as before, and the layered read (`?layers=true`) never carried the invented value. + + Who reads differently: a client that displays a served view's `label` now finds the key absent for a view stored without one, where it used to find the qualified name; it shows its own wording for such a view (the object's plural label, for example). The console's view tab falls back to the view's name on its own, so it keeps showing a machine name for such a view until its own fallback changes. +- a06f0d4: fix(rest)!: the `/data` API-exposure check treats an object of a disabled package as no object, so every operation on it answers `404 OBJECT_PACKAGE_DISABLED` again, with no fail-open warning + + Clause-②: no (narrowing) + + + + **BREAKING** (a refusal moves), shipped as `minor` under the launch-window convention for breaking changes. + + The by-name `/meta` read now refuses an object of a disabled package with `404 OBJECT_PACKAGE_DISABLED`. The API-exposure check reads that object by name. It now reads that refusal as "no object here": there is no exposure policy to judge, and nothing failed, so it logs nothing. The request then reaches the data plane, which refuses it with the same code. + + - **Changed back:** for an object whose package is disabled, a denied operation goes from `405 OBJECT_API_METHOD_NOT_ALLOWED` (an operation outside `apiMethods`) or `404 OBJECT_API_DISABLED` (an object with `apiEnabled: false`) back to `404 OBJECT_PACKAGE_DISABLED`. That is what it answered before the check read one object by name. An allowed operation still answers `404 OBJECT_PACKAGE_DISABLED`. + - **No warning now:** such a request no longer logs `api-exposure gate: object metadata read failed — failing open`. + - **Unchanged:** any other failed read still fails open and still logs that warning. Objects of enabled packages get the same exposure answers as before. + + What changes for you: nothing to rewrite. No accepted request is now refused, and no refused request is now accepted. +- aaaeeac: fix(rest)!: a schema-derived export skips the fields declared `hidden`, so the hidden `__search` search companion no longer adds an empty "Search Index" column + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `GET /api/v1/data/:object/export` without `?fields=` builds its columns from the object schema. It took every field of the object, so the hidden `__search` companion, which the search-normalization seam declares on every object with an eligible name field whenever pinyin search is on, and whose value the data reads strip from every row, became a "Search Index" column that was empty on every row. The default columns are now the fields the object shows by default: a field declared `hidden` (`FieldSchema.hidden`, "Hidden from default UI") is not one. + + - **Served less now:** the CSV and XLSX default header, and its zero-row header, no longer carry a column for a field declared `hidden`. Measured on a dev boot of `examples/app-showcase`, over every object with a hidden field that the export serves: `Search Index` (`__search`, 64 objects, empty on every row), `Organization` (`organization_id`, 53 objects, the one that carries a value: the record's organization), `Owning Business Unit` (`owning_business_unit_id`, 24 objects, empty on every row because nothing stamps it yet), and `Session Token` (`sys_session`), `Previous Password Hashes` (`sys_account`) and `Hashed Key` (`sys_api_key`), all three empty because the read omits their values. No other column moved. + - **Unchanged:** an explicit `?fields=` list is answered as asked, a hidden field included: the header names it and its cells carry what the read returns (empty for `__search`, which the read strips). The field-level-security narrowing of the default columns is unchanged, and so are the zero-row header rules. `format=json` streams each row as the read returns it, has no column set, and is unchanged. `?template=true` keeps its own column rule. + + What changes for you: a consumer that wants a hidden field's column in an export names the field in `?fields=`. +- 8259ea0: fix(rest)!: `format=json` honours an explicit `?fields=`, so each exported row carries exactly the named keys, in list order + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `GET /api/v1/data/:object/export` declares `fields=a,b,c` for every format. The CSV and XLSX arms wrote exactly the listed columns, but the JSON arm wrote each row as the read returned it, so `?format=json&fields=…` was ignored without any sign. objectui's list-view and grid export send `format=json` with the view's visible columns, so a JSON export of a list view carried every readable field instead of the view's columns. + + - **Served less now:** with `?fields=`, each JSON row carries exactly the named keys, in the order the list names them, with the same readable formatting as before. Measured on a dev boot of `examples/app-showcase`: `?format=json&fields=title` on `showcase_task` answered 25 keys per row (`id`, the audit and ownership columns, `organization_id`, `owning_business_unit_id` and every author field) and now answers `title` alone. A named key the read did not return (a field that field-level security removes from the row, or the `__search` companion the read strips) is absent from the JSON row. It is not written as `null`. The CSV and XLSX arms write an empty cell for it, as before. + - **Unchanged:** `format=json` without `?fields=` still writes each row as the read returns it. The CSV and XLSX arms, their default columns and their header rules are untouched. + + What changes for you: a caller that sends `format=json` with `fields` and reads a key it did not name, such as `id`, adds that key to `fields`. +- 2bcd406: fix(rest)!: `/history`, `/audit` and `/diff` answer a disabled package's item with the plain read's absence, never past the per-caller gate + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The by-name read now answers a disabled package's item as absent. The event and diff doors judge the item the plain read would judge, and they read "no item" as "nothing behind the name": events served whole, and a view's or dashboard's diff served whole, to a caller the item's own gate refuses. + + - **Absent now, `404 RESOURCE_NOT_FOUND`:** `GET /meta/:type/:name/history`, `/audit` and `/diff` for an `app`, `view`, `dashboard`, `doc` or `book` the plain read withholds while its layers still resolve, which is a disabled package's item. No events and no diff are read. + - **Unchanged:** an item that is really gone (every layer null, such as a deleted item) keeps its answer: its events, and its diff as before. Items of enabled packages keep the per-caller gate as before. + + What changes for you: re-enable the package to read its items' history, audit trail and diff again. +- 58a2faa: fix(rest)!: `/meta/:type/:name/layers` and the deprecated `?layers=true` answer a disabled package's non-object item as a missing name, on both transports + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + - **Absent now, byte for byte the missing name's answer (`404 RESOURCE_NOT_FOUND`):** the layered view of an item of every type but `object` and `package` whose owning package is disabled, on `GET /meta/:type/:name/layers` and on `GET /meta/:type/:name?layers=true`, to every caller, an author who may write the item included. One step in the shared layered chain (`createMetaLayeredAnswer`) answers it, so the runtime dispatcher's two spellings answer the same. The owner is the one the by-name read's protection envelope names (`packageId`), read under the request's `?package=` scope, asked of the engine registry's `isPackageDisabled`. Objects already answered `404 OBJECT_PACKAGE_DISABLED` here. + - **Unchanged:** items of enabled packages, a name with nothing behind it, and the protocol's `getMetaItemLayered`, which keeps serving every layer to the readers that resolve with it (the security catalog echo, the packaged-permission-set lock gate). + - **Added, optional:** `MetaItemReadGateSources.isItemOfDisabledPackage` and `MetaLayeredRequest.packageId`. A host that supplies neither answers as before. + + What changes for you: re-enable the package to read its items' layered views again. +- e74ec56: fix(rest)!: `/published` and `/meta/object/:name/state/:field` answer a disabled package's item as the by-name read does, and the event and diff doors' absence is the plain read's body + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + - **`GET /meta/:type/:name/published`, every type but `object` and `package`:** a disabled package's item is answered as a name with nothing behind it (`404 NOT_FOUND`, byte for byte), on both exits: the stored published row and the code/package snapshot. The owner is the one the by-name read's protection envelope names (`packageId`). Objects already answered `404 OBJECT_PACKAGE_DISABLED` here. + - **`GET /meta/object/:name/state/:field`:** a disabled package's object is refused `404 OBJECT_PACKAGE_DISABLED`, what `GET /meta/object/:name` answers, as the runtime dispatcher's twin already does. The schema is not read. + - **`/history`, `/audit` and `/diff`:** the `404 RESOURCE_NOT_FOUND` a disabled package's gated item gets is now the plain read's nested body, `{ "error": { "code": "RESOURCE_NOT_FOUND", "message": "Metadata item not found or access denied." } }`, instead of the flat `{ "error": "…", "code": "RESOURCE_NOT_FOUND" }`. Status and code are unchanged; read `body.error.code`. + - **Unchanged:** items of enabled packages, a deleted item, and a name with nothing behind it. + + What changes for you: re-enable the package to read its items' published versions and its objects' state machines again. +- 741aa85: fix(rest)!: the import doors honour `runAutomations: false` and `treatAsHistorical: true` only for the system principal, and refuse them `403 PERMISSION_DENIED` to every other caller + + Clause-②: no (narrowing) + + + + **BREAKING** (the import doors accept fewer request values: `runAutomations: false` and `treatAsHistorical: true` are refused from every caller but the system principal, administrators included), shipped as `minor` under the launch-window convention for breaking changes. Two known consumers break visibly: objectui's Import Wizard, whose two options now answer `403` for every signed-in user (#24381), and hotcrm's dev-only demo script, which sent the opt-out as the dev administrator and now exits by its own submitter check. + + Both flags take an app's declared business controls off every row an import writes. `runAutomations: false` writes with `skipAutomations`, so no metadata-bound hook and no record-change trigger runs (an approval flow and a closed-record freeze hook among them). `treatAsHistorical: true` writes with `skipStateMachine` and `preserveAudit`, so the object's `state_machine` rule is skipped and supplied audit and read-only values are kept. Until now, any caller who may import could send either. + + **What changes.** `POST /api/v1/data/:object/import` and `POST /api/v1/data/:object/import/jobs` admit either opt-out only from the system principal (`ExecutionContext.isSystem`), the predicate the doors that start an elevated self-triggered flow already ask. Every other caller, every signed-in user and administrator included, gets `403 PERMISSION_DENIED` in the ADR-0112 envelope, with `error.details: { object, refused }` naming the opt-outs it sent. The doors ask before the body is parsed: a refused request writes no row and creates no import job, on a dry run too. A hidden object keeps its `404` ahead of the refusal. + + **What stays as it was.** A request that omits both keys, or sends `runAutomations: true` / `treatAsHistorical: false`, imports exactly as before, with every control in force. `runImport` (`@objectstack/core`) is unchanged: in-process callers that run as the system principal, and the identity import in `@objectstack/plugin-auth`, keep their own settings. + + **If an import now answers 403.** Send it without `runAutomations: false` and `treatAsHistorical: true`: the rows are written with the object's automations, hooks, `state_machine` rule and audit stamps in force. A bulk load that must skip them is a migration, and runs in-process as the system principal. +- adb7910: fix(rest)!: an import undo runs under the object's hooks, record-change triggers and state machine for every caller but the system principal, and reports each row a control refuses instead of counting it as reverted + + Clause-②: yes (narrowing) + + + + **BREAKING** (the import undo door takes fewer controls off its writes, and claims less: `POST /api/v1/data/import/jobs/:jobId/undo` keeps `skipAutomations` and `skipStateMachine` on its reversal writes for the system principal alone, so every other caller's undo, administrators included, runs under the object's metadata-bound hooks, record-change triggers and `state_machine` rule; and an undo that leaves any row unreverted now answers `success: false` and leaves the job unstamped, where it answered `success: true` and stamped it reverted), shipped as `minor` under the launch-window convention for breaking changes. The cost is named, not hidden: undoing an ordinary import may fire record-change automation again (a notification, an approval re-opening on a restored amount), which is the app's declared behaviour for those writes. If a row now stays unreverted, read its entry in `failures`, resolve what refuses it on the app (complete the approval, reopen the record) and undo the job again, which retries only the rows that remain; a reversal that must skip the controls runs in-process as the system principal. + + The same rule now holds at every import door: only the system principal may take an app's declared controls off a write. The import doors already refused a caller-chosen opt-out from everyone else; the undo door chose its skips itself, for every caller, and no longer does. + + **What changes.** The undo's deletes (of the records the job created) and restores (of the fields it updated) are written as `ExecutionContext.isSystem` decides: the system principal keeps `skipAutomations` and `skipStateMachine`; every other caller writes with neither. A reversal that a hook, the state machine, a record lock or anything else refuses is caught for its row and never swallowed: + + - the answer lists it in a new `failures` array, one `{ id, action, code, error }` per row, where `action` is `delete` or `restore` and `code` / `error` are what the single-record write door answers the same error with (a hook's refusal reads in the hook's own sentence); `failed` is its length and `success` is `false`; + - the job's stored undo log is rewritten to exactly the unreverted rows, each refusal beside them under `failures`, and `reverted_at` is not stamped. The job stays `undoable`, and undoing it again retries only those rows, never one already reverted. `reverted_at` (`revertedAt` on the job's progress and history) now means every row was reverted. + + **What stays as it was.** An undo that no control refuses reverts every row and answers as before, with `failures: []`. A historical job (`treatAsHistorical`) keeps `preserveAudit` on its undo for every caller. The job-row write that records the outcome stays engine-owned (ADR-0103). The 404, 409 `ALREADY_REVERTED` and 422 `NOT_UNDOABLE` answers are unchanged, and the import doors and their opt-out gate are untouched. +- 2ec6827: feat(rest,runtime): `PUT /meta/:type/:name?surface=studio|api` declares the save's authoring surface on both HTTP save doors; `?surface=copilot` is refused + + Clause-②: yes (widening) + + - **The spelling.** `?surface=` joins the save door's closed query set on `RestServer` and on the runtime dispatcher (`META_ITEM_SAVE_PARAMS`), read once through `metaSaveRequestOptions` beside `?mode=`. A draft save stamps it on each key path it changes (`draftChanges[].by` on the strict draft read); without it the save is recorded as `api`. `?surface=studio` is the spelling for a person's save in Studio. + - **The refusals.** `?surface=copilot` is `400 VALIDATION_ERROR`: it marks a write as the AI copilot's, which only an in-process tool calling `saveMetaItem` may state. Any other value, a case variant included, is the same `400`, never read as `api`; a repeated `?surface=` is refused by the arity rule. Each refusal writes nothing. + - **Why a query parameter.** It is what the save is asked to do, as `?mode=` and `?force=` are, beside the HTTP preconditions the headers carry; and it needs no CORS allow-list entry, which a custom request header would. + +### Patch Changes + +- d112087: docs: the suggested-binding acceptance is deployment-level and takes effect + + Clause-②: no + + Text only. These are the published sentences that describe what confirming a package's `isDefault` suggestion does. They now match the behaviour `@objectstack/plugin-security` ships in this release. + + - `@objectstack/spec`: `ISecurityService.listAudienceBindingSuggestions`, `confirmAudienceBindingSuggestion` and `dismissAudienceBindingSuggestion` keep "writes the binding" for the confirm. The text now also says what that means: a `manage_metadata` holder accepts once for the deployment, `everyone`'s definition is derived again, and a dismiss on an accepted suggestion revokes it. The package-level `isDefault` note keeps "an admin confirms in Setup" and adds who that admin is. + - `@objectstack/client`: the `security.suggestedBindings.confirm` and `.dismiss` TSDoc. + - `@objectstack/rest`: the route summaries of the confirm and dismiss routes. Paths and envelopes are unchanged. + - `@objectstack/cli`: the ownership-inventory citation for `sys_audience_binding_suggestion` now says the record is deployment-level and owned by the Default Organization. +- 1fb274e: `GET /api/v1/meta/datasource/:name/published` serves a code-defined datasource's code definition while a stored row under its name still exists + + Clause-②: no + + - For a datasource name the host registers from code (one an installed package declares in `*.datasource.ts`, or the host's `default`), the published door now serves the layered read's `effective` layer, which is the code definition. Before this change it served the leftover stored `sys_metadata` row, with that row's label, `origin` and connection settings, while `GET /api/v1/meta/datasource/:name`, the `/meta/datasource` list and `/layers` all served the code definition. The door now asks the protocol's `declinesStoredRow`, the one decision those reads make, in place of `isShippedFlowName`. A shipped flow name is answered as before. + - Unchanged: a runtime datasource's stored row is still what the door serves, and so is every stored row of every other type. A protocol that does not provide `declinesStoredRow` still gets the stored row. The row itself stays at rest, `/layers` still reports it in `overlay`, and `DELETE /api/v1/meta/datasource/:name` still removes it as the repair. +- 3b49318: fix(meta): a second unpinned draft save of a package-owned item is no longer refused 409, the item read serves that draft's `version`, and a package-less draft save keeps a package-owned datasource's stored credential + + Clause-②: yes (widening) + + - **The second draft save is accepted.** Save an item into a package (`PUT /api/v1/meta/:type/:name?package=`), then save a draft of it without `?package=` twice. The repository stores that draft in the item's package (the draft inheritance that keeps a pending change in the package it edits). The save door took its expected parent from the package-unbound draft row instead. It found none, so the second save, which sent no `If-Match`, was refused `409 METADATA_CONFLICT` ("Expected parent null but current is hmac-sha256:…"). ADR-0008 makes an unpinned save last-writer-wins, and it is accepted again. + - **One head row per write address.** `SysMetadataRepository.headAt(ref, { state, packageId })` answers `{ head, packageId }`: the row a `put` at that address upserts (`null` for a create), and the package binding that put writes under. Both come from the same resolution `put`'s optimistic lock runs. That resolution includes the draft inheritance and the adoption of a draft stored without a package. The save door's expected parent and the item read's `version` both come from it, so the token `GET /meta/:type/:name?state=draft` serves is the token a draft save at the same address accepts. Before this, that read served `version: null` while the inherited draft existed. + - **`?package=all` on the item read.** `GET /meta/:type/:name?package=all` now reads `all` as the save and publish doors read it: as naming no package. Before, the read forwarded the literal `all`. Its `version` was resolved at a package no save writes, so it served `null` beside a row the save then judged. Its `?state=draft` read answered `404 NO_DRAFT` for a draft stored in the item's package. The read now serves the item as a read with no `?package=` serves it, and `?package=all` still takes the uncached arm, so it keeps serving `version`. + - **The credential carry-forward reads the same row.** A save of a type whose read withholds a credential (`datasource`; `flow` with the automation plugin) carries the stored credential back into a body that returns as it was served. It compared that body against the row at the key the caller named. A package-less draft save of a package-owned item therefore found neither the inherited draft nor the package's active row, and compared against the code layer. The stored credential was persisted away on the first such save, measured on the real route with a datasource whose `config.url` holds one. Both reads now go through `headAt`: the draft the save overwrites, or with no draft yet the active row in the package the draft is stamped into. An item with no package binding is unchanged. + - **Descriptions only, in `@objectstack/spec`.** `SaveMetaItemRequestSchema.packageId` now states the draft inheritance, where it said "absent = env-local overlay". The `POST /meta/:type/:name/publish` route description spells the conflict code `METADATA_CONFLICT`, as the wire sends it. + - A stale `If-Match` is still refused `409`, and `If-None-Match: *` is still refused while a row exists. An item with no package binding is unchanged. No spec key, request field or response field is added or removed. + - The only change to the public surface is one added method on the exported `SysMetadataRepository`: `headAt`. No existing signature, schema or accept set changes. +- a45d5d8: fix(auth): a server-side session read no longer renews a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Ten doors read the session in-process (`auth.api.getSession`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, later `GET /api/v1/auth/get-session` calls found a fresh row and re-issued nothing, and the cookie expired first: a dead cookie beside a live bearer, and every cookie-only path then saw a signed-out user. Measured on a fresh dev stack (better-auth 1.7.3, `expiresIn` 604800 s, `updateAge` 86400 s, a session aged to `now + expiresIn − updateAge − 60 s`): `GET /api/v1/data/:object`, `GET /api/v1/auth/me/permissions`, `GET /api/v1/meta/object`, `GET /api/v1/i18n/locales`, `GET /api/v1/packages`, `GET /api/v1/marketplace/install-local` and the MCP door each moved `expires_at` by +86460 s and set no session cookie. + + **The rule now, decided by what the request carries.** + + - **A session cookie** (a browser, including a console that sends its cookie beside its bearer): the in-process read passes `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. Measured after the change: every door above leaves `expires_at` unchanged on a cookie request and sets no cookie. + - **No session cookie** (a bearer-only client: `@objectstack/client` outside a browser, the `os` CLI): unchanged. A data read past `updateAge` still renews the session (+86460 s, measured on the same doors), so an active bearer client keeps sliding forward without calling `get-session`. No cookie is ever set on a response to a request that sent none. + + **Upgrading.** Nothing to change. A browser session renews whenever the app calls `GET /api/v1/auth/get-session`; a tab that never calls it now signs out at the session's real expiry instead of keeping a live bearer beside a dead cookie. + + `@objectstack/types` gains `inProcessSessionReadInput(headers)` (the `getSession` input for an in-process read: the request's own headers, plus `query: { disableRefresh: true }` when they carry a better-auth session cookie), `carriesSessionCookie(headers)` and the `InProcessSessionReadInput` type. A host that calls `auth.api.getSession` itself should read through `inProcessSessionReadInput` for the same reason. + + The same rule is applied to the in-process `auth.api.getSession` readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` by their own changeset. +- 4b7c630: `GET /api/v1/meta/view/`, the by-name read of a `defineView` container, answers `200` on an artifact boot, and translates the views inside the container on every boot + + Clause-②: no + + The registry files a `defineView({ object, list, listViews, … })` container under the object it binds, and serves it on the by-name read of that key. The read handed the container to the translator for one view, which reads the view's own `name`. + + - **Artifact boot** (`os build`, then `os serve` of the artifact): a compiled container carries no `name`, so the read answered `500 INTERNAL_ERROR` and the log read `Cannot read properties of undefined (reading 'startsWith')`. It now answers `200`. + - **Config boot** (`os serve objectstack.config.ts`): its registrar stamps `name: ` onto the container, so the read answered `200`. But it served a `label` equal to the object name, which no author wrote, and it left every view inside the container in the authored language. That label is no longer served. + - **What is translated now, on both boots.** Each view inside the container (`list`, `form`, each `listViews` and `formViews` entry) carries the same `label`, `description` and bulk-action copy as the by-name read of the view it expands to (`.`). That copy is keyed by the same `objects.._views.` entry and judged against the same packaged base, so an organization's explicit override still beats the catalog. The container's own `label` and `description` are read from `objects.._views.`, and are left unchanged when the catalog has no entry there. + - **What stays the same.** The by-name reads of the expanded views and the `GET /api/v1/meta/view` list are unchanged. The list still never includes a container. + - **Packaging.** `@objectstack/rest` now lists `@objectstack/metadata` under `dependencies` (it was a dev dependency). The read imports `deriveViewContainerObject` from `@objectstack/metadata/view-container`, the registry's own derivation of the object a container binds. +- 9859e4e: fix(rest): twenty more REST write routes answer a sandboxed hook's refusal in the hook's own words (#22719) + + Clause-②: no + + **What was wrong.** When a sandboxed hook refuses a write (`throw new Error('Locked rows cannot be changed here.')`), the error that reaches the route carries the QuickJS debug wrapper on `.message` (`hook 'guard' threw: Error: …`) and the sentence on `.innerMessage`. Twenty write routes built their error answer by hand from `.message`, so they shipped the wrapper, while the other write doors answer the sentence. Sixteen of them also answered the refusal as a server fault (`500`). + + **What changes on the wire, for a sandboxed hook's refusal.** + + | routes | before | after | + | --- | --- | --- | + | `POST /sharing/rules`, `DELETE /sharing/rules/:idOrName`, `POST /sharing/rules/:idOrName/evaluate` | `500 RULE_*_FAILED`, flat `{ code, error }`, the wrapper | `400 VALIDATION_ERROR`, nested `{ success: false, error: { code, message } }`, the sentence | + | `POST /security/suggested-bindings/:id/confirm` and `/dismiss` | `500 SUGGESTION_*_FAILED` / `500 INTERNAL`, the wrapper | `400 VALIDATION_ERROR`, same nested `{ error: { code, message } }` shape, the sentence | + | the nine `POST /approvals/requests/:id/*` writes (`approve`, `reject`, `recall`, `revise`, `resubmit`, `reassign`, `remind`, `request-info`, `comment`) | `500 APPROVAL_*_FAILED`, flat `{ code, error }`, the wrapper | `400 VALIDATION_ERROR`, nested, the sentence | + | `POST /packages/publish` | `500 INTERNAL_ERROR`, the wrapper | `400 VALIDATION_ERROR`, the sentence | + | `POST /datasources/:name/external/tables/:remote/draft` and `/import`, `/external/refresh-catalog`, `/external/validate` | `400 EXTERNAL_DATASOURCE_ERROR` / `400 EXTERNAL_IMPORT_ERROR`, the wrapper | the same status and code, the sentence | + + A refusal that declares its own `code` keeps it (an unregistered spelling rides `declaredCode`), a refusal that declares its own `status` is answered at that status, and a `userMessage` the hook set rides the answer, as on every other write door. + + **Also moved, on the sharing-rule, suggested-binding and approval routes.** These routes now ask the same classification as the `/data` door before their `500` arm. So a refusal from the service that declares its own `status` (or `statusCode`) and `code`, and that no route-specific arm reads, is answered at that status, in the nested envelope, instead of `500` with the route's own code. The external-datasource routes keep answering every refusal at `400` with their own code, and `POST /packages/publish` already answered a declared status. + + **Unchanged.** A plain fault keeps each route's own `500` answer and code. The route-specific refusals (`VALIDATION_FAILED`, `PERMISSION_DENIED`, `RULE_NOT_FOUND`, `SHARING_NOT_ENABLED`, the approval prefixes, the typed security errors) answer exactly as before. + + **Upgrading.** A client that reads both envelopes, as `@objectstack/client` does, needs no change. A client of the sharing-rule or approval routes that reads a string `body.error` reads `body.error.message` for these answers. +- d952358: fix(rest): the hand-built REST write routes answer a sandboxed hook that crashed, or that declared a server error, as the data routes do (#22764) + + Clause-②: no + + **What was wrong.** Two kinds of sandboxed hook failure reach a route as faults, not refusals. One is a hook body that crashed (`ctx.input.title.trim()` on a number): the route receives the QuickJS debug wrapper on `.message`, for example `hook 'guard' threw: TypeError: boom`. The other is a hook that declared a server-band status (`status: 503`). The `/data` routes answer both as faults and withhold the text: a crash answers `500 INTERNAL_ERROR` with `Internal server error`, and a declared `503` answers `503` with its own code and `Internal server error`. The routes below build their error answer by hand, and they sent the wrapper and the native error text to the caller. The four external-datasource routes also answered a crash as `400`, the caller's mistake. + + **What changes on the wire, for those two failures only.** + + | routes | before | after | + | --- | --- | --- | + | `POST /sharing/rules`, `DELETE /sharing/rules/:idOrName`, `POST /sharing/rules/:idOrName/evaluate` | `500 RULE_*_FAILED`, flat `{ code, error }`, the wrapper | the `/data` answer, nested `{ success: false, error: { code, message } }` | + | `POST /security/suggested-bindings/:id/confirm` and `/dismiss` | `500 SUGGESTION_*_FAILED`, the wrapper | the `/data` answer, `{ error: { code, message } }` | + | the nine `POST /approvals/requests/:id/*` writes (`approve`, `reject`, `recall`, `revise`, `resubmit`, `reassign`, `remind`, `request-info`, `comment`) | `500 APPROVAL_*_FAILED`, flat `{ code, error }`, the wrapper | the `/data` answer, nested | + | `POST /packages/publish` | a crash: `500 INTERNAL_ERROR` with the wrapper; a declared `503`: `503` with the hook's sentence | the `/data` answer | + | `POST /datasources/:name/external/tables/:remote/draft` and `/import`, `/external/refresh-catalog`, `/external/validate` | `400 EXTERNAL_DATASOURCE_ERROR` / `400 EXTERNAL_IMPORT_ERROR`, the wrapper for a crash, the sentence for a declared `503` | the `/data` answer, nested | + | `POST /security/explain` | `500 EXPLAIN_FAILED`, the wrapper | the `/data` answer, nested | + | `POST /analytics/dataset/query`, a crash only (a declared 5xx already answered as `/data` does) | `500 ANALYTICS_QUERY_FAILED`, the wrapper | the `/data` answer, in this route's flat `{ code, error }` | + + "The `/data` answer" is `500 INTERNAL_ERROR` with `Internal server error` for a crash, whatever status it declared. For a refusal that declared a 5xx, it is that status, the declared code and `Internal server error`. The prose is released only when the hook marked the declaration a refusal, and a `userMessage` the hook set rides the answer. The withheld text goes to the server log. The answer is computed by the same classification the `/data` routes use, so it cannot drift from theirs. + + The same arms serve the read routes of the sharing-rule, suggested-binding and external-datasource families (`GET /sharing/rules`, `GET /sharing/rules/:idOrName`, `GET /security/suggested-bindings`, `GET /datasources/:name/external/tables`), so they answer these two failures the same way. + + **Unchanged.** A hook's refusal keeps its sentence and status on every route. A refusal that declared a 4xx on the external-datasource routes still answers `400` with that family's code. A fault that is not from a sandboxed hook keeps each route's own answer and code. A service-declared `503` on `POST /packages/publish` is one such fault. The per-record share routes already withheld the text at their own `500 SHARE_*_FAILED` and are unchanged. + + **Upgrading.** Nothing to change. A client that branched on `RULE_*_FAILED`, `APPROVAL_*_FAILED`, `SUGGESTION_*_FAILED`, `EXPLAIN_FAILED`, `ANALYTICS_QUERY_FAILED` or a `400` from an external-datasource route to detect a broken hook reads `INTERNAL_ERROR` (or the declared 5xx code) instead. Those codes still answer every other fault. +- 3fdb92d: A sandboxed hook or action that fails on the sandbox's own fault (a capability it was not granted, a `ctx.api` method that is missing or unavailable, a CPU budget or wall-clock ceiling, an install or marshal failure) is now answered by the hand-built write routes the way `POST /api/v1/data/:object` answers it: a withheld `500` with `Internal server error`. The runner's diagnostic text, which names the hook, the object and the capability, goes to the server log only. + + Clause-②: no + + Before, 23 write routes relayed that text to the caller: `POST /analytics/dataset/query`, `POST /security/explain`, `POST` and `DELETE /data/:object/:id/shares`, the three sharing-rule writes, the two suggested-binding writes, the nine approval-request writes, `POST /packages/publish` and the four `POST /datasources/:name/external/*` routes. Most of them answered `500` with their own fault code, and the four external routes answered `400`. Twenty-one of them now answer `500 INTERNAL_ERROR`, the answer each already gave a sandboxed body that crashed. The record-share routes (and the share list, `GET /data/:object/:id/shares`) keep their own `500` and `SHARE_*_FAILED` code, as they do for a crash, and withhold the sentence the same way. + + A refusal a sandboxed body throws keeps its own sentence and status on every route. No key, default, enum member or export moves. +- bb7ccde: The CRUD doors' registrar body lives in its own module, with no route, handler or answer changed + + Clause-②: no (a move: the six CRUD routes, their order, every handler and every response are unchanged, and the published declarations are byte-identical) + + - `RestServer#registerCrudEndpoints` keeps its name, docblock and place. The body that registers `GET`, `POST` and `POST …/query` on `{basePath}{crud.dataPrefix}/:object`, and `GET`, `PATCH` and `DELETE` on `…/:object/:id`, moved unedited to `packages/rest/src/crud-routes.ts`. The five `crud.operations` switches and `crud.dataPrefix` gate and shape the same mounts. + - Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. +- df000d4: The `/meta` type list, whole-store maintenance and per-type list routes live in their own module, with no route, handler or answer changed + + Clause-②: no (a move: the seven routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical) + + - `RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head. The statements that register `GET {metaPath}` and `GET {metaPath}/types`, `GET {metaPath}/diagnostics`, `GET {metaPath}/_drafts`, `POST {metaPath}/_migrate-stored` and `GET {metaPath}/:type` moved, re-indented, to `packages/rest/src/meta-collection-routes.ts`, and the method calls them at their old position, inside the umbrella's guarded registration. The `metadata.endpoints.types`, `.maintenance` and `.items` switches gate the same mounts. + - Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. +- 646303e: The `/meta` per-item read routes live in their own module, with no route, handler or answer changed + + Clause-②: no (a move: the four routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical) + + - `RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head. The statement that registers `GET {metaPath}/:type/:name/references`, `GET {metaPath}/:type/:name/layers`, `GET {metaPath}/book/:name/tree` and `GET {metaPath}/:type/:name` moved, re-indented, to `packages/rest/src/meta-item-read-routes.ts`, and the method calls it at its old position, inside the umbrella's guarded registration. The `metadata.endpoints.item` switch gates the same mounts. + - Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. +- e9bc60b: The `/meta` per-item write routes live in their own module, with no route, handler or answer changed + + Clause-②: no (a move: the two routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical) + + - `RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head, its per-item registrar included. The two statements that register `PUT {metaPath}/:type/:name` (the item save) and `DELETE {metaPath}/:type/:name` (the reset to the artifact default) moved, re-indented, to `packages/rest/src/meta-item-write-routes.ts`, and the method calls them at their old position, inside the umbrella's guarded registration. They still register through that per-item registrar, so the `metadata.endpoints.item` switch gates the same mounts. + - Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. +- 52ff308: The `/meta` history family routes live in their own module, with no route, handler or answer changed + + Clause-②: no (a move: the five routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical) + + - `RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head, its per-item registrar included. The five statements that register `GET {metaPath}/:type/:name/history` (the change log), `GET …/audit` (the audit trail), `POST …/publish` (the promotion of a pending draft), `POST …/rollback` (the restore of an earlier version) and `GET …/diff` (the comparison of two stored versions) moved, re-indented, to `packages/rest/src/meta-item-history-routes.ts`, and the method calls them at their old position, inside the umbrella's guarded registration. They still register through that per-item registrar, so the `metadata.endpoints.item` switch gates the same mounts. + - Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. +- d1f082d: The `/meta` object state-machine read and the published read live in their own modules, with no route, handler or answer changed + + Clause-②: no (a move: the two routes per base, their place in the `/meta` registration order, every handler, every response and the anonymous-deny gate in front of them are unchanged, and the published declarations are byte-identical) + + - `RestServer#registerMetadataEndpointsInner` keeps its name, docblock, place and head, its per-item registrar included. The statement that registers `GET {metaPath}/object/:name/state/:field` (the legal next states of an object field's state machine) moved, re-indented, to `packages/rest/src/meta-object-state-routes.ts`, and the block that registers `GET {metaPath}/:type/:name/published` (an item's published version) moved to `packages/rest/src/meta-item-history-routes.ts`, beside the history family. The method calls each at its old position, inside the umbrella's guarded registration. The state read still answers to `api.enableMetadata` alone, and the published read still registers through the per-item registrar, so the `metadata.endpoints.item` switch gates the same mounts. + - Nothing to do for a consumer: no export of the package entry, no route, no configuration key and no response moved. +- 8c10776: fix(metadata-protocol): a stored-metadata migration records the user who ran it, or nobody, never a label + + Clause-②: no + + - **What was wrong.** The stored-metadata migration pass (`os migrate meta --stored`, `POST /api/v1/meta/_migrate-stored`, the runtime dispatcher's `POST /metadata/_migrate-stored`, and `migrateStoredMetadata()` called in process) recorded a label as the writer of every row it rewrote: `'os migrate meta --stored'`, `' (POST /api/v1/meta/_migrate-stored)'`, `'POST /api/v1/meta/_migrate-stored'`, or `'migrate-stored'`. The writer lands in `sys_metadata_history.recorded_by` and in the row's `updated_by`. Both columns are references to `sys_user`, so the label pointed at no user. Nothing refused it, because metadata writes run as the system. + - **What it does now.** The pass records the id of the user who ran it, exactly as every other metadata write does. A REST or runtime call by a signed-in user with `manage_metadata` records that user's id. A run with no user records `null`, which means "system-initiated". That covers the CLI, which opens the database directly with no session, and a system caller. The pass still names itself on each history row's `source` (`migrate-stored`), as it did before. + - **What an operator sees.** History rows and rewritten rows written by a migration from this release on name the person who ran it, or nobody. They never name a command or a route. Rows a migration rewrote before this release keep the label they were given. + - **Contract text.** `MetadataItem.authoredBy`'s description now gives the same rule as `PutOptions.actor`: the acting user's id, or `null` for a system-initiated write, never a label such as `"system"` or `"cli"`. The schema accepts the same values as before. +- 0511341: fix(rest): `GET /api/v1/openapi.json` documents a valid declared endpoint that the served read decorated, and stops logging it as a declaration to fix (#23083) + + Clause-②: no + + **What was wrong.** On a composition with no endpoint matcher (no `metadata` service with `matchEndpoint`, for example `@objectstack/verify`'s `bootStack`), the document is built from the `api` items `getMetaItems` serves. That read adds `_diagnostics` to every item, and `ApiEndpointSchema` is closed, so the document's own parse refused every valid declaration by that key. The endpoints were left out of the document. For each one, the server logged at `error` that the declaration did not satisfy `ApiEndpointSchema` and should be fixed. Measured on the showcase booted through `bootStack`: 0 of its 2 declared endpoints in the document, and 2 such `error` lines. + + **What changes.** + + - The document's parse first removes the read-time decorations (`METADATA_READ_DECORATIONS` from `@objectstack/spec/kernel`: `_diagnostics`, `_draft`). It is the same strip the seed door applies. On that boot the document now lists both endpoints, and no line about them is logged. + - The ADR-0010 envelope keys (`_packageId`, `_provenance`, …) are allowlisted by the schema and parse as before. Any other unknown key is still refused. + - A declaration that really is invalid is still left out of the document and logged at `error`. The log line no longer says that the endpoint matcher excludes the item too. The document's parse does not know that: the matcher judges its own stored copy after removing the storage envelope, and where no matcher is reachable there is none to ask. + + **Upgrading.** Nothing to change. `ApiEndpointSchema` is unchanged. +- 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')`. +- 0d34066: REST's request identity door resolves through `@objectstack/core`'s `resolveRequestExecutionContext`, the one request-to-`ExecutionContext` composition + + Clause-②: no + + - `RestServer` resolves the caller of every data, metadata and package route by calling `resolveRequestExecutionContext` with this door's differences as named parameters: `anonymous: 'fail-closed'`, `authGate: 'reread-when-active'`, `credentials: 'api-key-and-session'`, `sessionApi: 'required'`, `session: 'request'`, its settings lookup for the localization, its own request-locale parse, `accessToken: 'withhold'`, and no OAuth provenance or admission channel. The environment and auth-service resolution, the header normalisation, the data engine, the tenancy wiring, the per-request memo, the `Server-Timing` disclosure and the fail-closed `catch` stay at the door. + - The answers are the ones the door gave before: an anonymous caller gets `401` with the shared body; a session the authentication policy holds gets `403` with its code and message while a gate feature is active; an API key is judged by its own owner's posture; an unreadable permission store, tenancy service or active gate re-read gets `503 SERVICE_UNAVAILABLE`; and an API key its own lifecycle refuses gets its `403` and code. + - One ordering moved, and it shows only when two faults coincide on the single-kernel wiring. The data engine is now resolved before the auth service's session read is asked for. If the configured engine provider fails AND the auth service exposes no session read (or its `getApi()` rejects), the answer is now `503 SERVICE_UNAVAILABLE` (the engine outage) instead of `401`. With a working auth service, the engine outage was already `503`. + - Nothing to do for a consumer: no export, route, configuration key or response shape changed. +- 1eac7ad: fix(rest): `GET /forms/:slug` serves a public form's section headings in the visitor's language, beside the field labels it already translated + + Clause-②: no + + An anonymous visitor of a published form cannot load the translation bundle in the browser, so the server is the only place its section titles can be translated. The route translated `objectSchema` (the field labels) under `Accept-Language` and returned each form section as authored, so a zh-CN visitor read English section titles over Chinese field labels. It now localizes each section's `label` and `description` through the same `objects.OBJECT._sections.NAME` keys the signed-in console reads, with the request's locale, the deployment's fallback chain and default locale, and the packaged view as the override base. + + With `Accept-Language: zh-CN` over a form whose section `about` is labelled `Tell us about yourself`, and a zh-CN bundle carrying `objects.crm_lead._sections.about.label: 介绍一下你自己`: + + - FROM: `form.sections[0].label` is `Tell us about yourself`, and `objectSchema.fields.first_name.label` is `名`. + - TO: `form.sections[0].label` is `介绍一下你自己`; the field label is unchanged (`名`). A section `description` follows the same key's `description`. + + What does not change: a section whose key the bundle lacks keeps its authored text; a section with no `name` is served as authored; a section that authors no heading is not given one; a heading edited on top of the packaged view keeps the edit. The response shape (`slug`, `object`, `label`, `form`, `objectSchema`), its top-level `label`, `Vary: Accept-Language`, the declared-field narrowing, the lookup / master-detail / user strip and `POST /forms/:slug/submit` are unchanged. +- 5c2ac83: fix(rest): a client refusal its producer declared is no longer logged as `[REST] Unhandled error` with a stack trace + + Clause-②: no + + - **What the server log stops printing.** A refused request whose thrown error declares both a 4xx `status` (or `statusCode`) and a registered ADR-0112 `code`, and whose answer carries that same status and code, now prints nothing on the `[REST] Unhandled error` channel every REST route catch shares. Before, each one printed an error-level `[REST] Unhandled error:` line plus the error's full stack, once per request. Two measured cases: a record write whose file violates a field's declared `accept` / `maxSize` (`400 ERR_FILE_CONSTRAINT` on `POST /api/v1/data/:object` and `PATCH /api/v1/data/:object/:id`), and a metadata save that fails spec validation (`422 INVALID_METADATA` on `PUT /api/v1/meta/:type/:name`). Other registered codes thrown with a declared 4xx, such as `FILE_FIELD_BULK_WRITE_REFUSED`, go quiet too, with no per-code list to extend. + - **What still logs, unchanged:** a handler bug or any other undeclared fault (answered `500`); a 4xx that carries no `code` (for example a sandboxed hook's business-rule `400`); a 4xx whose `code` is not registered (it reaches the wire as `declaredCode`); a registered `code` on a status the producer did not declare; a declared `5xx` without `refusal: true`; and a sandboxed body that crashed, whatever it declared. The anonymous public-form submit route keeps its own `[REST] Public form submit error` line as before. + - **Unchanged:** every response's status, `code` and body. Only the server log line moves. +- c1c0a90: fix(rest): the API-exposure check on every `/data` request reads only the object it checks, not the whole object list + + Clause-②: no + + - **Faster `/data` requests.** Before each `/data` read or write, the REST layer checks the object's `enable.apiEnabled` and `enable.apiMethods`. To read that one object's flags, the check used to load every object's metadata and run a full schema diagnostic on each one. It now reads only the object named in the URL. Measured on the showcase app on a shared machine: 33 `GET /api/v1/data/showcase_account` calls took 647 ms instead of 2421 ms (-73 %), and the metadata read behind the check fell from 56-61 ms to about 4.5 ms per request. + - **Same answers.** Code-registered objects, objects stored in the environment and objects that another package extends get the same `404 OBJECT_API_DISABLED` and `405 OBJECT_API_METHOD_NOT_ALLOWED` as before. An unknown object is still answered by the data path's `404 OBJECT_NOT_FOUND`. When the metadata store cannot be read, the check still lets the request through and logs its `api-exposure gate` warning. Nothing is cached, so a metadata change applies from the next request. + - **One refusal changes, for an object whose package is disabled.** A denied operation on such an object now gets the exposure answer instead of the disabled-package one: + - an operation outside `apiMethods`: FROM `404 OBJECT_PACKAGE_DISABLED` TO `405 OBJECT_API_METHOD_NOT_ALLOWED`; + - any operation on an object with `apiEnabled: false`: FROM `404 OBJECT_PACKAGE_DISABLED` TO `404 OBJECT_API_DISABLED`. + + An allowed operation still answers `404 OBJECT_PACKAGE_DISABLED`. No accepted request is now refused, and no refused request is now accepted. +- 9975c26: docs(rest): the public form routes' docstring says what authorizes an anonymous submit — the form's own declaration (`publicFormGrant`), not a `guest_portal` permission set — and that nothing falls open without one (#24130) + + Clause-②: no + + The docstring of `RestServer#registerFormEndpoints`, which ships in `@objectstack/rest`'s published declarations, said the submit route delegates security to a `guest_portal` permission set on its execution context, that a deployment without that set "falls open", and that secure-by-default deployments must pair the form routes with one. None of that is what runs. The submit hands the engine a `publicFormGrant` scoped to the form's target object, and the security plugin admits on that grant alone, before any permission set resolves: the create and the read-back on that object only, every other operation and object refused, the server-managed anchors stripped from the payload. The grant never falls open and admits the same operations whether or not a `guest_portal` set is registered. The docstring now says this, and says what the `guest_portal` name on the context does reach: where a deployment registers a set under it, that set is what the grant's result masker reads over the record the grant hands back, and the submit answers the created id only. + + Text only; nothing the form routes do changes. +- 2ac0616: fix(rest)!: an anonymous public-form submit that leaves a form-required field empty answers `400 VALIDATION_FAILED` naming the field, and stores nothing + + Clause-②: no (narrowing) + + + + - **What changed.** `POST /forms/:slug/submit` now enforces the form's own `required`: a section entry `{ field: 'email', required: true }` is judged before the write, with the rule the engine's object-level check uses (`isEmptyForRequired` from `@objectstack/core`). A submission whose value for such a field is absent, `null`, `''`, only whitespace, or `[]` on a multi-value field is refused, and no row is created. Before, the engine judged only the fields the OBJECT requires, so a field only the form required answered `201` and was stored empty — while the rendered form refused the same submission. + - **The refusal** is the object-level one's envelope on this route, key for key: `400` with top-level `code: 'VALIDATION_FAILED'`, `error` (the messages), and `fields[]` of `{ field, code: 'required', message, label }`, worded by the same validation catalog (for example `Email is required`). Every empty form-required field is named in one refusal, in the form's order. + - **BREAKING — a narrowing, and who it affects.** A script or integration that posts to a public form and omits a field the form marks `required: true` now gets `400` instead of `201`. The fix is to send that field. Nothing that was refused before is accepted now. + - **Judged only where the form asks.** An entry the form hides (`hidden: true`), or shows only conditionally (`visibleWhen` on the entry or on its section), is not judged — nothing server-side evaluates a form-view `visibleWhen`. A server-managed anchor (`owner_id`, `organization_id`, …) is never written by this route, so it is never judged. An object `defaultValue` or a `beforeInsert` hook does not satisfy a field the visitor omitted: the route judges what the visitor sent. +- af284b0: feat(spec): the remaining `getSession` readers read `AuthSessionApi`'s declared result, and none reads a top-level `activeOrganizationId` + + Clause-②: yes (widening) + + - **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains three optional `user` members**, each declared for a named reader: `isPlatformAdmin` (`boolean`, the ADR-0068 D2 rung plugin-auth's `customSession` derives) and `role` (`string | null`, better-auth's `admin` plugin scalar), both read by `judgePlatformAdmin` in `@objectstack/plugin-auth`; and `name` (`string`), read by the runtime dispatcher's AI-route `req.user` resolver as the display name. Every member is optional, so every implementer and caller typechecks unchanged. + - **The readers read through the declared type instead of `any` or a cast** (types only, runtime behaviour unchanged): `judgePlatformAdmin` and the four platform-admin mounts in `@objectstack/plugin-auth` (better-auth's own session type now has to fit the declaration at those reads), the REST server's two session reads (`@objectstack/rest`), the dispatcher's posture read, `resolveSessionData` and both `HttpDispatcher` reads (`@objectstack/runtime`), the cloud-connection and install-local session reads (`@objectstack/cloud-connection`), the webhook redeliver door (`@objectstack/plugin-webhooks`), and the storage session bridge (`@objectstack/service-storage`). + - **Fix — the top-level `activeOrganizationId` fallback is removed** from the storage upload stamp (`@objectstack/service-storage`) and from install-local's `resolveActiveOrgId` (`@objectstack/cloud-connection`). The active organization is read from `session.activeOrganizationId` alone, where the contract declares it. No in-process producer answers a top-level `activeOrganizationId` (better-auth's read is `{ user, session }`), so for the shipped auth plugin nothing changes. An auth service that answered only the flattened shape now gets no organization: the upload is not stamped, and install-local reports no active organization. To fix it, answer the active organization on `session.activeOrganizationId`, as `AuthSessionApi` declares it. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [23dae89] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [0fea05f] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [84c4fff] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [c7168ac] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [077b75f] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/metadata@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/types@18.0.0 + - @objectstack/service-package@18.0.0 + - @objectstack/observability@18.0.0 + ## 18.0.0-next.5 ### Minor Changes diff --git a/packages/rest/package.json b/packages/rest/package.json index 0ab4689adea..499ccba96c3 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "18.0.0-next.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "ObjectStack REST API Server - automatic REST endpoint generation from protocol", "type": "module", diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index 4ad7c220499..53cfb6dd7f7 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,2484 @@ # @objectstack/runtime +## 18.0.0 + +### Minor Changes + +- 191e043: feat(plugin-webhooks,runtime)!: declared webhooks are read from the registry — the boot seeder retires, no `sys_webhook` row is written for one, and it is switched through the activation ledger + + Clause-②: yes (narrowing) + + + + **BREAKING** (a narrowing of where a declared webhook can be edited), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0131 D2 says no seeder writes declared items into a table, and D6 charts behavioural types under regime C. A webhook declared with `defineStack({ webhooks })` now follows both, as declared sharing rules already do: the dispatcher reads the declaration from the metadata registry on every subscription-cache refresh, and `bootstrapDeclaredWebhooks` no longer runs. + + What changes: + + - **No `sys_webhook` row for a declared webhook.** Before: every boot wrote or refreshed one `managed_by: 'package'` row per declared webhook, shown and editable in Setup → Integrations → Webhooks. Now: a fresh install writes none, and Setup lists only the webhooks an organization authors. The declared webhook still delivers, with the same URL, method, triggers, `timeoutMs`, signing secret and headers; its secret and headers are code, held in memory, and never stored. Its deliveries carry `ref_id = declared:`. + - **A declared webhook is read-only, and switched through the activation ledger.** Before: an administrator edited the seeded row in Setup (`customized: true`). Now: switch it on or off for the deployment with `POST /api/v1/security/_activation/webhook/` and `{ "enabled": true | false }` — one `sys_metadata_activation` row of type `webhook`, which overrides the declared `isActive` in either direction, so a webhook shipped inactive is switched on the same way. The door is the declared-sharing-rule door with `webhook` admitted: `manage_metadata`, then the platform operator under a wall; `404` for a name nothing declares; `503` when no declared-webhook reader is bound or it cannot be read. The dispatcher refreshes in the request that wrote the row. Remedy for a change beyond on/off: author a copy under a new name as a `sys_webhook` row, and switch the declared one off. + - **`label` and `description` on a declared webhook are no longer shown anywhere.** The seeded row was their only reader. They are display-only, so no delivery changes; the liveness ledger marks them dead, owed enforce-or-remove. + + On a database an 18.0 install created (expand first — nothing is moved or deleted): + + - The rows the 18.0 seeder wrote stay where they are. A pristine one (`managed_by: 'package'`, not `customized`) is ignored in favour of its declaration, so a declared webhook is never delivered twice; it follows the declaration's URL and switch, as the seeder's rewrite on every boot made it do. + - A seeded row an administrator edited (`customized: true`), or a row an administrator authored under a declared name, answers as itself, and the declaration is not dispatched beside it — an administrator's `active: false` on a noisy webhook keeps holding. This is logged at `warn` once per name per process, with the remedy. + - A seeded row whose declaration was removed keeps delivering, as it did on 18.0. + - Redelivering an old delivery of a pristine seeded row is judged by the declaration that now answers for it, so a webhook switched off through the ledger is not re-sent. + + Deleting the seeded rows, and re-pointing their `sys_http_delivery` history at the declaration, is a step of the ownership migration in a later 18.x release. +- c395405: fix(runtime)!: a flow resume body that carries both `inputs` and `variables` is refused with `400 VALIDATION_FAILED`; it no longer answers 200 with the `variables` bag silently dropped + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing on a public HTTP door: `POST /api/v1/automation/:name/runs/:runId/resume`), shipped as `minor` under the launch-window convention for breaking changes. + + `variables` is an accepted alias of `inputs` on the resume route: both name the screen submission. A body that carried both used to answer `200` with `success: true`, the run resumed on the `inputs` bag, and the `variables` bag was never read, even when it held a field the screen does not declare. Sent alone, that same bag is refused `400 INVALID_SCREEN_INPUT`. + + - **Now.** A body carrying both keys answers the route's own validation-failure envelope: `400`, `error.code: 'VALIDATION_FAILED'`, and `details.fields` naming `inputs` and `variables` (code `invalid_value`). The message says to send `inputs` alone. The engine is never consulted, so the paused run stays parked and the same call with a corrected body resumes it. + - **Unchanged.** A body carrying `inputs` alone, or `variables` alone, is forwarded exactly as before, and the engine judges it exactly as before. A key whose value is `undefined` counts as absent, as it already did for the value-shape check. The unknown-key and value-shape refusals keep their messages and come first. + + What to do. Send the screen submission under `inputs` only. `@objectstack/client`'s `automation.resume()` and the console's flow runner already send `inputs` alone, and the MCP `resume_run` tool builds its signal in process rather than through this body, so none of them is affected. +- 67b669e: The security catalog's activation door switches a position or permission set the environment authored, and a ledger write it refuses answers with the status its code means + + Clause-②: yes (widening) + + - **The door accepts environment-authored items** (`@objectstack/runtime`). `POST /api/v1/security/_activation/:type/:name` switched a position or permission set off and on only when a package shipped it. FROM: an item saved through the metadata door (`PUT /api/v1/meta/position/:name`, `PUT /api/v1/meta/permission/:name`) was refused `503` with `{ code: 'VALIDATION_FAILED', message: 'Package is required' }`, and nothing was written. TO: it is switched like a packaged one, `200`, and the resolver honours the row: a switched-off position stops granting its permission sets, and a switched-off set stops granting by every path. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared name `404`, a type with no switch or an audience anchor `400`, the last administrator `403`). + - **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, a hook's refusal keeps its own pair (the last-admin guard's `403 PERMISSION_DENIED`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. + - **The ledger row of an item no package ships carries no package** (`@objectstack/platform-objects`, `@objectstack/core`). `sys_metadata_activation.package_id` is no longer required. It is empty exactly when no package ships the item. It is not part of the row identity (`metadata_type`, `name`), and no reader decides anything from it. The column was already nullable on disk (ADR-0113: `required` is a write-time check), so there is nothing to migrate. + - **One spelling for "no package"** (`@objectstack/core`). `MetadataActivationRow.packageId` is optional. FROM: `packageId: string`, with `''` the only way to say "no package". TO: omit `packageId` for an item no package ships, and the store writes no value. `ObjectStoreMetadataActivationStore.setActive` refuses `packageId: ''` with a `TypeError` before it reads or writes anything, and `list()` returns `packageId` only when the row names a package. If your code passes `''`, omit the key instead. + - **The flow row follows the shared row** (`@objectstack/service-automation`). `FlowActivationRow.packageId` is optional to match. Every flow row still names its package, because the flow toggle refuses a flow no package ships before it writes. + - **The action activation door's answer for an action no package ships changes; the action is still refused.** `POST /api/v1/actions/_activation/:object/:action` still passes `''` as the package of such an action. The write is still refused and still writes nothing, but the store's `TypeError` refuses it now, with the store's sentence. FROM: `503 VALIDATION_FAILED`. TO: `503 SERVICE_UNAVAILABLE`. An environment-authored action could not be switched on `main` before this change and cannot after it. +- 45e206e: feat(runtime): `POST /api/v1/security/permission-sets/:name/clone` — clone a permission set by name from the security catalog (ADR-0126 §7.1) + + Clause-②: yes (widening) + + - **What is new.** A route that copies a permission set's whole definition under a new machine name. The body is `{ name, label }`: both are required, no other key is accepted, and `name` must be snake_case. The answer is `{ permissionSet, notice }`: the copy that was saved, and a sentence naming what was not copied. + - **Where it reads and writes.** The source is read through the security catalog (the engine registry first, then the metadata service), the same read the authorization resolver uses. It never reads a `sys_permission_set` row, so it works on a database with none. The copy is saved through the metadata protocol's `saveMetaItem` as a `permission` item authored in this environment, with no organization and no package. The save is create-only, so a row written under the name meanwhile is refused, not overwritten. + - **What is copied, and what is not.** Every facet of the definition is copied. One server-declared list (`PERMISSION_SET_CLONE_DROPPED_KEYS`) is removed: the protection envelope and read decorations (derived from the spec), `packageId`, `managedBy`, `protection`, and the two privilege decisions `adminScope` and `isDefault`. The copy records nothing about its source. + - **Who may call it.** A holder of `manage_metadata`, through the same verdict the metadata save door asks (`metaWriteCapabilityVerdict`); `isSystem` bypasses. Anyone else gets `403 PERMISSION_DENIED`, and an anonymous caller `401`. + - **Refusals.** `400 VALIDATION_FAILED` for a malformed body or a name failing the name rule; `404` for an unknown source; `409 RESOURCE_CONFLICT` for a name the catalog already holds, including the source's own; `503 SERVICE_UNAVAILABLE` when no catalog is bound or it cannot be read; `501` when no metadata protocol is composed. + - **Nothing to migrate.** No existing route changes. The Setup action `clone_permission_set` keeps working as before. +- cd3d391: feat(runtime): `POST /api/v1/security/_activation/:type/:name` switches a position or a permission set on or off for the deployment (ADR-0126 §3 regime C, as ADR-0131 D6 amends it) + + Clause-②: yes + + The authorization resolver reads whether a position or a permission set is switched off from the activation ledger, `sys_metadata_activation` (types `position` and `permission`), and never from the catalog row's `active` column (ADR-0131 D3). Until now nothing wrote that ledger for these two types, so a deactivation switched nothing off. This route is the write half: + + - **Route:** `POST /api/v1/security/_activation/:type/:name`, with `:type` either `position` or `permission`. It is also mounted under `/api/v1/environments/:environmentId` when project scoping is on, like the action door. + - **Body:** `{ enabled?: boolean }`. `enabled` defaults to `true`. Unknown keys and a non-boolean `enabled` are refused `400 VALIDATION_FAILED`, the same body reader `POST /actions/_activation/:object/:action` uses. + - **Effect:** one `sys_metadata_activation` row through the engine, carrying the definition's package. Re-enabling updates that row. Switching a position off stops every grant through it for every holder in the deployment. Switching a permission set off stops it granting by every path. No definition and no catalog row is written. + - **Authority:** the same as the flow and action activation doors. The caller needs `manage_metadata`. Under a `group` or `isolated` tenancy posture the caller must also be the platform operator (ADR-0126 §5). Refusals are `403 PERMISSION_DENIED`, and nothing is written. + - **Refusals:** the audience anchors `position/everyone` and `position/guest` are refused `400` in either direction, before anything is read or written, because one row would switch the authenticated or anonymous baseline off for every principal. A name that does not resolve in the security catalog is `404`. No catalog bound, or a catalog that cannot be read, is `503 SERVICE_UNAVAILABLE`. A composition without the ledger object is `501`. Switching `admin_full_access` off is refused `403 PERMISSION_DENIED` by the last-admin guard's ledger hook. + + The route is not in the JS SDK. Its caller is the Setup console's Deactivate on the position and permission-set pages, which calls the platform API directly. +- 4806b18: Under a walled posture a package that carries security catalog items is the platform operator's: tenants are refused installing, upgrading, uninstalling or deleting it + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant (managed packages, Studio)."). + + **Why.** A package registers its positions, permission sets and capabilities environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack, with an organization owner granted `manage_metadata` inside their own organization (never the platform operator): + - the owner re-installed an operator-installed package at a new version through `install-local`, adding `manage_users` to a permission set another organization's member holds, and that member's next resolution carried `manage_users`; + - the owner's `install-local` uninstall of that package took the set away from that member; + - the owner's `DELETE /api/v1/packages/:id` of a runtime package the operator had saved a permission set into did the same. + + The existing name-conflict refusal stops only a package that shadows a name another holder has, never a write by the package that holds it. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `POST /api/v1/marketplace/install-local` (`@objectstack/cloud-connection`) of a package whose incoming version declares a position, permission set or capability, or, on an upgrade, whose installed version does: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the package is registered or the ledger written. The whole package is refused, since a package installs whole. The message names the posture, and `error.details.catalogItems` lists the items. + - `DELETE /api/v1/marketplace/install-local/:id` of a package whose installed version declares one: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the ledger is touched. + - `DELETE /api/v1/packages/:id` on the runtime dispatcher, of a writable package holding stored positions or permission sets: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is deleted. + + **Unchanged.** A package with no catalog item installs, upgrades and uninstalls as before, for the same callers. The platform operator does all of the above as before. Under `single` (and with no `tenancy` service) nothing changes. Disabling a package is unchanged: it was measured not to change what any organization resolves. + + **The remedy.** The platform operator installs, upgrades and removes catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-writes-retired`. Both doors ask the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), once per catalog type the package declares. `@objectstack/objectql` now exports `declaredSecurityCatalogNames`, the engine's own reading of what a manifest declares, so a package door judges the declarations the engine registers. +- 1802315: Under a walled posture the `/packages` verbs that publish, discard, roll back, revert, duplicate or adopt security catalog items are the platform operator's + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant"). + + **Why.** A package's positions and permission sets are environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack: the operator published a permission set in a writable package, and another organization's member held it. An organization owner granted `manage_metadata` inside their own organization (never the platform operator) then: + - published the operator's pending draft adding `manage_users`, and that member's next resolution carried it; + - rolled the package back, and the member lost it; + - reverted the operator's commit, and the member lost it; + - copied the set, under the same name, into a package of their own, and `GET /meta` then served their copy; + - discarded the operator's pending draft (through `revert`, and `discard-drafts` is the same discard by a second door); + - adopted the environment's catalog rows into their own package. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner). Every door is on the runtime dispatcher, and each **FROM** is: admitted to any `manage_metadata` holder. Each **TO** is `403 PERMISSION_DENIED`, naming the posture, before the verb runs: + + - `POST /api/v1/packages/:id/publish-drafts`, when the package has a pending position, permission-set or capability draft. + - `POST /api/v1/packages/:id/revert`, when the package has such a draft. + - `POST /api/v1/packages/:id/discard-drafts`, when the package has such a draft. + - `POST /api/v1/packages/:id/rollback`, when a commit it would undo touched a position, permission set or capability. + - `POST /api/v1/packages/:id/commits/:commitId/revert`, when that commit touched one. The commit's own items are judged: the verb takes the commit id alone, so the URL's package segment changes nothing. + - `POST /api/v1/packages/:id/duplicate`, when the source package's published environment rows (the ones it copies) hold one. + - `POST /api/v1/packages/:id/adopt-orphans`, when the environment's orphaned rows include one. + + **Unchanged.** A package whose drafts, commits or rows carry no catalog item is handled as before, for the same callers. The platform operator runs every verb as before. Under `single` (and with no `tenancy` service) nothing changes, and no extra read happens: the verdict is asked before the items are read. + + **The remedy.** The platform operator publishes, discards, rolls back, reverts, duplicates and adopts catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-verbs-retired`. Every door asks the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), through the same helper as the package delete. +- 06cc45d: Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). + + **Why.** The catalog is environment-level, and every organization resolves it by name. Measured on a booted `isolated` and `group` stack: an organization owner granted `manage_metadata` inside their own organization saved `PUT /meta/permission/:name` over a permission set another organization's member holds, and that member's next resolution carried the change. One organization's write changed what another organization's members resolve. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for `position` or `permission`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. + - The same doors for `capability`: admitted where they were admitted. `capability` is a code-only type, so these doors already refused its writes before this change; only the sentence the caller reads changes, to the posture refusal. For `capability`, the accept set narrows at the data door on `sys_capability`, below. + - `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. + - A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. This includes an ADR-0090 D12 delegate whose `adminScope` carries `authorEnvironmentSets`: under a wall it can no longer author environment permission sets at the data door. Its assignment and binding paths are untouched. + + **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. + + **The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-tenant-writes-retired`. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. +- 7895671: feat(metadata-core,rest,runtime,plugin-email,spec)!: the `/meta` doors carry no organization; organization-admin metadata authoring closes and `manage_org_presentation` retires (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio belongs to the whole deployment. The `/meta` doors of both transports (`@objectstack/rest` and the runtime dispatcher's `/meta` branch) used to thread the caller's active organization into writes and reads of the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`). They no longer do, for any type, and the read and the write flip together. + + **What changes.** + + - **Writes land environment-wide.** `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` on `/api/v1/meta/:type/:name` hand the protocol no organization, so the row and its audit and history rows carry `organization_id` NULL, whatever the caller's active organization. + - **Reads are environment → code.** The item read (both cache arms), the list, the layered view (`/layers` and `?layers=true`), `/published`, `?state=draft`, `GET /meta/_drafts`, `/history`, `/diff`, `/audit` (the environment rows, `organizationId: null`), `GET /meta/diagnostics` and `/references` name no organization. + - **An organization admin's metadata write is refused.** `metaWriteCapabilityVerdict` admits `isSystem` or `manage_metadata` only. A caller holding `manage_org_presentation` and not `manage_metadata` is answered `403` on all four item doors (`FORBIDDEN` on REST, `PERMISSION_DENIED` on the dispatcher) with `… requires the \`manage_metadata\` capability.`, for every type and whatever its active organization. + - **`manage_org_presentation` retires** from `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`). A permission set naming it still parses and loads, and its other grants still apply; the grant itself admits nothing. The `sys_capability` row seeded for it earlier is not pruned (the seeder upserts only); an operator may delete it in Setup. + - **The email-template boot sweep** (`@objectstack/plugin-email`) reads the effective templates environment → code, as the door serves them; it no longer reads in the Default Organization. + + **What moves for consumers.** + + - FROM `import { organizationIdForMetaWrite } from '@objectstack/metadata-core'` TO nothing: a `/meta` write carries no organization. Delete the call and the `organizationId` it fed. + - FROM `import { ORG_PRESENTATION_AUTHORING_CAPABILITY } from '@objectstack/metadata-core'` TO nothing: delete the import. + - FROM `metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType, activeOrganizationId, operation })` TO `metaWriteCapabilityVerdict({ isSystem, systemPermissions, operation })`: drop the two members. + - FROM `import { metaReadOrganizationId } from '@objectstack/rest'` TO nothing: a `/meta` read carries no organization. `metaCallerOrganizationId` stays. + - FROM `bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy })` TO `{ protocol }`: the `tenancy` source is gone. + - A permission set granting `manage_org_presentation`: grant `manage_metadata` to whoever must author those five types, and delete the stale grant. + + **What a deployment observes.** An overlay row an earlier release stored under an organization stays in `sys_metadata` untouched, and is no longer served by any `/meta` read or projected by the email-template sweep until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Under the `single` posture that is every earlier Studio save of the five types, because it was filed under the Default Organization: on the `/meta` doors such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. + + **What does not change.** Saves of every other type were already environment-wide. Flow saves, the capability gate's answer for `manage_metadata` holders and `isSystem`, the protocol's own organization-scoped refusals, and the `/packages` doors are untouched by this change. +- b389e43: feat(metadata-protocol,runtime,service-automation,spec)!: the metadata protocol refuses every organization-scoped write, and an uninstall is environment-wide (ADR-0131 D6/D12) + + Clause-②: yes (narrowing) + + + + **BREAKING, in both directions.** It narrows: an organization-scoped metadata write is refused, and request keys retire. It widens one refusal: a package uninstall naming neither `organizationId` nor `allTenants`, refused before with `400 TENANT_SCOPE_REQUIRED`, is now accepted and runs environment-wide. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The `/meta` doors already carry no organization; now the metadata protocol itself refuses an organization-scoped write from every door, and the per-organization write path behind it is deleted. + + **What changes.** + + - Every protocol write that names an organization is refused with `403 NOT_OVERRIDABLE`, before anything is read or written, for every metadata type and every tenancy posture. This covers `saveMetaItem` (draft and publish), `publishMetaItem`, `deleteMetaItem`, `rollbackMetaItem`, `revertCommit`, `rollbackToPackageCommit`, `publishPackageDrafts`, `discardPackageDrafts`, `revertStoredPackage`, `duplicatePackage` and `reassignOrphanedMetadata`. The message's first sentence names the tenancy posture in force. The five types that declared `allowOrgOverride` (`view`, `dashboard`, `report`, `translation`, `email_template`) and the `OS_METADATA_WRITABLE` hatch no longer open an organization scope. + - The audit ledger (`sys_metadata_audit`) and the commit ledger (`sys_metadata_commit`) record `organization_id` NULL. + - The `/packages` doors of the runtime dispatcher thread no organization into any verb: publish-drafts, discard-drafts, the commit list, commit revert, rollback, revert, adopt-orphans, duplicate, delete, and the package manifest read. + - `deletePackage` retires its `organizationId` and `allTenants` request keys and both `400 TENANT_SCOPE_REQUIRED` refusals. The dispatcher's `DELETE /api/v1/packages/:id` no longer refuses an operator with no active organization. An uninstall removes every row bound to the package in this environment. Who may uninstall is the door's operator gate, as before. + - A seed published with a package no longer takes the publisher's active organization: a seed dataset names the organization it populates (ADR-0131 D9). + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `SaveMetaItem` / `PublishMetaItem` / `DeleteMetaItem` request (`@objectstack/spec`) | drop it: the write lands environment-wide. The key is stripped at a spec parse (the schemas are not strict) and refused at the protocol: a request still naming one answers `403 NOT_OVERRIDABLE` | + | `organizationId` on any other protocol write verb's request | drop it, same refusal | + | `deletePackage({ packageId, organizationId })` or `deletePackage({ packageId, allTenants: true })` | `deletePackage({ packageId })`; either retired key answers `400 INVALID_REQUEST` and removes nothing | + | `DeletePackageRequest.organizationId` / `.allTenants` (`@objectstack/metadata-protocol`) | gone from the type | + | `UninstallCleanup`'s `organizationId` argument | gone; a cleanup receives `{ packageId, actor? }` | + | `TENANT_SCOPE_REQUIRED` in the error-code ledger | retired; no producer emits it | + | `findPlatformScheduleOrgGaps`' `organizationId` input | gone: every write is platform-level | + | a `seed` draft whose records carry no `organization_id`, relying on the publisher's active organization under `group` | set `organization_id` on each record (ADR-0131 D12, item 12); under `group` a seed record that names no organization is refused at load, and under `single` the loader still derives the Default Organization | + + **What a deployment observes.** + + - Rows stored organization-scoped before this release are not touched by any write. `POST /meta/_migrate-stored` reports each one as `skipped` and names the promotion ceremony (ADR-0131 C7); the stored-flow credential move reports such a flow as not moved (`NOT_OVERRIDABLE`) and logs that its credential is still in cleartext in that row — rotate it. A legacy organization-scoped draft is no longer promoted, discarded or reverted by a package verb, a commit recorded in a legacy organization layer is refused by `revertCommit`, and `duplicatePackage` and adopt-orphans copy or adopt the environment's rows only. + - An uninstall removes the package's legacy organization-scoped rows with it, as the declared cross-tenant uninstall did. + + **What does not change.** The protocol's reads still accept an organization and still serve legacy organization rows; that narrowing is a later stage. The anonymous form doors' read of the Default Organization's layer is unchanged. +- 5cfd866: feat(sharing): a share-link password can be sent in the `X-Share-Password` header whatever its characters, under a declared encoding (`X-Share-Password-Encoding: utf-8`) + + Clause-②: yes (widening) + + - **What was missing.** A browser cannot put a character above U+00FF in a request header (`Headers` throws a `TypeError` before the request leaves), and it strips leading and trailing spaces. `createLink` accepts any password, so a link whose password has a CJK character or an emoji could not be opened through the header. + - **What is now accepted.** A new companion request header, `X-Share-Password-Encoding`, declares how `X-Share-Password` is encoded. Its one value is `utf-8`, compared case-insensitively. Under it, `X-Share-Password` carries the password's UTF-8 bytes percent-encoded, as `encodeURIComponent(password)` produces them, and both public routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) decode it on both mounts: the sharing plugin's routes and the runtime dispatcher's `/share-links` domain. Both read the pair through one helper, `readSharePasswordHeader`, exported from `@objectstack/types` with the header-name constants. + - **Unchanged.** Without `X-Share-Password-Encoding`, `X-Share-Password` is read raw, exactly as before, so every value a client sends today resolves as it did. That includes a Latin-1 password and a raw password containing `%`; the server never percent-decodes a value nobody declared encoded. The `?password=` query parameter is still read first, and when it is present the header pair is not read. + - **What is refused.** `X-Share-Password-Encoding` naming any other value, or a password header that is not percent-encoded UTF-8 under `utf-8` (a `%` without two hex digits, octets that are not well-formed UTF-8, a character outside visible ASCII), answers `400 VALIDATION_FAILED` before the token is looked up. It is never compared raw instead. The message names the headers and the rule, never the presented value. + - **Response headers.** Both public routes now answer `Vary: X-Share-Password, X-Share-Password-Encoding`, still beside `Cache-Control: no-store`. + - **Cross-origin clients.** `X-Share-Password-Encoding` is in the default CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS` in `@objectstack/plugin-hono-server`, which the `@objectstack/hono` adapter also applies). A deployment that passes its own `allowHeaders` must add `X-Share-Password-Encoding` beside `X-Share-Password` to let a cross-origin client send an encoded password. +- db9cf80: feat(cli): `os migrate security-catalog-overlays` lists, and with `--apply` deletes, the environment-wide rows a v18 cold boot refuses — run it before the first v18 boot + + Clause-②: yes (widening) + + - **What it is for.** A v18 cold boot refuses to start when the environment catalog holds a permission-set or position name that a configured package also declares (ADR-0048 N.3). A deployment upgraded from 17.x can carry such rows: a set or position saved in the environment over a package's name before the packaged locks refused that, or over one of the platform security plugin's own sets (`member_default`, `admin_full_access` and the rest). The server cannot clear them, because it does not start. This step is the offline remedy. + - **What it lists.** Every active, environment-wide `sys_metadata` row (`organization_id IS NULL`, `state = 'active'`) of type `permission` or `position`, the legacy plurals `permissions` and `positions` included, whose name a configured package holds. That is the population the cold boot refuses: the list is computed from the engine's own reading of who holds a name, not from a copy of it. A draft row and an organization-scoped row are not loaded at boot, so they are never listed. Each row is shown with its stored type, its `package_id` binding and the package that holds its name. + - **How it gets there without the refusal.** It composes the deployment as `os serve` does: the host config's plugins, the application or the compiled artifact, and the security plugin behind `serve`'s auth gate. It runs the kernel's first phase for them only, and boots without reading `sys_metadata` back into the registry, so the cold-boot check meets nothing and the boot comes up. No server starts. The security plugin's shipped sets are package-held names only where `serve` composes that plugin, so the step reads the environment `serve` reads. With `OS_AUTH_SECRET` (or `AUTH_SECRET` / `BETTER_AUTH_SECRET`) set, or on a development boot, the plugin is composed and its sets are held, unless the `auth` tier is off. The step takes `os serve`'s `--preset` and `--dev` with `serve`'s meaning, read through the rules `serve` reads them by: `--preset` names the tier preset the auth tier falls back to, and `--dev` composes the config's `devPlugins` and makes the boot a development one (as does `NODE_ENV=development`). The report says which way it went. **Run the step with the flags and environment the deployment boots with.** + - **Preview by default.** The preview boots read-only, like `os migrate plan`. It writes nothing, creates no database file, and exits `1` when it lists a row: the next boot would be refused. `--apply` deletes the listed rows, after a `[y/N]` prompt or `--yes`, and prints one audit line per row: what was deleted, through which write path, or why not. It exits `0` when every listed row is gone. `--json` prints one document (`listed`, `deleted`, `failed`, `rows[]` with `outcome`, `securityPlugin` and `serveFlags`). `--force` gets past a busy SQLite file. `--database-url` / `$OS_DATABASE_URL` name the target. + - **How it deletes.** A row stored under `permission` or `position` goes through the metadata protocol's own delete, the one `DELETE /api/v1/meta/:type/:name` uses. That writes a `sys_metadata_history` tombstone and a `sys_metadata_audit` row, with actor `os migrate security-catalog-overlays`. A row stored under a legacy plural is out of that door's reach, so it goes through the `sys_metadata` repository beneath it. That delete is addressed by the row's stored type, name, `package_id` and checksum, and writes the same history tombstone. + - **The refusal names it.** The cold boot's `NAMESPACE_CONFLICT` message now points at `os migrate security-catalog-overlays` for the environment's rows, in place of "through the metadata API on a boot that leaves the package out of the configuration, or in the database". The envelope (`code`, `status`, `conflicts[]`) is unchanged. + - **What it never does.** It adopts nothing: `managed_by` and `package_id` are never rewritten, and no row is renamed. A row the environment needs under its own name must be re-created under a name no package holds. A host config that exists and cannot be loaded is refused before any row is read, because the held names would be incomplete. + - **New exports it is built on.** + - `@objectstack/objectql` exports `findPackageHeldSecurityCatalogNames(registry)`: every package-held permission-set and position name, with its holder packages. The cold-boot check now reads the same list. + - `@objectstack/core` exports the auth gate `os serve` and the step share. `resolvePlatformAuthComposition` answers whether the platform composes `AuthPlugin` and the security plugin beside it, or why not. With it come `resolveStackTiers`, `STACK_TIER_PRESETS`, `CAPABILITY_TO_TIER`, `resolveAuthSecret`, `isDevelopmentBoot`, `DEV_AUTH_SECRET_FALLBACK`, `stackSuppliesAuthPlugin`, `isHostKernelComposition` and the `PlatformAuthComposition` / `PlatformAuthSkipReason` types. `Serve.TIER_PRESETS` and `Serve.CAPABILITY_TO_TIER` are now handles over the core declarations, and `os serve` composes exactly what it composed before. + - `@objectstack/runtime`'s `createStandaloneStack` accepts `hydrateMetadataFromDb: false`, which skips the `sys_metadata` read-back. The default stays on. + - `@objectstack/spec` registers the ADR-0087 D3 entry `security-catalog-environment-overlay-refused` in its migration registry and `spec-changes.json`, so `os migrate meta --from 17` names the refusal and this step as its remedy. Its projection is the entry under protocol 18 in the upgrade guide (`docs/protocol-upgrade-guide.md`). +- b53b949: fix(runtime)!: the `/i18n` dispatcher domain refuses a caller without a session with `401 UNAUTHENTICATED`, like every other dispatcher domain + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The `/i18n` dispatcher domain serves the application's translations: the locale list, the translation bundle and the field labels. The bundle carries the labels of every object, field, app and page the application declares. Until now the domain served a caller with no session, while the metadata read of the same objects refused one. ADR-0056 D2 denies anonymous callers by default. + + **What changed.** The domain handler opens with the shared anonymous-deny decision (`shouldDenyAnonymous`), the same floor the `/meta`, `/actions`, `/automation`, `/packages` and `/analytics` domains stand on. It is the handler's first statement: + + - every face of the domain answers a caller without a session `401` with code `UNAUTHENTICATED`, in the dispatcher's wrapped envelope (`{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }`), and serves nothing of the bundle; + - it runs before the i18n provider is looked up, so the answer is `401` whether or not a provider is installed, never the `501` an empty slot answers; + - it runs before a face reads its parameters, so a request that leaves out its locale is `401`, never the `400` that face answers. + + **What is not affected.** A signed-in caller, an API-key caller and an internal system context are served exactly as before: the same bundle, the same `400` for a missing locale, the same `501` when no provider is installed. A CORS preflight is unchanged. + + **The Console.** The Console this release pins renders its sign-in page from its built-in language packs and loads the application's translations after sign-in, with the signed-in session. A signed-in Console user sees the application's labels as before. + + **If you read `/i18n` from your own client,** send the signed-in user's session or bearer token, or an API key, with the request. Render anything shown before sign-in from strings your client ships, and load the application's translations once the user has signed in. +- faf6348: feat(i18n): a refused `end` node's message translates in the run's locale (`flows..refusals..message`) + + Clause-②: yes (widening) + + - **What is new.** An `end` node declaring `outcome: 'refused'` shows its `message` to the person who started the run. Translate it under `flows..refusals..message`, keyed by the flow's `name` and the `end` node's `id`, the same addressing the `screens` keys use. Before this key, a refusal rendered in the source language on every console. + - **Keep the holes.** The translation is a `{{ }}` template like the message it translates (`'已拒绝:{{ record.name }} 是重复记录'`). A single-brace `{token}` in a translation is refused when the bundle is parsed, with the same text-slot refusal the source message gets. + - **Where it is read.** The engine picks the translated template before it renders the holes, through the `i18n` service, in the run's locale, and the run stores and returns that text (`refusalMessage`). With no translation for that locale, the authored message renders. `flowRefusalMessageKey(flowName, nodeId)` (`@objectstack/spec/system`) spells the key. + - **The run's locale.** `AutomationContext.locale` (`@objectstack/spec/contracts`) is new and optional. The trigger door (`POST /api/v1/automation/:name/trigger`, the legacy trigger route, a `type: 'flow'` endpoint) and the action door (`POST /api/v1/actions/...`, the MCP `run_action` bridge) set it from the request's resolved locale: `Accept-Language` first, then the `localization` settings. It is persisted with a paused run, so a resumed leg renders in the locale the run was started in. A run no person started (a record-change or schedule trigger) has no locale and stores the authored message. + - **`objectstack validate`** refuses a refusal key over an unknown flow, an unknown node, a completed `end` node, or a node of another type (`translation-target-unknown`, error). + - **`os i18n extract` and the coverage gate** now scaffold and demand the refusal message of every refusing `end` node in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` and has a refusing `end` with no translation now gets one missing-key issue per locale for it. + - **`AutomationEngine.setI18nServiceSource()`** (`@objectstack/service-automation`) attaches the `i18n` reader. The automation plugin wires it, so a host using `AutomationServicePlugin` needs no change. A host constructing the engine directly gets the authored message until it attaches one. +- fe58bbc: feat(runtime,core): inbound automation hooks work on a kernel with no raw HTTP app — the dispatcher admits `POST /automation/hooks/:flowName/:hookId` with no session, through to the trigger's own signature check + + Clause-②: yes + + On a self-hosted server, `@objectstack/trigger-api` serves a flow's inbound hook by mounting `POST /api/v1/automation/hooks/:flowName/:hookId` on the raw Hono app, where a third-party sender (a payment provider, a code host) posts a signed event with no session. A hosted tenant kernel has no raw app, so on that shape the path belonged to the dispatcher's `/automation` domain, whose anonymous floor refused every sender `401 UNAUTHENTICATED`. + + **What widens: one route passes the anonymous floor and the ADR-0069 gate with no session.** `@objectstack/core`'s auth-gate allow-list gains its first parameterised row: exactly `POST /automation/hooks/:flowName/:hookId` in the dispatcher's spelling (four segments, optionally under one `/environments/:id` scope), and nothing else. `/automation/hooks`, `/automation/hooks/x`, `/automation/hooks/x/y/z`, every other method on the route and every other `/automation` path stay gated exactly as before. + + - `@objectstack/core`: `isAuthGateAllowlisted(path, method?)` and `evaluateAuthGate(user, path, method?)` take an optional method. The existing rows are exempt for every method, as before, so a caller that passes no method gets the answer it always got. The new row is exempt for `POST` only, and only when a method is passed. `shouldDenyAnonymous` now passes its `method` to the allow-list beside its `path`. New export: `matchInboundHookRoute(path, method)`, the one reading of the route that the row matches through, returning the two path segments raw. + - `@objectstack/runtime`: the `/automation` domain's anonymous floor now asks the allow-list with its path and method. On the very next statement it hands the admitted route to the request kernel's `trigger-api` service (`ITriggerApiService.handleInboundHook`), ahead of every other `/automation` gate and service probe. The request goes with its body unread and the two path parameters decoded, and the service's `Response` comes back as it is. The service verifies the HMAC signature, so a correctly signed post is accepted and a wrongly signed or unsigned one is refused by that service, with its own answer. The membership gate skips the route for `POST` only, so a signed-in caller is judged exactly as an anonymous one, as on the self-hosted mount. + - No `trigger-api` service registered, or one without `handleInboundHook`: `501 NOT_IMPLEMENTED`, naming which, never `ROUTE_NOT_FOUND`. A service that throws, or a request whose body a transport already read: a sanitised `500 INTERNAL_ERROR`, logged. + + Nothing changes on a self-hosted server: the trigger's raw-app mount still serves the path there and never reaches the dispatcher, and the dispatcher mounts nothing that answers it. +- 51f36e7: The action activation door switches an action no package ships, its row names no package, and a ledger write it refuses answers with the status its code means + + Clause-②: yes (widening) + + - **The door accepts an action the environment authored** (`@objectstack/runtime`). `POST /api/v1/actions/_activation/:object/:action` declares that it does not require the action to be packaged. FROM: an object-less action saved through the metadata door (`PUT /api/v1/meta/action/:name`), or one bound to an object no package ships, was refused `503` with `{ code: 'SERVICE_UNAVAILABLE' }` and the store's sentence about an empty package id, and nothing was written. TO: it is switched like a packaged one, `200`, its row carries no package (`package_id` is `null`), and dispatch then refuses it `409 ACTION_DISABLED` like any switched-off action. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared action `404`, an ambiguous name `409`, an engine with no ledger `501`). + - **An action is credited only to the package that ships it** (`@objectstack/runtime`). FROM: an action with no package of its own was credited to its route object's package, so an action the environment saved against a packaged object wrote that object's package into its row. TO: the row names the action's own package; for an action declared inside its object's `actions`, the object's package; otherwise no package. A `sys_metadata` placeholder or a tenant-authored action's package binding is not a shipping package. Rows of shipped actions are unchanged, including the platform's own actions declared inside their objects. No reader decides anything from `package_id`, so nothing that is switched on or off changes. + - **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, an error that declares its own pair keeps it (no ledger attached: `503 SERVICE_UNAVAILABLE`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. + - **The in-memory activation store refuses what the durable store refuses** (`@objectstack/core`). FROM: `InMemoryMetadataActivationStore.setActive` (the base of `InMemoryActionActivationStore` and `InMemoryFlowActivationStore`) accepted `packageId: ''`, which `ObjectStoreMetadataActivationStore.setActive` refuses. TO: it refuses `packageId: ''` with the same `TypeError` and the same sentence, before it keeps anything. If a test passes `''`, omit the key instead. +- 20028df: fix(runtime)!: `callData` asks each nested-relation condition's related object its declared exposure before the engine reads it, on the query fallback and the aggregate arm (#22823) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A nested-relation filter condition, `{ : { : … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one. + + **FROM.** The runtime dispatcher's data bridge, `callData`, judged only the object a call addresses against its `enable` block, and then handed the caller's `where` to the ObjectQL engine at two positions: + + - the `query` / `find` fallback, which serves when no protocol service resolves (an assembly that composes the engine plugin with `registerProtocol: false` and no `MetadataProtocolPlugin`); + - the `aggregate` arm, which never takes the protocol service, so on every assembly (the MCP `aggregate_records` tool over HTTP reaches it). + + At both, such a condition was evaluated whatever the related object's `enable` block declared, including a related object every data door refuses when it is addressed. + + **TO.** `callData` asks each condition's related object the exposure gate it applies to an addressed object, for `list` (the operation a query of that object is judged as), before the engine is called. A related object the gate does not serve refuses the call with the data door's own exposure answer, the one the protocol path gives the same condition: + + - `enable.apiEnabled: false` → `404 OBJECT_API_DISABLED`. + - an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `405 OBJECT_API_METHOD_NOT_ALLOWED`, with the effective operation set in `allowed`. + + The refusal names the related object (`object`) and the condition's position (`param`, `where.`), with the remedy. It applies at the top level and under `$and` / `$or` / `$not`. A related object with no `enable` block, or a whitelist that grants `list`, is served exactly as before. With the protocol service present the `query` arm is unchanged: the data door judges the condition, as it has since #22737. + + **Migration.** Filter on the lookup's stored id (`{ : '' }` or `{ : { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`. + + **Bindings.** + + - **Refused, not withheld.** Dropping a condition would widen the result. + - **One gate, one walk.** The gate is the one `callData` already applies to an addressed object, and the conditions are found by `relationConditionSites` from `@objectstack/core`, the walk the data door and the MCP stdio bridge use. + - **A system context is not asked**, as it is not asked for the addressed object: these flags govern API exposure, not the engine's own reads. A flow's or a hook's own query calls the engine directly and never passes `callData`. + - **No new error code.** `@objectstack/runtime` now stamps the two existing exposure codes, and the error-code ledger lists them under that package too. + + **Measured producers.** Read on `origin/main` `75b6d6a048`: no shipped composition reaches the `query` fallback. Every non-test `ObjectQLPlugin` construction under `packages/` and `examples/` keeps the built-in protocol (`os serve`, the standalone stack, the dev plugin, the verify harness, the kernel factory), and `registerProtocol: false` appears only in tests. The `aggregate` arm is reached on every assembly that serves the MCP route over HTTP. Deployed compositions were NOT MEASURED. +- 8024e12: fix(runtime)!: the runtime dispatcher's by-name `/meta` doors ask the query decision `RestServer` asks, so `?member=` is honoured on the item read and refused where it is not read, never dropped + + `@objectstack/rest` exports one portable decision for the query a by-name `/meta` door accepts, `metaItemQueryRefusal(door, query)`, and the bundle member selector's one reading, `metaMemberSelector(query)`. Both transports call them, so a door's closed set, its sentences and their order are one answer. On a host whose `/meta` is answered by the runtime dispatcher alone (the `@objectstack/hono` `createHonoApp` catch-all), the item read, `PUT`, `/layers` and `/published` declared no closed query set: `GET /meta/email_template/x?member=zh-CN` was answered with the canonical member, a misspelling such as `?memeber=` the same, and `PUT ?member=zh-CN` beside an `en-US` body wrote `en-US`. + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What each runtime dispatcher door accepts now, and what to do when it refuses. The answers are `RestServer`'s, in the dispatcher's own envelope, `{ success: false, error: { code: 'VALIDATION_ERROR', message } }`, and the message names the refused parameter: + + - `GET /meta/:type/:name` accepts `layers`, `state`, `preview`, `package`, `locale` and `member`, the six names `RestServer`'s item read accepts. `?member=` now reads that member of a bundled type (an `email_template`'s `zh-CN`); a member the bundle does not hold is `404`, and a selector on a type with no members is the protocol's located `400`. A single-valued name supplied twice is refused, and `?member=` beside `?layers=` is refused (the layered view shows the canonical member's layers). Read one member with `?member=` alone. + - `PUT /meta/:type/:name` accepts `package` and `mode`. It takes no `force`: this door never honoured one, and its `409` remedy already says so. Drop `?force=`, and send a body that keeps what the stored item carries. A save is addressed by its body: to save one member of a bundled type, put that member's own discriminator in the body and drop `?member=`. + - `GET /meta/:type/:name/layers` accepts `package`. Read one member with `GET /meta/:type/:name?member=…`. + - `GET /meta/:type/:name/published` accepts no query parameter. Send none. + + A request that sends only names the door reads is answered as before, except that `?member=` on the item read is now honoured. `RestServer`'s item read asks the same decision, and its answers are byte for byte what they were. Every name the `@objectstack/client` SDK and the Studio metadata client send to these doors is accepted, except `force` on a save: the SDK sends it only when its caller passes `force: true`, and Studio only to retry a save after a `409 DESTRUCTIVE_CHANGE`. On a dispatcher-only host that retry already failed with the same `409`, and it now answers the located `400` that names `force`. +- d9d7edd: fix(runtime,rest,cloud-connection)!: the declared-endpoint fallback, REST's direct-mounted publish and federation routes, and cloud-connection's session doors refuse a session the ADR-0069 authentication policy holds + + Clause-②: no (narrowing) — a session ADR-0069 holds out of protected resources is refused by doors that answered it; core's existing exports only, no published schema or export moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate. REST's `enforceAuth` and the runtime dispatcher already refuse such a session. Three groups of doors that resolve a session on their own never asked, so they served it as an ordinary signed-in caller: + + - **`@objectstack/runtime`**: the declared-endpoint fallback (app-declared `apis:` endpoints, served by the dispatcher plugin's fallback seam). Its policy chain now asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) before `authRequired` and before the endpoint's target runs, on every declared endpoint, an `authRequired: false` one included, because the target runs as the caller. While a gate feature is active the chain reads the session for this, and when an active gate cannot read the session the request is answered `503` (`SERVICE_UNAVAILABLE`), as REST answers it. No declared path is handed to the allow-list, so no endpoint can exempt itself through the prefix it sits under. + - **`@objectstack/rest`**: the direct-mounted package publish door (`registerPackageRoutes`) and the external-datasource federation family (`registerExternalDatasourceRoutes`). Each refuses on the `authGate` the REST resolver already puts on the execution context it hands them. + - **`@objectstack/cloud-connection`**: `CloudConnectionPlugin`'s seven session doors (bind, poll, unbind, install, and the three installed-state reads) and all five `MarketplaceInstallLocalPlugin` doors (install, list, uninstall, reseed, purge). Each plugin asks the gate once, where it resolves the session, and every door that resolves one refuses on it. + + - **Refused now:** a held session gets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message`, in each door's declared error envelope, before the door does anything else. This is the answer REST gives the same session. + - **Unchanged:** a session the policy does not hold is served exactly as before, an API-key caller is unaffected because it has no session to hold, and an anonymous caller gets the same `401`. `CloudConnectionPlugin`'s status read and `MarketplaceProxyPlugin`'s catalog proxy resolve no session and answer every caller alike, so they are unchanged. + + What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before these doors answer them, as on every other protected route. Nothing needs configuring: the posture comes from the kernel's `auth` service, as it does for REST and the dispatcher. +- d45d0e9: fix(runtime)!: the runtime dispatcher's read points refuse a repeated single-valued query name as their `RestServer` twins do, instead of reading one value, a joined value or an array + + The runtime dispatcher's `/meta`, `/ui`, `/i18n`, `/security`, `/packages` and `/data` read points each read a query name as ONE value. None of them guarded against the name arriving twice, because the only door in front of most of them, the `@objectstack/hono` `createHonoApp` catch-all, has always flattened a repeat to its last value. Handed an array, they answered wrongly: `GET /meta/object/:name/state/:field?from=a&from=b` answered a dead end for the joined state `'a,b'`; `_drafts`, `/ui/view` and the `/data` filter slot passed the array into the protocol call; both `/i18n` query-locale arms threw, which surfaced as a `500`; and the `/meta` list, the book tree, `/security/suggested-bindings` and `/packages` answered an empty or unscoped `200`. Each now refuses the repeat before any protocol or service call. + + `@objectstack/rest` publishes the lists and rules both transports read, so the two cannot drift: `META_QUERY_ARITY_DOORS` (the single-valued names of the `/meta` list, book tree, `_drafts` and state machine doors, which `RestServer`'s twins of those doors now read from), the decision `metaQueryArityRefusal(door, query)`, and `assertFilterParamSuppliedOnce`, the `/data` list's filter-slot rule, on the package entry. + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + What changes for you: a request that repeats one of the names below to the runtime dispatcher is now refused `400` in the dispatcher's own envelope, `{ success: false, error: { code, message } }`, instead of being answered with one value, a joined value or an array. The `code` is `VALIDATION_ERROR` with the sentence `RestServer` answers ("The "NAME" query parameter was supplied N times…"), except on the `/data` filter slot, which answers `INVALID_FILTER` with `RestServer`'s repeated-filter sentence. Send each name once. + + - `GET /meta/:type`: `package`, `preview`, `object`, `include`, `id`. + - `GET /meta/book/:name/tree`: `package`. + - `GET /meta/_drafts`: `packageId`, `type`. + - `GET /meta/object/:name/state/:field`: `from`. + - `GET /ui/view/:object`: `type`, when the path names no type. + - `GET /i18n/translations` and `GET /i18n/labels/:object`: `locale`, when the path names no locale. + - `GET /security/suggested-bindings`: `status`, `packageId`. A repeated `status` was already refused `400`, as an unknown status; it now gets the multiplicity sentence. + - `GET /packages`: `status`, `type`, beside `enabled`, which was already refused. + - `GET /data/:object`: every spelling of the filter slot (`filter`, `where` and their aliases, `filters`, `$filter`). Genuinely multi-valued names such as `select` and `expand` are untouched. + + A name sent once is answered as before, and one sent once as a one-element array now reads as that one value everywhere. Where a caller meets this today: `GET /packages` through `@objectstack/plugin-hono-server`, whose adapter already keeps repeats as arrays. Every other point is reached on a shipped host only through `createHonoApp`, which still flattens a repeat to its last value, so until that adapter keeps repeats the refusals are visible only to a caller of `HttpDispatcher.dispatch()` that passes an array. The `@objectstack/client` SDK sends each of these names once. +- 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. +- 01d5034: fix(runtime)!: the notification inbox, API-key mint and share-link management dispatcher domains decide an anonymous request once, as each domain's first statement, so an anonymous caller is answered 401 whatever the deployment carries + + ADR-0138 D2 decides that everything off its closed list of six door classes answers 401, and that "the decision is taken once per domain, as the domain's first statement, never surface by surface". Three dispatcher domains decided it per face, below another answer: `/notifications` below its inbox capability probe, `/keys` below its method check, and `/share-links` inside its management faces, below the share-link service probe. Each now opens with the shared `shouldDenyAnonymous` decision, before any service is resolved and before any face is chosen, so a face added to one of them later is refused to an anonymous caller from the day it lands. + + Clause-②: no (narrowing) + + + + **BREAKING** (a refusal moved ahead of the answers it used to follow), shipped as `minor` under the launch-window convention for breaking changes. + + What an unauthenticated caller is answered now, on each domain: + + - `/notifications`: `401 UNAUTHENTICATED` on every face. On a deployment with no inbox (a send-only notification provider, or none) the anonymous answer was the `501` capability answer; it is now the 401, and a signed-in caller there still gets the 501. + - `/keys`: `401 UNAUTHENTICATED` for every verb. A verb other than `POST` used to be answered `405` to an anonymous caller; a signed-in caller still gets the 405. + - `/share-links`: `401 UNAUTHENTICATED` on the create, list and revoke faces and on every path the domain does not serve. On a deployment without the share-link service the anonymous answer was `501`; a signed-in caller there still gets the 501. The two token faces, `GET /share-links/:token/resolve` and `GET /share-links/:token/messages`, are unchanged: the capability token admits them (ADR-0138 D2 class 2). + + The refusal is the dispatcher's standard envelope, `{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }`, with the shared message every other dispatcher domain answers with, so a client reads one anonymous refusal on all of them. Nothing a signed-in caller is served changes, and on the standalone server the booted showcase already answered 401 on the faces it mounts. +- f24574a: fix(runtime)!: the `/ui` and `/data` dispatcher domains decide an anonymous request once, as each domain's first statement, so an unauthenticated caller is answered 401 on every face they serve + + ADR-0138 D2 decides that everything off its closed list of six door classes answers 401, and that "the decision is taken once per domain, as the domain's first statement, never surface by surface". The rendered-view domain `/ui` and the CRUD and query domain `/data` made no anonymous decision of their own: an unauthenticated caller was answered by whichever face it named. Each now opens with the shared `shouldDenyAnonymous` decision, before the path is parsed and before any service is resolved, so a face added to either later is refused to an anonymous caller from the day it lands. No door class D2 lists runs through either domain. + + Clause-②: no (narrowing) + + + + **BREAKING** (a refusal moved ahead of the answers it used to follow), shipped as `minor` under the launch-window convention for breaking changes. + + What an unauthenticated caller is answered now, wherever these dispatcher domains serve the request. Where `@objectstack/rest` is mounted it serves the same paths itself and already answered 401, so the standalone server's answers do not move. + + - `/data`: `401 UNAUTHENTICATED` on every read and write face, and on every path or verb the domain does not serve. The answer used to come from the face: the CRUD gate's `403 PERMISSION_DENIED` for the guest, a `400` for a request naming no object, a `428` for an unresolved environment on a multi-tenant host. A CORS preflight keeps its pass. + - `/ui`: `401 UNAUTHENTICATED` on every face, and on every path the domain does not serve. + + The refusal is the dispatcher's standard envelope, `{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }`, with the shared message every other dispatcher domain answers with. Nothing a signed-in caller is served changes. The decision is asked about the caller's user id, as the `/notifications` and `/keys` floors ask it: `dispatch()` resolves identity for every request, so no caller on the wire is a system context, and an in-process call of either handler that passes a system context with no user id is refused too. +- 430e5f5: fix(security)!: under a walled posture every environment-metadata write is the platform operator's, whatever its type (ADR-0131 D12 items 7–8, D11) + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. ADR-0131 D12 item 7: a walled deployment is "one environment, one schema, one catalog … The metadata-authoring capability (`manage_metadata` / `studio.access`) is what headquarters IT holds and plant admins do not." Item 8: a plant "cannot create a position, a permission set, a capability, a template or any metadata … A plant that needs its own catalog is given its own environment." D11: the rules are identical in every posture; only whether the wall is enforced differs. + + **Why.** The environment definition ledger carries no organization column, so a write lands for every organization. Measured on booted `isolated` and `group` stacks before this change: an organization owner granted `manage_metadata` inside their own organization created an object, a view and a flow at `/api/v1/meta`, edited, published, rolled back and reset the operator's view, ran `POST /api/v1/meta/_migrate-stored`, passed all thirteen `/api/v1/packages` write doors, installed, upgraded and uninstalled packages through `install-local`, and created a flow at `/api/v1/automation`, each with `2xx`; another organization's owner read every result. The security catalog was refused at these doors already; every other type was not. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` of `/api/v1/meta/:type/:name` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for a type outside the security catalog (`object`, `view`, `flow`, `app`, `dashboard`, `translation` and every other type): **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher answers `403 PERMISSION_DENIED`). + - Every `/api/v1/packages` write on the runtime dispatcher — create, the manifest `PATCH`, enable, disable, publish, publish-drafts, discard-drafts, revert, rollback, `commits/:commitId/revert`, duplicate, adopt-orphans and delete — for a package carrying no position, permission set or capability: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is read or written. The refusal no longer reads the package's items. + - `POST /api/v1/marketplace/install-local` (install and upgrade) and `DELETE /api/v1/marketplace/install-local/:id` (uninstall) on `@objectstack/cloud-connection`, for a package carrying no catalog item: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`. The refusal's `error.details` no longer carries `catalogItems`; it carries `manifestId`. + - `POST /api/v1/automation`, `PUT /api/v1/automation/:name`, `DELETE /api/v1/automation/:name` and `POST /api/v1/automation/:name/clone`: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the automation service is touched. + - `POST /api/v1/meta/_migrate-stored`, the install-wide rewrite of the stored metadata, preview and apply alike: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` on `@objectstack/rest` and `403 PERMISSION_DENIED` on the runtime dispatcher, before a row is read. + - Every one of these refusals carries one sentence: it names the written type (`a package` at the package doors, `the environment's stored metadata` at the rewrite), the posture and "ADR-0131 D3, D12 items 7–8, D11", and points at an environment of the organization's own. The catalog refusals that shipped before keep their envelopes and now read the same sentence. + - The activation refusals under a wall (`POST /api/v1/automation/:name/toggle`, `POST /api/v1/actions/_activation/…`, `POST /api/v1/security/_activation/permission/…`) no longer recommend cloning a flow or a permission set, or authoring a sibling action, since those writes are the operator's under a wall too; they name the platform operator. + + **Unchanged.** Reads. Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows. The platform operator and the engine's own writes (seeders, installers, migrations) write as before. Under `single` (and with no `tenancy` service), a `manage_metadata` holder writes exactly as before. + + **The remedy.** The platform operator authors the environment's metadata, in Studio or through a managed package; an organization that needs metadata of its own is given an environment of its own. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-environment-metadata-writes-retired`. `walledCatalogWriteVerdict` (`@objectstack/metadata-core`) now judges every type alike: a custom door that writes environment metadata asks it after its capability gate, with no `isSecurityCatalogType` pre-filter. +- 5d3018d: fix(runtime)!: the runtime dispatcher's ADR-0069 gate answers `503` when an active gate cannot read the session, as REST does + + Clause-②: no (narrowing) — an active gate's outage now refuses what it served; core's existing exports only, no published schema or export moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled. While a gate feature is active, the runtime dispatcher (`HttpDispatcher`) reads the session again at its own gate to ask that question. When that read failed, because the auth service could not answer, the dispatcher took the failure for "no session" and served the request, so a session the policy holds passed the gate on every dispatcher-served route for as long as the outage lasted. REST, and the dispatcher plugin's declared-endpoint fallback, answer the same window `503`. + + - **Refused now:** with a gate feature active, a request whose gate read fails (the session read rejects, or the auth service cannot hand back its session API) is answered `503` with the code `SERVICE_UNAVAILABLE`, in the dispatcher's existing error envelope. It is the authorization-store outage (`AuthzStoreUnavailableError`, `@objectstack/core`, read object `auth_gate`) that REST raises for the same window, and the route's own handler does not run. + - **Unchanged:** with no gate feature active the gate makes no read at all, and a deployment whose gate probe itself fails is treated as having no active gate, as on REST. Whenever the read answers, the answers are as before: a held session gets the gate's `403` with its own `code`, and a session the policy does not hold, an API-key caller and an anonymous caller are served as before. + + What changes for you. Only deployments with password expiry or enforced MFA turned on are affected, and only while their auth service cannot answer a session read: requests on routes the runtime dispatcher serves now answer a retriable `503` instead of being served. Retry once the auth service answers. Nothing needs configuring. +- 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. +- 34b740f: fix(runtime,cli): an upper-case `FILE:` database URL opens the file its lower-case spelling opens, `file://NAME` opens `NAME` on both hosts, and the URL-to-filename rule is exported as `sqliteFilenameFromUrl` + + Clause-②: yes (widening) + + - **`FILE:` is stripped case-insensitively.** The standalone host classified `FILE:./app.db` as SQLite case-insensitively but stripped the prefix case-sensitively, so the prefix stayed in the file name: `OS_DATABASE_URL=FILE:./app.db` created a directory literally named `FILE:.` and put the database inside it, and `File:///tmp/app.db` was refused as an unsupported `File://` scheme. Both now open the file the lower-case spelling opens (`./app.db`, `/tmp/app.db`), as the `sqlite-wasm` path already did. Lower-case `file:` URLs and bare paths are unchanged. + - **New export: `sqliteFilenameFromUrl(url, kind)`** from `@objectstack/runtime`, `kind` being `'sqlite'` or `'sqlite-wasm'`. It is the one rule that turns a SQLite database URL (after `normalizeDatabaseUrl`) into a file name: `file:` / `file://` stripped case-insensitively, plus `wasm-sqlite:` for the WASM kind. For `'sqlite'` it throws a plain `Error` on a URL that is not a file path, naming the URL's scheme and never the URL. Nothing is removed. + - **`os serve`'s legacy storage path (`OS_MODE=off`, `bootMode: 'off'`, or a host config) turns a SQLite URL into a file by that same rule**, so both hosts open one file per URL. It used to strip only a lower-case `file:`: `OS_DATABASE_URL=file://NAME` opened `//NAME` at the filesystem root, where the standalone host opens `./NAME`, and `FILE:./app.db` made a `FILE:.` directory there too. `file:///abs/app.db` opens `/abs/app.db`; `sqlite:` / `sqlite://` URLs are unchanged. + - **What that rule refuses, the legacy path now refuses too, and fatally**, with the standalone host's own sentence (it names the URL's scheme, never the URL): an explicit `OS_DATABASE_DRIVER=sqlite` over a server URL such as `postgres://user:password@host/db`, which used to create directories named after the URL, credentials included, and a `file:` URL that names no file, which used to declare a database with an empty file name. Fix: point `OS_DATABASE_URL` at a file (`file:./app.db`), or select the driver the URL is for. The undocumented `sql://` prefix that path also stripped under an explicit `sqlite` selection is refused the same way; write `file:` instead. A blank `OS_DATABASE_URL` under an explicit `sqlite` selection is read as absent (`:memory:`), as an absent one always was. +- ae4dfaf: feat(driver-turso,cli): the v18 organization-ownership ceremony runs on a Turso remote database + + Clause-②: yes (widening) + + `os migrate organization-ownership`, the plan and `--apply --backup-taken`, now completes against a Turso database reached over the remote transport (`libsql://…`), the transport hosted environments use. Before, the plan stopped at its boot with `Deferred schema DDL is not supported by the Turso REMOTE transport`, and the apply's column drops had no remote path either. + + - **`@objectstack/driver-turso`**, remote face: + - `detectManagedDrift()` answers instead of refusing with `NOT_IMPLEMENTED` / 501. The verdict is the shared differ's, the one the local face runs; the physical schema it diffs is read from the remote catalog (`pragma_table_info`, `sqlite_master`, `pragma_index_list` / `pragma_index_info`, and the ADR-0120 D4 duplicate probe). The declarations it diffs against are recorded by every remote schema door and by the DDL-free `registerObjectMetadata`, so a boot that skipped schema sync is covered too. + - `applyMigrationEntries()` applies `drop_index`, `create_index`, `recreate_index`, `replace_unique_index` and `drop_column` instead of refusing. Each entry runs as one libSQL `write` batch, so it lands whole or not at all: a `drop_column` drops the column's indexes with it in the same transaction, and a re-created index that fails to build leaves the old one in place. An entry is reported `applied` only once a read-back shows its target state. Every other column entry (nullability, width, a dropped DEFAULT) needs the SQLite table rebuild, which this transport does not run, so it is reported `skipped` and logged, as the in-place dialects report an op they cannot perform. + - The remote face declares `driftReportCaveat`: a sentence saying its drift report is for reading only and naming the reason. The OS_ARTIFACT_URL boot gate reads it (below). The local and embedded-replica faces declare `null`. + - The remote driver's `config` now carries its `url`, so the CLI labels a remote database by its URL (credential-free) instead of `better-sqlite3`. The ceremony writes that label into its plan and refuses an apply whose plan names another database; that refusal can now tell two remote databases apart. + - **`@objectstack/cli`**: the ceremony's plan and apply boots skip schema sync, instead of arming deferred DDL, when the primary datasource is a Turso remote one. No DDL is attempted, so the plan stays read-only, and every other datasource the boot connects is still deferred. The environment's own boot has already run the additive sync the ceremony reads. Every other `os migrate` command keeps refusing on that transport: their dry runs list held-back schema work, which a boot that holds nothing back cannot list. + - **`@objectstack/runtime`**: `createStandaloneStack` takes `skipSchemaSync`, passed to `ObjectQLPlugin` as the option `OS_SKIP_SCHEMA_SYNC=1` names. Unset, the plugin reads the variable as before. + + On a Turso remote primary, the `OS_ARTIFACT_URL` boot migration gate on `os serve` keeps its earlier answer: cannot judge, warn and boot. It applies no drift and refuses no boot. Only its warning's wording changes, from `Could not check the physical schema` to `Did not check the physical schema against the artifact: …`, followed by the driver's reason. The gate now decides this from a capability the driver declares (`driftReportCaveat`, a string on the remote face and `null` on the local and embedded-replica faces), not from a refusal it catches. Two follow-ups, filed separately and not closed by this change, have to land before that gate may act on a remote report: + - #23847: the remote transport's schema sync honours `storage.notNull` on create and add column, so the report stops naming a tightening its own sync produced; + - #23848: `os migrate apply` works on a Turso remote database, so the destructive drift the gate would refuse on has a remedy there. +- 62f959d: fix(runtime)!: `POST /api/v1/packages` with `overwrite` refuses a read-only package with `422 WRITABLE_PACKAGE_REQUIRED`, as disable, publish, revert and delete already do + + ADR-0070 D2 makes a package the deployment ships read-only: one booted by code, one installed from the marketplace, and one whose manifest `scope` is `system` or `cloud`. `isWritablePackage` is that rule, and the four lifecycle doors that change a package (`PATCH /packages/:id/disable`, `POST /packages/:id/publish`, `POST /packages/:id/revert`, `DELETE /packages/:id`) refuse such a package with `422 WRITABLE_PACKAGE_REQUIRED`. The install door's `overwrite` option only bypassed the `409 … already exists` answer and never asked the rule. So an overwrite over a read-only package answered `201` and replaced its registry row with an authored one, and with a `protocol` service composed it also wrote that package's `sys_packages` row, while the server went on treating the package as read-only. The install door now asks the same rule, for the manifest's id, before either install path writes anything. + + Clause-②: no (narrowing) + + + + **BREAKING** (a REST door's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes. + + - **FROM:** `POST /api/v1/packages` with `overwrite: true` in the wrapped body, or `?overwrite=true`, and a manifest whose `id` names a read-only package, answered `201` and replaced that package's registry row. + - **TO:** the same request answers `422` with `error.code: 'WRITABLE_PACKAGE_REQUIRED'` and `error.details.packageId`, and writes nothing: no registry row, no `sys_packages` row. It is the refusal the four lifecycle doors give. + - **Unchanged:** an install of a new id, with or without `overwrite`, and an overwrite of a package an organization authored answer `201` as before. An install over an existing id without `overwrite` still answers `409`. + - **The fix for an integrator:** a package the deployment ships is managed by the deployment (its code release, or a new marketplace version), not over this API. Install the manifest under an id you own, or customise what the shipped package provides beside it as environment-level state (ADR-0131 D6). +- a06f0d4: fix(runtime)!: the dispatcher's `GET /meta/object/:name` answers an object of a disabled package with the protocol's `404 OBJECT_PACKAGE_DISABLED`, never the registry's copy + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The dispatcher's object read had two registry reads. On an unscoped kernel it read `registry.getObject(name)` before the protocol. On a scoped kernel it fell back to `registry.getObject(name)` when the protocol read threw. The registry keeps a disabled package's objects on purpose, so both reads served such an object. + + - **Refused now:** `GET /meta/object/:name` on the dispatcher transport, for an object whose owning package is disabled. It answers the protocol's `404 OBJECT_PACKAGE_DISABLED`, on scoped and unscoped kernels alike. + - **Unchanged:** every other object read, including the registry fallback for an object the protocol misses for any other reason. + + What changes for you: re-enable the package to read the object again. +- 58a2faa: fix(runtime)!: the dispatcher's `/meta/:type/:name/layers` and `?layers=true` answer a disabled package's non-object item as a missing name + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + - **Absent now, `404 RESOURCE_NOT_FOUND` "Not found", byte for byte the missing name's answer:** `GET /meta/:type/:name/layers` and `GET /meta/:type/:name?layers=true` (with its `Deprecation` header) for an item of every type but `object` and `package` whose owning package is disabled, to every caller. The owner is the one the by-name read's protection envelope names (`packageId`) under the request's `?package=` scope, as `RestServer` asks it. Objects already answered `404 OBJECT_PACKAGE_DISABLED` here. + - **Unchanged:** items of enabled packages, and a name with nothing behind it. + + What changes for you: re-enable the package to read its items' layered views again. +- 2bcd406: fix(runtime)!: the dispatcher's `/meta` doors that read past the protocol answer a disabled package's object or item as the protocol's by-name read does + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The registry keeps a disabled package's objects on purpose, and the metadata service knows nothing of a disable. Four dispatcher doors read one of them for a disabled package's object or item and served it. + + - **Refused now, `404 OBJECT_PACKAGE_DISABLED`:** `GET /meta/object/:name/state/:field`, and the legacy one-segment `GET /meta/:name` exit, for an object whose owning package is disabled. Both read `registry.getObject`; they now answer the protocol's refusal, the answer `GET /meta/object/:name` gives. + - **Refused now, `404 OBJECT_PACKAGE_DISABLED`:** `GET /meta/object/:name/published`. When the layered read refused the object, this door fell through to the published snapshot and served it. The refusal is now the answer, as on `RestServer`'s `/published`. + - **Absent now, `404 RESOURCE_NOT_FOUND`:** `GET /meta/:type/:name` for a non-object item of a disabled package. The protocol answers it as absent, and the door no longer serves the metadata service's copy in its place. The body is byte for byte the answer for a missing name. + - **Unchanged:** objects and items of enabled packages, and every other fall-through. + + What changes for you: re-enable the package to read the object or item again. +- e74ec56: fix(runtime)!: the dispatcher's `/meta/:type/:name/published` answers a disabled package's non-object item as a missing name + + Clause-②: no (narrowing) + + + + **BREAKING** (a served-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + - **Absent now, `404 RESOURCE_NOT_FOUND` "Not found", byte for byte the missing name's answer:** `GET /meta/:type/:name/published` for an item of every type but `object` and `package` whose owning package is disabled, on the stored published row and the code/package snapshot alike. The owner is the one the by-name read's protection envelope names (`packageId`), as `RestServer`'s `/published` asks it. Objects already answered `404 OBJECT_PACKAGE_DISABLED` here. + - **Unchanged:** items of enabled packages, and a name with nothing behind it. + + What changes for you: re-enable the package to read its items' published versions again. +- 1e001c7: feat(runtime): `POST /packages/:id/publish-drafts` accepts a `backfill` body key and passes it to the protocol with the caller's execution context + + Clause-②: yes (widening) + + - The body's `backfill` (`[{ object, field }]`) reaches `publishPackageDrafts` as sent, together with the request's own execution context, so the backfill writes run as the signed-in caller, never as the system. A body without the key sends neither. + - The protocol's request refusal answers `400 VALIDATION_ERROR` with its `issues[]`, before anything is published, flipped or announced. The `backfillApplied` receipt rides the `200` body. +- d79dc48: fix(runtime)!: an `operation: 'update'` action that declares no `params` refuses every undeclared wire param at both action doors, instead of writing it to the row + + Clause-②: no (narrowing) + + + + **BREAKING** (the REST `/actions` door and the MCP `run_action` door now refuse, with a 400 `VALIDATION_ERROR`, a wire param that a declarative update action without `params` does not declare; measured consumers: objectstack-ai/hotcrm's `complete_task` called with such params; any console vendored before objectui `fb501bc894`, which posts the action's `patch` keys as `params`; and page action rows (`action:button` / `action:icon` with `operation: 'update'`) whose static object `params` the console forwards to a param-less declarative update action, none of which exist in this repo, with the row surface tracked in #24394), shipped as `minor` under the launch-window convention for breaking changes. + + A declarative update (`operation: 'update'` + `patch`) has no handler: the route merges the request's `params` over `patch` and writes the result to the current record. An action that declared no `params` skipped the ADR-0104 D2 param check altogether, so any key the caller sent was written too. An action declaring `patch: { status: 'completed' }` could rewrite any other field the caller may write, which is not what its author declared. + + Now an empty or absent `params` list on a declarative update is an empty accept set. Every key that is neither a declared param nor a dispatcher built-in (`recordId`, `objectName`, `_selectedIds`) is refused before anything is read or written, through the same D2 path a declared-params action already takes: the same `Invalid action params: Unknown action param "KEY"` message, the same 400, and the same `OS_ALLOW_LAX_ACTION_PARAMS=1` escape hatch. The action still writes its `patch` when the request carries no `params` or an empty bag, and a declared param still writes and still wins over `patch`. + + Unchanged: an action with a handler (`target`, `body`, or a flow) that declares no `params` keeps its pass-through. + + Also at the MCP `run_action` door: every ADR-0104 D2 param refusal, and the declarative update's own refusals (no `recordId`, or nothing to write), now carry `code: 'VALIDATION_ERROR'` and `status: 400`, so the tool error renders the coded envelope the REST door already answers with, rather than the message alone. + + What to do: declare the param on the action if the request's value should be written, or stop sending it, because the route applies `patch` from the declaration and never needs it on the wire. +- 2ec6827: feat(rest,runtime): `PUT /meta/:type/:name?surface=studio|api` declares the save's authoring surface on both HTTP save doors; `?surface=copilot` is refused + + Clause-②: yes (widening) + + - **The spelling.** `?surface=` joins the save door's closed query set on `RestServer` and on the runtime dispatcher (`META_ITEM_SAVE_PARAMS`), read once through `metaSaveRequestOptions` beside `?mode=`. A draft save stamps it on each key path it changes (`draftChanges[].by` on the strict draft read); without it the save is recorded as `api`. `?surface=studio` is the spelling for a person's save in Studio. + - **The refusals.** `?surface=copilot` is `400 VALIDATION_ERROR`: it marks a write as the AI copilot's, which only an in-process tool calling `saveMetaItem` may state. Any other value, a case variant included, is the same `400`, never read as `api`; a repeated `?surface=` is refused by the arity rule. Each refusal writes nothing. + - **Why a query parameter.** It is what the save is asked to do, as `?mode=` and `?force=` are, beside the HTTP preconditions the headers carry; and it needs no CORS allow-list entry, which a custom request header would. +- 964a237: fix(plugin-sharing,runtime)!: a declared sharing rule is read-only and clonable — no rule, new or renamed, can take its name, and it is switched off through the activation ledger, which revokes its grants at once + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0131 D6 charts declared sharing rules under regime C: a rule a package declares is read-only, it is customised by a clone under another name, and it is switched off through the deployment-level activation ledger (`sys_metadata_activation`), like flows, actions, positions and permission sets. Same-name shadowing is retired. + + What is refused now: + + - `POST /api/v1/sharing/rules` (and `SharingRuleService.defineRule`) creating a rule under a declared rule's name — FROM `201`, the new row replacing the declared rule in its scope, TO `409 RESOURCE_CONFLICT` naming the declared rule. Fix: create your rule under another name, and switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/` and `{ "enabled": false }`. + - A `/data` insert of a `sys_sharing_rule` row under a declared rule's name, or an update that renames a row onto one — FROM `201` / `200`, TO `409 RESOURCE_CONFLICT`. Fix: the same. + + What is added: + + - **The switch.** `POST /api/v1/security/_activation/sharing_rule/:name` writes the declared rule's activation row, behind the same authority as the other activation doors: `manage_metadata`, and the platform operator under a wall, because the row covers every organization. A declared rule switched off is served nowhere. The write revokes every grant that carries the rule's name in the same request, and switching it back on re-derives them. The name must be a declared rule (`404` otherwise; a rule your organization authored keeps its own `active` field), and with no sharing-rule service bound the door answers `503`. + - **A declaration over a stored row is reported, never made an overlay.** When a package declares a rule whose name a `sys_sharing_rule` row already holds in a scope (a package upgrade, or a copy customised before this release), the row keeps answering as its own rule. The declaration is not served beside it, and that is logged at `error` once per name and scope per process, with the remedy. + + Unchanged: rules whose name no declaration holds are created, renamed and switched as before. A stored row that already holds a declared name stays editable as itself. `DELETE /api/v1/sharing/rules/:idOrName` on a declared rule is still `403 PERMISSION_DENIED`, and is now answered the same way when the rule is switched off. +- cdf3777: The v18 organization-ownership ceremony now completes on a long-lived 17.x database, and `os dev` runs it for you on a local development database (ADR-0131, amendment 2026-10-16). + + **`os dev` upgrades a v17-shape development database in place.** When the boot refuses a local SQLite development database that is still in the v17 organization-ownership shape, `os dev` first copies the database and its `telemetry` sibling (with `-wal` / `-shm`) into `/backups/organization-ownership-/`. It then runs `os migrate organization-ownership --dev` (the plan), applies that plan, and starts the server again. The plan, the apply report and the ceremony's log are kept beside the copy. If a step blocks, `os dev` stops and prints the blocker, where the copy is, and how to resume. `os serve` / `os start`, and any database that is not a local SQLite file, keep the manual ceremony and the boot refusal unchanged. + + **What the ceremony decides for you is printed as a warning before it writes anything.** The plan carries a `warnings` list. The plan prints it, `--apply` prints it again before its first write, and the apply report keeps it. + + **The `telemetry` sibling is covered.** A development boot (or an `OS_TELEMETRY_DB` deployment) keeps `sys_audit_log`, `sys_job_run`, `sys_notification` and the other lifecycle objects in a sibling database. The ceremony now plans, attributes and drops columns for each object in the database it lives in, reading anchors across databases. Before, the boot refusal read those tables in the sibling while the ceremony only read the primary, so an upgraded database could still never boot. A copy of such a table that an older boot left in the primary is listed under `staleCopies`, warned about, and left untouched; it no longer blocks the column drops. + + **The shipped permission sets an install seeded before 2026-08-15 are adopted.** Those installs store the eight shipped sets (`organization_admin`, `member_default`, …) as `managed_by: 'admin'` rows. The authored-conversion step used to refuse them with "rename the authored row". The ceremony now re-owns them as the platform seeds they are and deletes them as mirrors once every grant names its set, so the shipped definitions apply. The warning names each row; a Setup edit to one of them is not carried over, and the pre-upgrade backup keeps it. + + **`os migrate organization-ownership --dev`** composes the stack as `os serve --dev` does: the development auth secret's fallback (so the security and audit plugins), the development `.env*` files, and the `telemetry` sibling. A development boot's refusal now names the command with `--dev`, and every refusal now names an apply the ceremony accepts: `--out PLAN` first, then `--apply --plan-file PLAN --backup-taken`. A bare `--apply` is refused without a reviewed plan file. Before, running the ceremony from a shell without `NODE_ENV=development` composed neither plugin, and its apply blocked on the shipped permission sets and on `sys_audit_log`. + + `OrganizationOwnershipBootGuardPlugin` (`@objectstack/runtime`) takes `{ dev }`; `organizationOwnershipRefusalMessage` and `assertOrganizationOwnershipMigrated` take the same option. + +### Patch Changes + +- 5b14b7f: fix(cli, runtime): the 18.0 operator messages say that 18.0 supports fresh `single` installs only + + Clause-②: no + + - **The v18 boot refusal on a v17-shaped database** now says first that 18.0 does not upgrade a v17 database: keep it on a 17.x release until the 18.x release that carries the upgrade path. Before, it named only `os migrate organization-ownership`, which refuses in 18.0 as well, so the operator reached the answer in two hops. + - **The fix list for an unrecognized `OS_TENANCY_POSTURE`** (printed by `os serve` and `os doctor`) still lists `group` and `isolated`, and now says beside each that 18.0 refuses it at boot. + - **`os doctor` reports a walled posture.** Under `group` or `isolated` it prints a `Tenancy posture` warning: 18.0 supports `single` only, so the next `os serve` (and so `os dev` and `os start`) refuses the posture at boot. The row is a warning, so doctor's exit code does not change. Before, doctor printed nothing about a valid posture. + + Nothing else changes. The refusals themselves, their codes and their exit codes are as before. +- 3b8e2b1: fix(runtime): two hand-built 400 refusals now put an ADR-0114 `FieldErrorCode` catalog member on `details.fields[].code` instead of a Zod issue code (#24597) + + Clause-②: no + + `FieldErrorCode` is a closed catalog: a service may not invent a field code. Two refusals built their `details.fields[]` rows by hand and wrote Zod's issue code there instead of a catalog member: + + - **The run-lifecycle body refusal** on `POST /api/v1/automation/:name/runs/:runId/cancel` and `POST /api/v1/automation/:name/runs/:runId/restore-suspension`. For an unknown body key, the row's `code` changes from `unrecognized_keys` to `unknown_field`. `unknown_field` is the code the `resume` door on the same run already answers for an unknown key. + - **The permission-set clone door's name rule** on `POST /api/v1/security/permission-sets/:name/clone`. For a `name` that is not lowercase snake_case, the row's `code` changes from `invalid_string` to `invalid_format`. + + What changes on the wire: a client that switched on `details.fields[].code` sees `unknown_field` where it saw `unrecognized_keys`, and `invalid_format` where it saw `invalid_string`. Both are members the published catalog already declares. Nothing else changes: each refusal keeps its status (400), its `error.code` (`VALIDATION_FAILED`), its message and its rows' `field` and `message`. No body is accepted or refused that was not before, and no catalog member is added. +- fec87e7: feat(spec)!: a package manifest's `permissions` no longer takes a flat list of permission strings — the structured `{ services, hooks, network, fs }` block is the only form (#13458) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not yet in on `main`). + + `ManifestPermissionsSchema` was a union of a flat `string[]` and the structured ADR-0025 §3.2 block. Nothing ever acted on the list: the loader registers the consented `grantedPermissions` set from the environment artifact with the permission enforcer, never the manifest's request, and the only code that met a list on a manifest was two reports saying it had been skipped. The marketplace install disclosure reads only the four lists, so a list was shown to an installer as "Requests no special permissions." (ADR-0049 enforce-or-remove). `ManifestPermissionsSchema` is now the structured block itself. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `permissions: ['system.user.read', 'system.data.write']` | `permissions: { services: [...], hooks: [...], network: [...], fs: [...] }`, naming the platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it reaches and the filesystem paths it touches. | + | `permissions: []` | delete `permissions`: absence is the spelling for "requests nothing". | + | a list on an app manifest that ships no code | delete `permissions`. An app's record access is its permission SETS, in the stack's own top-level `permissions` collection. | + + **The one-line fix: replace every manifest `permissions` list with the structured block, or delete it.** A permission string has no mechanical mapping onto the four lists, so the translation is done by hand. `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + + **What an author now sees.** Writing a list fails `tsc` (the key's type is the block), and `os validate`, `os build`, `os plugin build` and `defineStack` refuse it at `manifest.permissions` with the block's own answer: `Expected the plugin permission block { services?, hooks?, network?, fs? }, received a flat list.`, followed by the prescription. Every structured block that parsed before still parses, unchanged. + + ### The retirement kit + + - **Schema.** `ManifestPermissionsSchema` is `PluginPermissionsSchema`, by identity, and both export names stay. The block answers a list with its prescription on its own error map, the bare-array pattern `ListViewExportOptionsSchema` uses. The block also carries `EnvironmentArtifactSchema.grantedPermissions` values, so the answer is worded true there too, where a list was never legal. + - **D2 conversion `manifest-permissions-string-list-removed`** (step 18, retired from the load path): a lossless delete of an all-string list from the stack's `manifest` and every `packages[].manifest`. The notice carries the dropped strings. A built artifact replays it at the artifact door, so an artifact built while the list was legal still boots. It never touches the structured block, an array of objects, or the top-level ADR-0090 permission-set collection. + - **D3 entry `manifest-permissions-string-list-retired`** carries the judgement the delete cannot make: what each dropped string meant in services, hooks, hosts and paths. + - **Liveness.** `manifest.permissions` stays `live` on corrected evidence. Its consumer is the marketplace install disclosure, and it refuses nothing at load. The four keys are now drilled. + - **The two skip reports reworded.** `AppPlugin`'s security registrar and `@objectstack/plugin-security`'s audience-binding reconciler both report a manifest-stage `permissions` they cannot read as permission sets. They now name the flat list as the retired legacy form. They behave as before. + + **Measured producers: none outside tests.** On origin/main e67ba80049, no manifest in `examples/`, `apps/`, `packages/`, `skills/` or `content/docs/` writes a list. The exceptions are five `@objectstack/spec` `manifest.test.ts` fixtures, re-triaged here, and the skip-report tests of `@objectstack/runtime` and `@objectstack/plugin-security`, which hand a list to an unparsed bundle on purpose and still pass. The same instrument finds those five fixtures, which is its control. At the objectui pin `a58626c88dc8`, nothing reads `manifest.permissions` (control: 91 `manifest.(id|name|version)` reads), and the install disclosure reads the structured block alone. Deployed and cloud-held manifests NOT MEASURED. +- 34dba5a: `AppPlugin` starts after the auth plugin when both are composed, so the Default Organization exists before the inline seed loads + + `AppPlugin` now declares the auth plugin (`com.objectstack.auth`) among its order-if-present dependencies. Under the `single` posture the auth plugin creates the Default Organization in its `start()` (ADR-0131 D3), and every seed row is stamped with it; the declaration makes the kernel start the auth plugin first instead of relying on the order plugins were registered in. A composition without the auth plugin is unaffected. +- 95a8435: feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted + + Clause-②: no + + `SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served. + + A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run. + + In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `CAPABILITY_NAME_COLLISION` + - `capabilityNameCollisionDiagnostic` + - `formatCapabilityNameCollisionDiagnostic` + - `reportCapabilityNameCollisions` + - `CapabilityNameCollisionDiagnostic` (type) + + FROM → TO: + + - FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`. + - FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place. +- 9e9e6f9: feat(cli)!: `os meta resync` is deleted + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + `os meta resync` reconciled the platform-default `sys_permission_set` rows (`admin_full_access`, `member_default`, …) to the shipped declaration. Since the authorization resolver reads permission sets from the security catalog (ADR-0131 D3), an edited default set is served from its declaration at the next boot, and the rows the command reconciled decide nothing. The command, its `--yes` / `--json` modes and its skip-count explanation are gone; a script that calls it gets oclif's unknown-command error. + + In `@objectstack/plugin-security`, `@objectstack/driver-sql` and `@objectstack/runtime`, shipped comments and one warning sentence (the permission-set backfill's re-provision list) no longer name the command. Their behaviour is unchanged. +- 240890c: The `/packages` refusals of a read-only package name the customisation that works today, instead of the retired organization overlay (ADR-0131 D6) + + Clause-②: no + + `DELETE`, `PATCH /disable`, `POST /publish` and `POST /revert` on a package provided by code or an installed app answer `422 WRITABLE_PACKAGE_REQUIRED`, and `POST /packages/:id/duplicate` on one answers `422 DUPLICATE_SOURCE_NOT_A_BASE`. Both refusals told the caller to customise the package "in place, with an ADR-0005 org overlay". ADR-0131 D6 retired the per-organization overlay axis: no metadata write lands organization-scoped any more, and the protocol refuses one that names an organization with `403 NOT_OVERRIDABLE`. The remedy named a mechanism that no longer exists. + + Both refusals now name the customisation that works: customise what the package provides beside it, as environment-level state. Saving an item under its own name overlays it where the item's type takes an environment overlay and the package's lock does not refuse it. Any other save is refused, and that refusal names the route the item's type has, or the lock. The duplicate refusal also names the editable copy of a whole package: install it unmanaged instead (`installMode: 'unmanaged'`), where its manifest's `installModes` permits that. + + Only the message text changes. Each refusal's `code`, status, `details`, condition and order are unchanged. +- 1fb274e: The runtime dispatcher's `GET /meta/datasource/:name/published` serves a code-defined datasource's code definition while a stored row under its name still exists + + Clause-②: no + + - This is the dispatcher twin of the `@objectstack/rest` published door, and it now answers the same way. For a datasource name the host registers from code (one an installed package declares in `*.datasource.ts`, or the host's `default`), the door serves the layered read's `effective` layer, which is the code definition, instead of the leftover stored row. It asks the protocol's `declinesStoredRow` in place of `isShippedFlowName`. A shipped flow name is answered as before. + - Unchanged: a runtime datasource's stored row, and every stored row of every other type, is served as before. So is every row when the protocol does not provide `declinesStoredRow`. +- a543e24: `POST /packages/:id/revert` reverts a Studio-authored package instead of answering 404 "No metadata items found" + + Clause-②: no + + - The door asked only the metadata service, whose in-memory registry never holds a Studio package's stored rows. So a package with published items and a pending draft answered `404 RESOURCE_NOT_FOUND`, and the draft stayed. + - The door now asks the protocol's `revertStoredPackage` first, with the caller's active organization. A package with stored rows answers `200 { success: true }` with its drafts removed, or `409 RESOURCE_CONFLICT` when it has never been published. + - A package with no stored row is answered by the metadata service's `revertPackage`, exactly as before. That covers a code-shipped package and an unknown id (`404`). It is also what happens when the protocol does not provide `revertStoredPackage`. + - The request and the response shape are unchanged. +- d0bb78e: fix(runtime,metadata): publishing or reverting a read-only package is refused with `422 WRITABLE_PACKAGE_REQUIRED`, and package membership reads the `_packageId` stamp + + Clause-②: no + + ADR-0070 D2 makes a code or installed package read-only. `POST /api/v1/packages/:id/publish` and `POST /api/v1/packages/:id/revert` did not check this. These two answers change: + + - **Publish of a code or installed package: 200 → 422.** Before, the publish door answered 200. For a package whose items carry an authored `packageId`, for example `com.example.showcase`'s two capabilities, it answered `success: true` and wrote `publishedDefinition`, `state` and `version` onto those read-only items. For every other code package it answered `success: false`, "No metadata items found". It now answers `422 WRITABLE_PACKAGE_REQUIRED` before anything is written, the same refusal that `PATCH /packages/:id/disable` and `DELETE /packages/:id` already give. + - **Revert of a code or installed package: 404 or 409 → 422.** Before, the revert door answered `404 RESOURCE_NOT_FOUND` "No metadata items found" (for example `com.objectstack.setup` and the platform packages that ship objects) or `409 RESOURCE_CONFLICT` "Package '…' has never been published" (for example `com.example.showcase`). It now answers `422 WRITABLE_PACKAGE_REQUIRED`. The check runs after the protocol's stored-row answer, so a revert of a code package that has a stored row bound to it, such as an organization overlay draft, keeps its answer. + + To customise what a code package provides, use an ADR-0005 organization overlay. Overlay drafts publish through `POST /api/v1/packages/:id/publish-drafts` and the per-item publish door, and this change leaves both alone. + + **Unchanged:** a writable package's publish and revert, and an id that nothing carries (revert 404; publish 200 with `success: false`). + + `MetadataManager.publishPackage` and `revertPackage` now find a package's members by `packageId`, `package` or the private `_packageId` stamp. The artifact loader writes that stamp through `applyProtection`, and the ObjectQL object bridge copies it onto every object it registers. Before, an item that carried only the stamp was not a member. `MetadataManager` has no notion of package kind; the refusal of read-only packages lives at the two doors above. +- 3aa0c7b: The runtime dispatcher's `PUT /meta/:type/:name` honours `If-Match`, `If-None-Match: *` and `?mode=draft`, so a host that mounts the `@objectstack/hono` catch-all no longer overwrites a pinned save or publishes a draft save + + Clause-②: no + + - Behind `createHonoApp`'s `${prefix}/*` catch-all, this door handed `saveMetaItem` no `parentVersion` and no `mode`. A stale `If-Match` wrote (`200`, not `409`), `If-None-Match: *` over an existing row wrote, and a `?mode=draft` save landed on the ACTIVE row with a receipt saying `state: 'active'`, so every draft edit went live without a publish. The door now reads the request through `metaSaveRequestOptions` from `@objectstack/rest`, the same mapping `RestServer`'s `PUT` door reads it through. It answers as that door does: `409 METADATA_CONFLICT` for a stale token or for `*` over an existing row, a draft row that leaves the active row alone, and `400 VALIDATION_ERROR` for `If-Match` sent beside `If-None-Match` or for an `If-None-Match` other than `*`. Each refusal writes nothing. + - A host whose protocol has no `saveMetaItem` falls back to the metadata service's `saveItem(type, name, item)`, which cannot carry a precondition or a lifecycle. A save that asks for one there is now refused `501 NOT_IMPLEMENTED` and is not written. + - Unchanged: a save that sends neither header and no `?mode=draft` writes the active row, last writer wins, as before. The refusal envelope is this transport's own (`{ success: false, error: { code, message, httpStatus } }`). +- fbcbcf1: The runtime dispatcher's `/meta` domain reads `?package=all` as naming no package, as `RestServer` does, so a `PUT ?package=all` through the `@objectstack/hono` catch-all no longer binds the row to a package called `all` + + Clause-②: no + + - The dispatcher's `PUT /meta/:type/:name?package=all` stored the row with `package_id: 'all'`. It now stores it env-local (`package_id` null), as `RestServer`'s `PUT` does. + - Its layered read (`/meta/:type/:name/layers`), list (`/meta/:type`), book tree (`/meta/book/:name/tree`) and item read forwarded `all` to the store as a package id. The layered read answered `404` and the list `[]` for an item stored in a package. Each now answers `?package=all` as it answers the same request without `?package=`. + - Every branch reads `?package=` through `metaItemPackageBinding` from `@objectstack/rest`, the function `RestServer`'s doors read it through. + - Unchanged: a real package id still scopes each read and still binds a save. +- f4069c5: fix(metadata): the object read's `sortability` follows the caller's field permission for every caller class + + Clause-②: yes (widening) + + - **What was wrong.** ADR-0106 D4 serves a caller holding `manage_metadata`, `studio.access` or `setup.access` (a platform admin, and an organization admin through `setup.access`) the object schema UNMASKED, because Studio and Setup authoring need the whole definition. The `sortability` projection served beside it on `GET /meta/object/:name` (and the runtime dispatcher's `/metadata/object/:name`) was derived from that unmasked document, so it marked a field `sortable: true` that the same caller's field permission marks unreadable, while the data route refuses a sort or filter on that field with `403 PERMISSION_DENIED`. A client building its sort picker from the projection offered a click the server refuses. + - **What changes.** `sortability` is now derived from the caller's runtime view: the served document for every caller the mask projects (unchanged), and for a D4-exempt caller the fields their own field permission reads. The definition (`item.fields`) is unchanged for every class: whole for an exempt caller, masked for everyone else. The data route and `/auth/me/permissions` are unchanged. + - **Caching.** On the cached `GET /meta/object/:name` branch an exempt caller's ETag folds a fingerprint of the fields withheld from their projection, and the conditional request is judged after it is folded. An exempt caller whose permission withholds nothing keeps the byte-identical ETag. An exempt caller and a projected caller denied the same field never share a validator. + - **Failure posture.** The exempt caller's field permission is asked lazily, only by the item read that serves `sortability`, and never turns their definition read into a fault: if the security service throws or cannot answer, `sortability` is derived from the served document as before, with one `warn`. + - **New in `@objectstack/metadata-core`.** `resolveObjectSchemaRuntimeView(document, posture)` returns `{ document, withheld, fingerprint }`, the view the runtime projections beside an object schema are derived from. The types `ObjectSchemaRuntimeView` and `ObjectSchemaRuntimeFieldPosture` are exported with it, and the `passthrough` member of `ObjectSchemaMaskPosture` gains an optional `runtime` (set on an `exempt` posture for a capability-exempt caller in a deployment with a `security` service). + - **New in `@objectstack/rest`.** `translateMetaEnvelope` takes an optional fifth argument, `runtimeDocument` (default: the served document), and `MetaItemAnswerSources.translateEnvelope` an optional third. Nothing is removed, renamed or narrowed. +- a45d5d8: fix(auth): a server-side session read no longer renews a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Ten doors read the session in-process (`auth.api.getSession`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, later `GET /api/v1/auth/get-session` calls found a fresh row and re-issued nothing, and the cookie expired first: a dead cookie beside a live bearer, and every cookie-only path then saw a signed-out user. Measured on a fresh dev stack (better-auth 1.7.3, `expiresIn` 604800 s, `updateAge` 86400 s, a session aged to `now + expiresIn − updateAge − 60 s`): `GET /api/v1/data/:object`, `GET /api/v1/auth/me/permissions`, `GET /api/v1/meta/object`, `GET /api/v1/i18n/locales`, `GET /api/v1/packages`, `GET /api/v1/marketplace/install-local` and the MCP door each moved `expires_at` by +86460 s and set no session cookie. + + **The rule now, decided by what the request carries.** + + - **A session cookie** (a browser, including a console that sends its cookie beside its bearer): the in-process read passes `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. Measured after the change: every door above leaves `expires_at` unchanged on a cookie request and sets no cookie. + - **No session cookie** (a bearer-only client: `@objectstack/client` outside a browser, the `os` CLI): unchanged. A data read past `updateAge` still renews the session (+86460 s, measured on the same doors), so an active bearer client keeps sliding forward without calling `get-session`. No cookie is ever set on a response to a request that sent none. + + **Upgrading.** Nothing to change. A browser session renews whenever the app calls `GET /api/v1/auth/get-session`; a tab that never calls it now signs out at the session's real expiry instead of keeping a live bearer beside a dead cookie. + + `@objectstack/types` gains `inProcessSessionReadInput(headers)` (the `getSession` input for an in-process read: the request's own headers, plus `query: { disableRefresh: true }` when they carry a better-auth session cookie), `carriesSessionCookie(headers)` and the `InProcessSessionReadInput` type. A host that calls `auth.api.getSession` itself should read through `inProcessSessionReadInput` for the same reason. + + The same rule is applied to the in-process `auth.api.getSession` readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` by their own changeset. +- ca135dc: fix(runtime): the trigger door refuses a self-triggered flow declared to run as system to any caller but the system principal + + Clause-②: no + + A self-triggered flow declared to run as system could be started by any signed-in member through the trigger door. `POST /api/v1/automation/:name/trigger` (and the legacy `POST /api/v1/automation/trigger/:name`, and `@objectstack/verify`'s `flows.run`, which answer through the same function) asked only whether the caller was anonymous, so a flow meant to run on its own trigger, or as a sub-flow, also ran elevated for anyone who named it. + + **What changes.** A caller that is not the system principal now gets `403 PERMISSION_DENIED` (ADR-0112 envelope) when it starts a flow declared `runAs: 'system'` whose `type` is `autolaunched`, `record_change` or `schedule`. Nothing runs: no run record, no node, no side effect. The refusal names no part of the flow. This includes a platform admin's session, which is a signed-in user, not the system principal. + + **What stays as it was.** + + - `screen` and `api` flows, elevated or not: doors the author designed (ADR-0073 D2). + - Every flow that does not declare `runAs: 'system'`. + - A parent flow's `subflow` node calling an elevated flow: the child starts through the engine, never through the door. + - The system principal (an in-process caller, such as a job), which starts every flow. + + **If a call now answers 403.** That flow runs on its own trigger. To start its work on a user's request, call it from a parent flow's `subflow` node, or, if it is meant to be a door, declare it `type: 'screen'` (or `type: 'api'` for a signed inbound hook) so the elevation is a reviewable choice. +- 746637e: fix(service-automation)!: retire the `RunProvenanceContext` type; `RunDataContext` names only what `resolveRunDataContext` returns + + Clause-②: no (narrowing) + + + + **BREAKING** for TypeScript code that imports from `@objectstack/service-automation`: one type export is removed and one type narrows. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes in any package. + + **What is removed.** The `RunProvenanceContext` type export, the `{ flowRunId }`-only "provenance" envelope, and its arm of `RunDataContext`. No code in this repository builds or reads one. Since ADR-0096 D5, the data engine's security middleware refuses a context that carries no principal and is not a system context (`403 PERMISSION_DENIED`, for every verb). So the type described a context the engine refuses, while its docstring said such a context was "indistinguishable from passing no context at all" and was let through. + + **What `RunDataContext` is now.** Exactly what `resolveRunDataContext` returns: `isSystem: true` for a `runAs: 'system'` run, and the triggering user's principal for a `runAs: 'user'` run. A run that resolves no principal is refused before any data operation (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`), as before. + + **If your code named the type.** There is no replacement, because the context it described is refused. A custom data node builds its context with `resolveRunDataContext(context)`, typed `RunDataContext`. + + **`@objectstack/plugin-security`, `@objectstack/runtime`, `@objectstack/service-analytics`, `@objectstack/platform-objects`:** comment and docstring text only. Shipped comments that said the security middleware hands through, skips or falls open for a context with no principal now say that it used to, and that ADR-0096 D5 refuses such a context. Nothing these packages do changes. +- c572fe2: fix(runtime): the action door and the declared flow endpoint refuse a self-triggered flow declared to run as system to any caller but the system principal + + Clause-②: no + + The trigger door already refused this start, but two other doors that start a flow by name did not ask: an action of `type: 'flow'` (`POST /api/v1/actions/:object/:action`, and the MCP `run_action` tool, which reaches the same dispatch) and a declared endpoint of `type: 'flow'`. Any signed-in member who was refused at the trigger door could start the same elevated flow through either one, and its elevated write landed. + + **What changes.** All three doors now apply one rule from one place. A caller that is not the system principal gets `403 PERMISSION_DENIED` (ADR-0112 envelope) when it starts a flow declared `runAs: 'system'` whose `type` is `autolaunched`, `record_change` or `schedule`. Nothing runs: no run record, no node, no side effect. This includes a platform admin's session and the guest principal an `authRequired: false` endpoint admits. The refusal names no part of the flow, and its message is now the same at every door: it no longer names the trigger door. + + **What stays as it was.** + + - `screen` and `api` flows, elevated or not: entries the author designed (ADR-0073 D2). + - Every flow that does not declare `runAs: 'system'`. + - A parent flow's `subflow` node calling an elevated flow: the child starts through the engine, never through a door. + - The system principal (an in-process caller, such as a job), which starts every flow. + - The action's own `requiredPermissions` gate (ADR-0066 D4) still answers first. Holding that capability does not open a self-triggered elevated target. + - A declared endpoint's policy chain: an anonymous caller at an `authRequired: true` endpoint still gets `401` first. + + **If an action or an endpoint now answers 403.** Its target flow runs on its own trigger. If members are meant to start that elevated work, declare the target `type: 'screen'` (or `type: 'api'` for a signed inbound hook), which states that it is an entry and keeps the elevation reviewable. Or keep it self-triggered and point the action or endpoint at such an entry whose `subflow` node calls it. +- 0fea05f: `os serve `: a multi-package config now serves the top-level metadata no package declares, under the stack's `manifest.id`, and the boot says so, as an artifact boot of the same project already did + + Clause-②: yes + + A config boot (`os serve objectstack.config.ts`, with or without `--dev`, and no compiled artifact) of a stack that carries `packages[]` registers each package body and never the stack's top level. A top-level item that no package body declares was served by no door, and the boot said nothing. Three shapes reached it: flat `src/docs/*.md` pages when the stack's `manifest.id` names no `packages[]` entry, inline `docs` spread onto a composed stack's top level, and any other top-level collection (objects, views, …) that no package carries. `os build` of the same project, booted as an artifact, registered those items under the stack's `manifest.id` and warned. + + - **What changes.** The config boot now runs the same residual rule as the artifact boot, `MetadataPlugin.registerUnclaimedTopLevel`. Every top-level item no package body declares is registered under the stack's `manifest.id`, and the boot prints one `warn` line with the count: `config stack '' carries N top-level metadata item(s) that none of its M package bodies declare`, followed by the remedy. `GET /api/v1/meta/` lists those items under that id, as on an artifact boot of the same project. This includes the flat `src/docs/` pages of a stack whose `manifest.id` names no package, which a config boot did not list before. This entry supersedes, in the same release, the note in the `os serve ` flat `src/docs/` entry that "A config boot of such a stack still does not list them": it now does, under the stack's `manifest.id`, with the warning. + - **A new refusal, in one corner.** A residual view container is judged like any other: a top-level `defineView` container that no package declares, and whose own `name` is not the object it binds to, now refuses the config boot with ``Invalid `views:` container from manifest '': the container's own `name` is …``. On the previous release that config booted, because its top level was never read; an artifact boot of the same project already refused it. `os validate` and `os build` refuse the same container first, in the same words, so the refusal is never a boot-time surprise. A divergent container that a package body declares and the top level repeats is refused once, as that package's. **The one-line fix:** drop the container's `name`, or set it to the object it binds to. + - **The remedy the line names.** Declare each such item inside the `packages[]` entry of the package that owns it. A flat `src/docs/` page moves under `src//docs/`, where `` is the package id or its last dot-separated segment. + - **What widens, in `@objectstack/metadata`.** Its root entry now exports `unclaimedTopLevel(stack)`, the pure residual decision both boot doors and `os validate` read, with its two types `UnclaimedTopLevel` and `UnclaimedTopLevelItem`, and `MetadataPlugin` gains the public static `registerUnclaimedTopLevel(sink, stack, source)`, the one residual rule both boot doors call. + - **What stays the same.** An artifact boot registers the same items and prints the same line, byte for byte. A single-package config (no `packages[]`) has no residual and is unchanged. A composed stack whose top level repeats its package bodies, as `composeStacks([…], { manifest: 'preserve' })` writes it, registers nothing extra and prints nothing. + - **What the residual is, on both doors.** It is metadata: a residual object is listed on `GET /api/v1/meta/object` under the stack's `manifest.id`, and `GET /api/v1/data/` still answers `404`. That was already true on an artifact boot, and the config boot now gives the same answer. +- 26aa999: fix(runtime,hono): approval e-mail links work on a kernel with no raw HTTP app — the dispatcher serves `/approvals/act`, and the Hono catch-all no longer consumes request bodies + + Clause-②: no + + On a self-hosted server, `@objectstack/plugin-approvals` serves the ADR-0043 action page (the approve / reject link in an approval e-mail or IM message) by mounting `GET` and `POST /api/v1/approvals/act` on the raw Hono app. A hosted tenant kernel has no raw app, so on that shape the path answered `404 ROUTE_NOT_FOUND` and every action link was dead. + + **`@objectstack/runtime`.** `HttpDispatcher` now serves exactly `/approvals/act` (no sub-paths) for `GET`, `HEAD` and `POST`. It forwards the request, unread, to the request kernel's `approvals` service (`IApprovalService.handleActionPage`) and returns that `Response` as it is. + + - `HEAD` answers as the self-hosted mount does: the `GET` page's status and headers, with no body. It renders and never decides. + - No `approvals` service registered, or one without `handleActionPage`: `501 NOT_IMPLEMENTED`, naming which. Never `ROUTE_NOT_FOUND`. + - A member that throws: a sanitised `500 INTERNAL_ERROR`, with the original error in the server log. The same answer, logged, when a transport hands over a `POST` whose body was already read, instead of an "invalid link" page for a live link. + - The token is the only credential, as on the self-hosted mount. The project-membership gate now skips the exact act path (unscoped or `/environments/:id`-scoped), so a signed-in caller who is not a member of the environment reaches the page like an anonymous one instead of getting `403 PROJECT_MEMBERSHIP_REQUIRED`. Every other path, `/approvals/act/...` and `/approvals/requests/...` included, is checked as before. + + **`@objectstack/hono`.** The `createHonoApp` catch-all (and the `/auth/*` fallback) read the JSON body with `c.req.json()`, which consumed the raw request for every `Content-Type`. A domain that forwarded the request got it empty. The body is now parsed from a clone, so `context.request` reaches the dispatcher unread, byte for byte. The parsed `body` the domains receive is unchanged for every input: JSON values as parsed, and `{}` for an empty, form, text or invalid body. The cost is that a request body is buffered twice for the life of the request. + + Nothing changes on a self-hosted server: the plugin's raw-app mount still serves the path there and never reaches the dispatcher. +- cb3bb93: fix: a hook's refusal reached through an action or another hook answers in the hook's own words, and `$top=0` reports the real `total` (#22588) + + Clause-②: no + + - **A nested sandboxed refusal keeps its sentence (`@objectstack/runtime`).** A script action whose body writes through `ctx.api` and is refused by a sandboxed hook used to answer with the hook's debug wrapper in front of the sentence: `POST /api/v1/actions/:object/:action/:id` → `400 VALIDATION_ERROR` "`hook 'guard' threw: Error: A finished task cannot be reopened.`". It now answers the hook's sentence alone, as `PATCH /api/v1/data/:object/:id` and a declarative `operation: 'update'` action already did. The same holds for a hook that writes another object through `ctx.api` and is refused there: `/data` answers the inner hook's sentence. Status and `code` do not change. The cause was the sandbox's VM hop: a host error crosses into a body's VM as `name` and `message` only, so the refused write's sentence was left behind and the wrapper became the outer error's caller-facing text. The sentence now travels beside the wrapper and is used when the body lets that error escape unchanged. A body that catches the error and throws its own (or rewrites its message first) is still answered in its own words, a nested crash is still a `500`, and the server log still names every frame. + - **The `/actions` door reads its sentence through `sandboxBusinessMessage`** (`@objectstack/types`), the same read `/data`, the bulk doors and `/analytics/dataset/query` make, instead of its own copy. The wire answer is unchanged. + - **`$top=0` reports the filtered `total` (`@objectstack/metadata-protocol`).** `limit: 0` asks for no rows, and `findData` treated it as "no limit", so the zero-length page was reported as the total: `GET /api/v1/data/:object?$top=0` answered `total: 0` while `$top=1` answered the real count. The Console reads its list footer's record count from exactly that `$top: 0` request. A zero-row request is now a paged request: it returns no rows and the same `total` as any other page size, filters included. `$count=false` still skips the count and now omits `total` for `$top=0` too, instead of reporting `0`. A `search` query still reports its page-local estimate, as it does at every page size. +- 5081090: fix(runtime): the `/meta/` legacy object exit asks the read gate before it serves the schema (#22639) + + Clause-②: no + + - The runtime dispatcher's one-segment `/meta/` exit, reached when a host's protocol has no type catalogue and no list for the segment, served the object schema through the field mask alone. It now asks the shared `/meta` read gate first, as every other object exit does, so an object's own `listViews` entries whose `requiredPermissions` the caller does not hold are not served there either. A caller who may edit the object still reads them all. +- 8558668: fix(runtime): the webhook redeliver button works on a kernel with no raw HTTP app — the dispatcher serves `POST /webhooks/redeliver` + + Clause-②: no + + On a self-hosted server, `@objectstack/plugin-webhooks` serves the operator's redeliver button by mounting `POST /api/v1/webhooks/redeliver` on the raw Hono app. A hosted tenant kernel has no raw app, so on that shape the path answered `404 ROUTE_NOT_FOUND`, even though the plugin had registered the `webhooks` service that can serve it. + + **The door that changes: `HttpDispatcher`, for a kernel with no raw app.** It now serves exactly `POST /webhooks/redeliver` (no sub-paths, no other method). It forwards the request, body unread, to the request kernel's `webhooks` service (`IWebhookService.handleRedeliver`), read on every request, and returns that `Response` as it is. So the hosted door answers what the self-hosted mount answers: `200` with the replayed row, and the same `401`, `400`, `404` and `409` refusals with the same bodies. An anonymous call is refused `401 UNAUTHENTICATED` there exactly as on the mount. + + - No `webhooks` service registered (plugin-webhooks absent, or present without its redelivery veto), or one without `handleRedeliver`: `501 NOT_IMPLEMENTED`, naming which. Never `ROUTE_NOT_FOUND`. + - A member that throws: a sanitised `500 INTERNAL_ERROR`, with the original error in the server log. The same answer, logged, when a transport hands over a request whose body was already read, instead of a "not JSON" refusal for a well-formed request. + - Any method other than `POST` is not claimed: it answers the dispatcher's ordinary `404 ROUTE_NOT_FOUND` and never reaches the service. + - The dispatcher's two gates apply to this path unchanged: a session that still owes an authentication step, or a signed-in caller who is not a member of the request's environment, is refused `403` before the service runs. This door is therefore stricter than the self-hosted mount, never looser. + + Nothing changes on a self-hosted server: the plugin's raw-app mount still serves the path there and never reaches the dispatcher. +- 117817e: fix(runtime): on a `kernel-resolver` host, a dispatcher door resolves a service on the request's own environment kernel before the host kernel. `HttpDispatcher.resolveService` used to ask the host kernel's scoped lookup first, and the host kernel's singleton `protocol` / `metadata` / `objectql` answered it whatever the scope. So `POST /api/v1/actions/:object/:action` loaded its subject row through a protocol that registers none of the environment's objects, and it answered `404 RECORD_NOT_FOUND` for a row the caller can read. Hosts with no request kernel of their own (single-environment, SharedProjectPlugin) resolve exactly as before, and an id that names nothing is still refused with 404. + + Clause-②: no +- 0cbfb7c: A stack's declared `apis` endpoints are served on a config boot whose metadata service has an endpoint matcher, as they already were on an artifact boot + + Clause-②: no + + The endpoint matcher reads only the metadata service's own `api` items. The artifact door registers them there. A config boot (`os serve objectstack.config.ts` with no compiled artifact) handed them to the engine registry alone, so every declared path answered `404 ENDPOINT_NOT_FOUND`. The built artifact of the same stack matched the same path and answered `401 UNAUTHENTICATED` to a caller with no session. Measured on a stack with no plugin instances that declares one `authRequired` endpoint: `GET` of its path answered 404 from source and 401 after `os build`. + + `AppPlugin` now registers each declared `api` item into the metadata service's in-memory registry. It uses the route and the package stamp (`_packageId` / `_packageVersion` / `_provenance`) the code-defined datasources already take, and it takes the items from the boot's one forward-converted copy. The same boot now answers 401 from source. The stored item equals the artifact door's copy of it. On a boot that runs the artifact door (`os dev`, an artifact boot), the door stays the only registrar, so each item is registered once. + + One case is unchanged: a composition whose metadata service is the kernel's in-memory fallback has no endpoint matcher. A config whose `plugins` holds plugin instances, booted with `os serve`, is that composition. It now lists the items and still serves none. +- 7f2f081: `os serve` of a host config serves the stack's declared `apis` endpoints, and a composition that still cannot serve them says so once + + Clause-②: no + + A host config is a stack whose `plugins` holds plugin instances: `examples/app-showcase` and every `create-objectstack` scaffold. On `os serve objectstack.config.ts` (and on `os start`, whose `serve` child boots the same way) such a config never got `@objectstack/metadata`'s `MetadataPlugin`, so its `metadata` service was the kernel's in-memory fallback, which has no endpoint matcher. Every declared `apis` path answered `404 ENDPOINT_NOT_FOUND` there, while `os dev` of the same stack matched it. Measured on the showcase with no session: `GET /api/v1/apps/showcase/tasks` answered 404 and now answers `401 UNAUTHENTICATED`; an undeclared path under the same namespace still answers 404. + + `os serve` now composes a `MetadataPlugin` with no artifact on a host-config boot that has none (when `os dev`'s artifact door does not compose and `plugins` holds no `MetadataPlugin` of its own). What an upgrading host author meets on that boot, measured on the showcase before and after: + + - `GET /api/v1/meta/TYPE` answers byte-for-byte the same for every type it served before (22 types read, including `app`, `view`, `page`, `dashboard`, `flow`, `position` and `permission`). + - `GET /api/v1/openapi.json` now documents the declared endpoints the boot serves; before, it listed none of them. + - No new database table: the `sys_metadata` storage tables were already provisioned, and no row is written at boot. + - The door writes nothing into the project directory at boot. Its metadata repository is rooted at the served app's directory, not at the directory the command was run from. + - The door reads none of the stack and primes nothing from the filesystem (`bootstrap: 'lazy'`), so `positions` / `permissions` / `capabilities` / `sharingRules` keep exactly one registrar. + - The dev-only `GET /api/v1/dev/metadata-events` stays unmounted unless `NODE_ENV=development`. Under `os serve --dev` with no compiled artifact it is now mounted, as it is on every other config's dev boot. + - With a remote cluster driver, the metadata service's cross-node cache invalidation (`metadata.changed`) now attaches; the boot warning that it was disabled is gone. + - Boot time and memory did not measurably move (median of 7 boots each: 8.47 s → 8.41 s to listening, 461 MB → 459 MB resident). + + A composition that still has no endpoint matcher (for example a kernel composed without `MetadataPlugin`) now logs one warning at boot per app that declares `apis`, with the count, naming the remedy: compose `@objectstack/metadata`'s `MetadataPlugin`. +- 4267484: `GET /api/v1/meta/` now lists an artifact's items in their converted shape, the shape `GET /api/v1/meta//` serves + + Clause-②: no + + A compiled artifact built before a spec retirement still carries the retired key, and the ADR-0087 forward conversion rewrites it when the artifact boots. The artifact door converted it, so the item read served the converted copy. `AppPlugin` handed the RAW bundle to the engine registry, which is what the list read, `getMetaItemsForExecution` and the email template loader read. So the list served the retired key with `_diagnostics.valid: false`, and a reader of the list (the email loader is one) was handed an item the current schema refuses. Measured with `os dev -a dist/objectstack.json --fresh` on an artifact that declares no `engines.protocol` and carries a dashboard with `refreshInterval: 300`: the item read served `refreshIntervalSeconds: 300`, valid, and the list served `refreshInterval: 300`, invalid. + + `AppPlugin` now runs one forward conversion per boot, after the protocol handshake, over the same definition its security registrar already converted. The registry and that registrar both register from its result. An artifact authored in the current spellings registers exactly what it did before, because a conversion that changes nothing returns its input. + + The security seeders read the same registry copy first, so a boot with an engine now seeds what the conversion produced. Measured on `createStandaloneStack` with a `>=17.1.0` artifact: a sharing rule declared with the legacy recipient type `role` is now seeded as `position`, where it used to be skipped as an unmappable recipient. A seeded permission set no longer stores the retired `allowRestore` / `allowPurge` or the row-level-security `priority`. + + One case is unchanged: a bundle that carries `packages[]` still hands its package bodies over as authored. No conversion reaches those bodies, at the artifact door either. +- c5b43b6: fix(metadata-core, runtime): a multi-package artifact built before a retirement boots at `os dev -a` and `os start`. The ADR-0087 forward conversion walks every `packages[].manifest` body, and the runtime's artifact load converts the bundle once, before any reader sees it + + Clause-②: no + + **What an operator saw.** A multi-package release artifact (ADR-0130 D4: `os build` of a `composeStacks(…, { manifest: 'preserve' })` project) carries its collections in `packages[i].manifest`. Take such an artifact built before a retirement, for example with a dashboard still spelled `refreshInterval`. `os dev -a dist/objectstack.json` and `os start` exited at boot with "`packages[1]` is not a package entry (ADR-0130 D4): manifest.dashboards.0.refreshInterval: `dashboard.refreshInterval` was renamed to `refreshIntervalSeconds`…". The metadata plugin's artifact door refused the same artifact at its strict parse. The ADR-0087 conversion converted that dashboard when it sat in the artifact's own `dashboards`, but it never read a package body, and the runtime read the bundle raw before any conversion ran. + + **What an operator sees now.** The same artifact boots at `os dev -a` and at `os start`. Each body's items are served converted (`refreshIntervalSeconds: 300`, valid), each owned by its own package. The boot log names each conversion once, with the body it found it in: `[MetadataPlugin] artifact '…' … converted 2 site(s) forward via ADR-0087 conversion 'dashboard-refresh-interval-to-refresh-interval-seconds' (first at packages[0].manifest.dashboards[0].refreshIntervalSeconds) …`. The artifact file itself is unchanged, so it converts again on every boot until it is rebuilt with `os build`. A body built after the retirement converts nothing and logs nothing. An artifact authored past every retirement is still refused with the tombstone inside its body. + + - **`@objectstack/metadata-core`.** `applyArtifactForwardConversions` runs the same conversion over each package body as over the artifact's own collections. It uses the same registry and the same window, decided once from the artifact's own `engines.protocol`; a body's own `engines.protocol` is not read. A notice from a body is located from the artifact root (`packages[1].manifest.…`). + - **`@objectstack/runtime`.** `loadArtifactBundle` converts the loaded artifact once, before it merges the runtime module and before the stack, `AppPlugin` or the `manifest` service read it. The database resolver reads the artifact file itself, and it runs the same conversion on what it reads. So a multi-package artifact that declares its default datasource in a package body opens that database, not the unified default. + + **Unchanged.** An artifact without `packages[]` converts exactly as before, with the same notices, paths and messages, and it is served the same way. +- 6662e3f: A job's `timeout`, a turso datasource's `config.timeout` and a memory datasource's `config.persistence.autoSaveInterval`, authored by 17.x tooling, are honoured again when the bundle reaches `AppPlugin` raw + + Clause-②: no + + Protocol 18 renames these three duration keys so their unit is in their name: `jobs[].timeout` to `timeoutMs`, turso `config.timeout` to `config.timeoutMs`, memory `config.persistence.autoSaveInterval` to `autoSaveIntervalMs`. An artifact built earlier still carries the old spelling, and the boot's ADR-0087 forward conversion renames it. `AppPlugin` registered that converted copy with the `manifest` service and the security registrar, but its job scheduling and its datasource registration and auto-connect read the RAW bundle. So the scheduler and the drivers found no `*Ms` key and fell back to their defaults, with no notice. + + `loadArtifactBundle` converts at load, so an artifact booted with `os start --artifact` or `os dev -a` already arrived converted. A bundle in a host config's `plugins[]` array does not go through it: `os serve` and `os migrate` wrap it into `new AppPlugin(bundle)` as authored. Measured through that route on a bundle that declares no `engines.protocol`: the job was scheduled with no timeout at all, and both datasources were registered and handed to `connectDeclared` with the retired keys. + + Job scheduling, the code-defined datasource registration, its reserved-name check and `connectDeclared` now read the boot's one forward conversion, the copy every other registrar takes. A bundle authored in the current spellings is read exactly as before: a conversion that changes nothing returns its input, and the registered datasources carry the same `_packageId` / `_packageVersion` / `_provenance` stamps. A handler job's `ctx.bundle` is that converted copy as well. +- 8c10776: fix(metadata-protocol): a stored-metadata migration records the user who ran it, or nobody, never a label + + Clause-②: no + + - **What was wrong.** The stored-metadata migration pass (`os migrate meta --stored`, `POST /api/v1/meta/_migrate-stored`, the runtime dispatcher's `POST /metadata/_migrate-stored`, and `migrateStoredMetadata()` called in process) recorded a label as the writer of every row it rewrote: `'os migrate meta --stored'`, `' (POST /api/v1/meta/_migrate-stored)'`, `'POST /api/v1/meta/_migrate-stored'`, or `'migrate-stored'`. The writer lands in `sys_metadata_history.recorded_by` and in the row's `updated_by`. Both columns are references to `sys_user`, so the label pointed at no user. Nothing refused it, because metadata writes run as the system. + - **What it does now.** The pass records the id of the user who ran it, exactly as every other metadata write does. A REST or runtime call by a signed-in user with `manage_metadata` records that user's id. A run with no user records `null`, which means "system-initiated". That covers the CLI, which opens the database directly with no session, and a system caller. The pass still names itself on each history row's `source` (`migrate-stored`), as it did before. + - **What an operator sees.** History rows and rewritten rows written by a migration from this release on name the person who ran it, or nobody. They never name a command or a route. Rows a migration rewrote before this release keep the label they were given. + - **Contract text.** `MetadataItem.authoredBy`'s description now gives the same rule as `PutOptions.actor`: the acting user's id, or `null` for a system-initiated write, never a label such as `"system"` or `"cli"`. The schema accepts the same values as before. +- a3d47c2: fix(metadata-fs, runtime): metadata writes record the acting user's id or nobody, never the label `'fs'`, and a signed-in save through the runtime dispatcher records the user + + Clause-②: no + + - **What was wrong (filesystem repository).** `FileSystemRepository` named the label `'fs'` as the writer whenever no user was given. That covered every edit it saw on disk, every file it found that no recorded write produced, and an actor-less `put()`, which read back as `'fs'`. `MetadataPlugin` attaches the repository without naming a user, so this happened on every host that boots it. The contract for these fields (`PutOptions.actor`, `MetadataItem.authoredBy`, `MetadataEvent.actor`) is the acting user's id, or `null` for "nobody". + - **What it does now.** With no user named, the repository records `null`. A write that names a user records that user, and a write that records `null` reads back as `null`. The `fsActor` option keeps its type. When it is set, it is the user id reported for changes the repository did not write itself, and it no longer replaces a writer that a write recorded. Every event still names its origin on `source: 'fs'`, as before. + - **What was wrong (runtime dispatcher).** `PUT /api/v1/meta/:type/:name` served by the runtime dispatcher (the `@objectstack/hono` catch-all) saved without the caller's identity, so a signed-in author's save recorded `null` in `sys_metadata.updated_by` / `created_by` and `sys_metadata_history.recorded_by`. `RestServer`'s route recorded the user. + - **What an operator sees.** Filesystem-backed metadata history and events from this release on no longer name `'fs'` as the writer. A save through the runtime dispatcher records the signed-in user's id, as the REST route does, and a system caller with no user records `null`. Entries written before this release keep what they recorded. +- 21c8644: A hook's `timeout` authored by 17.x tooling bounds the hook again, and the three retired translation keys are no longer served, when the bundle reaches `AppPlugin` raw + + Clause-②: no + + Protocol 18 renames a hook's `timeout` to `timeoutMs`, and retires three translation keys: a per-app `settings` group, a page component's `submitLabel` and a dashboard widget's `subCaption`. An artifact built earlier still carries the old spellings, and the boot's ADR-0087 forward conversion rewrites them. `AppPlugin` bound its hooks and loaded its translation bundles from the RAW bundle, though. So the engine's hook wrapper found no `timeoutMs` and ran the hook with no time limit and no notice, and the i18n service served all three retired keys; the per-app `settings` group still filled the gaps in the platform's own settings copy. + + `loadArtifactBundle` converts at load, so an artifact booted with `os start --artifact` or `os dev -a` already arrived converted. A bundle in a host config's `plugins[]` array does not go through it: `os serve` and `os migrate` wrap it into `new AppPlugin(bundle)` as authored. Measured through that route on a bundle that declares no `engines.protocol`: the hook reached the engine as `{ timeout: 5000 }` and never timed out, and `getTranslations('zh-CN')` served all three retired keys. + + The hook binder and `loadTranslations` now read the boot's one forward conversion, the copy every other `AppPlugin` registrar takes. Which lists are read (the stack's own, a legacy `manifest.hooks` or `manifest.translations` list) is decided as before, and a list the conversion lifted from the manifest is read once, converted. A bundle authored in the current spellings is read exactly as before: a conversion that changes nothing returns its input, so the binder and the i18n service are handed the authored hooks and bundles themselves. + + One more behaviour change, measured on the same `plugins[]` route. A hook body or a script action's body that declares `capabilities: ['crypto.hash']`, reaching `AppPlugin` raw, was refused at bind: an `invalid hook.body shape` or `invalid action.body shape` warn, the hook never fired and the action was not registered. Read from the converted copy, where `hook-body-crypto-hash-removed` (the capability was retired at major 17) strips the token, it now binds and runs, as the `manifest` service already registered it. A body that calls `ctx.crypto.hash` throws when it runs, because the sandbox never installed that call. The remedy: drop the `ctx.crypto.hash` call. +- d4aaeaf: fix(runtime): cloning a flow, and publishing a package's drafts, no longer fail on a `protection` block left in a row stored before the metadata save door refused it + + Clause-②: no + + Two runtime doors re-save a body the server builds from a stored row: the flow clone (`POST /api/v1/automation/:name/clone`), and the visibility flip of `POST /api/v1/packages/:id/publish-drafts`, which re-saves each of the package's apps still stored with `_unpublished: true`. The metadata save door now refuses a `protection` block with `400 VALIDATION_ERROR` (entry `23096-save-door-protection`). A row stored before that refusal can still carry a block, and both doors copied it into the body they saved. + + **FROM.** A clone of such a flow answered `400 VALIDATION_ERROR` about a block the admin never sent. The flip of such an app was refused the same way: the publish still answered `200`, the app stayed `_unpublished`, and the refusal was reported only under `unhideError`. + + **TO.** Both doors drop the `protection` block from the body they save, as the permission-set clone already does. The clone and the flip save, and the saved row carries no block. Nothing else in either body changes, and neither door states a `source`, so every other save-door check still applies. The block never bound anything: only a code package's loader translates `protection` into a lock, so dropping it changes no lock and no verdict. + + **Unchanged.** A flow or an app with no block is saved exactly as before. +- 917b1a0: fix(plugin-auth)!: IdentityObjectsPlugin keeps the single posture's Default Organization at boot + + Clause-②: no (narrowing) — it restores ADR-0131 D3's Default Organization boot invariant, which `default-organization-invariant.ts` states as "[ADR-0131 D3] The Default Organization as a BOOT INVARIANT under the single posture", in the kernel that mounts `createIdentityObjectsPlugin()` without `AuthPlugin`; no published schema or export moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A kernel that mounts `createIdentityObjectsPlugin()` without `AuthPlugin` now holds its Default Organization under the `single` tenancy posture, so its seeds and system writes on tenant-scoped objects are stamped with it instead of refused with "the install holds no organization at all". + + - **Refused now:** a composition that mounts `createIdentityObjectsPlugin()` and publishes its driver after `bootstrap()` (the identity objects' `start()` reads `sys_organization` under `single`, so the boot fails with `No driver available for object 'sys_organization'`); and a kit that inserts its own organization row after boot, which now holds two organizations under `single`, so every system write that names no organization is refused as ambiguous (ADR-0131 D9). + - **Unchanged:** the walled postures (`group`, `isolated`), where `start()` creates nothing; the refusal at `init()` of `createIdentityObjectsPlugin()` mounted beside `AuthPlugin`; every composition that publishes its driver before boot; and a declaration-only boot (`os migrate`), which suppresses `start()`. + + - **Why.** ADR-0131 D3 makes the Default Organization a boot invariant under `single`: it owns every row. Registering `sys_organization` is what turns the engine's and the seed loader's zero-organization branch from "unstamped" into "refused" (D9). `IdentityObjectsPlugin` registers that object, but only `AuthPlugin.start()` created the organization, so the reduced kernel the plugin exists for (ObjectQL, `createIdentityObjectsPlugin()`, `SecurityPlugin`) refused every seed row of an organization-owned object. + - **What changed in `@objectstack/plugin-auth`.** `IdentityObjectsPlugin` gains a `start()` that, under `single`, finds or creates the Default Organization with the same invariant `AuthPlugin.start()` runs. It is idempotent, and a failure fails the boot. The posture is the `tenancy` service's when one is registered, otherwise the requested one (`OS_TENANCY_POSTURE`). Under `group` and `isolated` it does nothing. Nothing new is exported, and the plugin is still refused beside `AuthPlugin`. + - **What changed in `@objectstack/runtime`.** `AppPlugin` orders `com.objectstack.auth.identity-objects` ahead of itself, as it already ordered `com.objectstack.auth`, so the organization exists before the inline seed loads, wherever a host lists the two plugins. + - **If your test kit mounts `createIdentityObjectsPlugin()`.** Its kernel now reads and writes `sys_organization` during `bootstrap()` under `single`. Publish the driver before the boot (a `driver.default` service, or a datasource plugin), not with `registerDriver()` after it. Otherwise the boot fails with `No driver available for object 'sys_organization'`. A kit that inserted its own organization row to stand in for the missing one now holds two, and a system write that names no organization is refused as ambiguous; drop that insert and read the Default Organization (`slug: 'default'`) the boot created. +- 37510aa: feat(security): the guest's grants channel — an anonymous request at a guest-entry door holds the sets bound to the `guest` anchor, in the one organization the deployment resolves for it (ADR-0138 E1, D3/D4) + + ADR-0087 disposition: none. No authorable key, accept set or stored shape moves: no spec schema, metadata type, stored row or published response field is added, removed, renamed or re-shaped. A declared binding that was not enforced becomes enforced (ADR-0090 D9, as ADR-0138 D3 amends it). + + **The bindings that start granting.** On the reference apps, none: `app-showcase`, `app-crm`, `app-multi-package` and `app-todo` declare no definition of the `guest` anchor that names a set. `app-showcase` ships `showcase_guest_portal` as a capability an administrator may bind and binds it to no anchor (its `client_portal_user` position is an ordinary position), and `app-crm`'s `guest_portal` is the public form doors' set, which keeps its own posture. No reference app declares a permission set named `guest`, so no grant stops either. A set starts granting only where a deployment holds an environment definition of the `guest` position that names it. + + What changes: + + - **`@objectstack/core`** — the anonymous branch of `resolveAuthzContext` resolves the guest's grants on a face that takes the explicit guest entry (the new `guestEntry` input). It reads the sets the `guest` position's definition names, through the same position-bound read every position uses, and the activation ledger as for any held set. It resolves them only in the organization the guest entry's organization step derives (`resolveGuestOrganization`), which asks ADR-0131 D9's predicate and no second spelling of it. With nothing bound, the guest holds no set and no read is issued. The predicate itself, `resolveSystemWriteOrganization`, moves to `@objectstack/core` so both of its askers share it. New exports: `resolveGuestOrganization`, `resolveSystemWriteOrganization` (with `SystemWriteOrganizationDecision` and `SystemWriteRefusalReason`), and `GuestOrganizationUnresolvedError` with `isGuestOrganizationUnresolvedError` and its constants. + - **`@objectstack/objectql`** — `resolveSystemWriteOrganization` and its two types are re-exported from `@objectstack/core` as the same bindings. Nothing a caller imports changes. + - **`@objectstack/plugin-security`** — the set resolution treats the guest's sets as any requested set, and no longer folds the guest's position into the request. A permission set that merely shares the anchor's name no longer reaches an anonymous caller, and the baseline and `everyone` never did. At the CRUD gate, a guest that reaches a data operation acting in no organization, on a deployment where the organization step refuses, is refused before the operation runs. + - **`@objectstack/plugin-sharing`** — the read filter answers deny-all for the guest on every object that record sharing scopes, before the sharing-model branch: the guest reaches rows only through share links, never through the object's sharing model (ADR-0138 D3 item 6, as the maintainer's ruling C amends it). `externalSharingModel` is still not read for any caller. Share links are redeemed at their own door and are unchanged. + - **`@objectstack/runtime`** — the runtime face, which serves the guest, asks for the guest's grants. + + What an anonymous caller sees at an `authRequired: false` endpoint of `type: 'object_operation'`: + + - **One organization, a set bound to `guest`:** the operation runs as the guest, under that set, in that organization. A read the set grants is admitted and returns no row, whatever the object's sharing model (`private`, `public_read`, `public_read_write` or `controlled_by_parent`): the guest reaches a record only through a share link. A create the set grants lands in that organization with no owner. + - **Nothing bound:** `403 PERMISSION_DENIED`, as before. + - **A walled deployment, several organizations, or none:** `501 NOT_IMPLEMENTED`, naming the missing site binding (ADR-0138 D5), where it used to be `403 PERMISSION_DENIED` with nothing granted. Nothing that was served stops being served. The operator's log names the reason once. + + Unchanged: share links, which still serve an anonymous caller the one record they name, with `publicSharing.eligibility` checked at the mint and at every redemption; the public form doors, which keep their own posture and organization; the flow door, which runs under the flow's own declared posture; every surface that answers an anonymous request `401`; and every signed-in caller. + + Level: `minor` for `@objectstack/core`, because new exported symbols widen its published index; `patch` for the other four, whose exports do not move. +- 28e294f: fix(plugin-sharing, runtime): a share link that has a password and is limited to signed-in users answers an anonymous visitor `401 SIGN_IN_REQUIRED`, whatever password was sent + + Clause-②: no — every request refused before is still refused; only which refusal code an anonymous caller reads changes. + + A share link can carry a password and the `signed_in` audience together. For such a link, the share-link resolve door answered an anonymous visitor from the password before the audience: `NEEDS_PASSWORD` with no password, and `WRONG_PASSWORD` with any password, the correct one included. The console's shared-record page then told a visitor who typed the right password "Wrong password.", and never offered sign-in. + + - **Anonymous caller, `signed_in` link:** `401 SIGN_IN_REQUIRED` with no password, a wrong one or the correct one. The answer is the same for all three, so it never tells an anonymous caller whether a password is right. Once signed in, the visitor is asked for the password as before. + - **Signed-in caller:** unchanged. `401 NEEDS_PASSWORD` with no password, `401 WRONG_PASSWORD` with a wrong one, and the record with the correct one. + - **Unchanged:** links with any other audience (a `public` link with a password still answers `NEEDS_PASSWORD` / `WRONG_PASSWORD` to an anonymous caller), the expired-or-revoked `410 EXPIRED_OR_REVOKED`, the `404 INVALID_OR_EXPIRED` for an unknown token or an object with public sharing switched off, and every link that resolves. + - Both mounts of the resolve door answer alike: plugin-sharing's share-link routes and the runtime dispatcher's share-links domain. +- 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. +- 9fcea20: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + fix(runtime): `callData`'s exposure refusal for the object a request addresses carries the data door's code, `OBJECT_API_DISABLED` (404) or `OBJECT_API_METHOD_NOT_ALLOWED` (405), on the dispatcher's `/data` routes, the `/mcp` data tools and declared endpoints (#23528) + + Clause-②: no + + **What was wrong.** An object can declare `apiEnabled: false` or an `apiMethods` whitelist. The runtime dispatcher's exposure check for the object a request addresses (`refuseUnexposedDataAction`, which every `callData` asks first) refused with a status and a message but no `code`. So: + + - the dispatcher's `/data` routes derived a code from the status: `404 RESOURCE_NOT_FOUND` / `405 METHOD_NOT_ALLOWED`; + - the `/mcp` data tools rendered the message alone; + - a declared endpoint whose target is an object answered the derived code too. + + `RestServer` and the MCP stdio tools answer the same declaration `OBJECT_API_DISABLED` / `OBJECT_API_METHOD_NOT_ALLOWED`, and so did the dispatcher's own refusal of a nested-relation filter on an unexposed object. + + **What a caller reads now.** Every one of these doors answers the data door's code with the same status as before: + + | door | declaration | FROM | TO | + |:--|:--|:--|:--| + | dispatcher `/data` (and declared endpoints) | `apiEnabled: false` | `404`, `error.code` `RESOURCE_NOT_FOUND` | `404`, `error.code` `OBJECT_API_DISABLED` | + | dispatcher `/data` (and declared endpoints) | verb outside `apiMethods` | `405`, `error.code` `METHOD_NOT_ALLOWED` | `405`, `error.code` `OBJECT_API_METHOD_NOT_ALLOWED` | + | `/mcp` data tools | either | `isError: true`; the text is the message | `isError: true`; the text is `{ "error": { "code", "message", "status" } }` | + + The status and the message do not change. The thrown value is now an `Error` carrying `code`, `status`, the addressed object as `object`, and the effective operation set as `allowed` on a 405. A caller that branched on `RESOURCE_NOT_FOUND` / `METHOD_NOT_ALLOWED` for an exposure refusal on these doors should branch on the two `OBJECT_API_*` codes, as it already does for `RestServer`. +- ee0e115: docs: the default-record-page remedies name the other package's page, not the whole package + + Clause-②: no + + A second `isDefault: true` record page for one object is refused with `DEFAULT_RECORD_PAGE_CONFLICT`. The package registry's refusal already told the author to keep one default or "compose the deployment without the other package's page". Three sibling texts still told them to drop the whole package, though a deployment can keep a package and leave out the one page it ships: + + - `@objectstack/metadata-protocol`: the metadata save door's refusal now reads "a page a package ships stays the default unless the deployment is composed without that package's page." Only the remedy clause changed; `code`, `status`, `object` and `holders` are unchanged. + - `@objectstack/spec`: `PageSchema.isDefault`'s TSDoc and the error-code ledger's comment on `DEFAULT_RECORD_PAGE_CONFLICT` say the same. + - `@objectstack/runtime`: `AppPlugin`'s private `bundle` field now documents the three readers outside the class that read it structurally, so a rename is not made blind. + + No behaviour changes; nothing to migrate. +- 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 +- 7428da0: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + refactor(plugin-hono-server,runtime): the Hono doors and the HTTP dispatcher read the duplicate-record wire code off the shared error classification instead of spelling it themselves (#23714) + + **No behaviour change.** A repeated unique value still answers `409 UNIQUE_VIOLATION` at both Hono doors (a `HonoHttpServer` route or raw mount, and `createHonoApp`'s catch arms through `declaredEnvelopeForThrow`) and at the dispatcher's doors (`HttpDispatcher`'s thrown-error envelope), with the same `message`, the same `details` (`developerMessage`, `object`, and `field` when the driver named the column) and the same envelope keys. The answers were compared byte for byte at those doors before and after. A client or an app author has nothing to change. + + **What changed.** Both translations used to spell the wire code as a string literal. They now read it off the classification the REST server answers with (`structuredCodeAnswer` in `@objectstack/types`), as `@objectstack/mcp` already does. So the code has one source, and neither package stamps a code that the error-code ledger does not list under it. The engine's in-process code is unchanged: `DuplicateRecordError.code` is still `DUPLICATE_RECORD`. + + 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 +- b55be42: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + fix(runtime): a sandboxed body that throws a value no read can flatten (`throw ''`, a null-prototype object, an object whose `message` getter throws) now ends its run at once with the sentence `the script threw a value that could not be read`, instead of waiting for the wall-clock ceiling (#23723) + + **Latency and answer change, for these values only.** The QuickJS runner's reject handler records a body's rejection as a flattened string, and the run ends only once that string is non-empty. For these values the read threw or gave `''`, so nothing was recorded: the run waited for the sandbox's wall-clock ceiling (30 s by default) and ended as a budget error with no business message, which the doors answer as a crash. The handler now records the fixed sentence above for any value it cannot read, and the run ends on the same path as any other rejection. The sentence names nothing the value carried and no stack. + + Measured at `POST /api/v1/actions/:object/:action` on a `type: 'script'` action: + + - `throw { code: 'EMAIL_TAKEN', get message() { throw new Error('boom') } }` + - FROM, after the 30 s ceiling: `500 {"success":false,"error":{"code":"INTERNAL_ERROR","message":"Internal server error","httpStatus":500}}` + - TO, in about 0.1 s: `400 {"success":false,"error":{"code":"VALIDATION_ERROR","message":"the script threw a value that could not be read","httpStatus":400,"declaredCode":"EMAIL_TAKEN"}}` + - `throw ''` and `throw Object.create(null)` + - FROM, after the 30 s ceiling: the same `500 INTERNAL_ERROR` + - TO, in about 0.1 s: `400 {"success":false,"error":{"code":"VALIDATION_ERROR","message":"the script threw a value that could not be read","httpStatus":400}}` + + The status and envelope are the ones the door already gives a body that threw; no door changed. A `code` the value carries is kept, as for any other thrown value. Two more values that could not be read move the same way: an `Error` whose `name` and `message` are both empty, and an `Error` whose `message` getter throws. To give the caller words, throw a readable `message` (or an `Error`). + + Control, unchanged byte for byte: every value the handler could already read (a thrown `Error` or subclass, a plain `{ code, message }` object and the other plain-object shapes, primitives, the engine's relayed duplicate refusal, a nested hook refusal's sentence, the `code` / `fields` / `status` / `userMessage` fields, and a capability denial). A body that never settles still ends at the wall-clock ceiling. + + In-process: the `SandboxError` a caller catches for such a throw now reads `KIND 'NAME' threw: Error: the script threw a value that could not be read`, with that sentence as `innerMessage`, instead of the wall-clock budget error. + + Clause-②: no +- f7a5ebe: fix(runtime,cli): `os start` and `os dev` refuse a SQLite database whose directory cannot be made, at once, instead of hanging + + Clause-②: no + + **What was wrong.** The standalone host (`createStandaloneStack`, which `os start`, `os dev`'s standalone boot and every embedder run) prepared the directory of a file-backed SQLite database with Node's recursive `mkdirSync`. Node's recursive mkdir never returns when a level answers `ENOENT` although its parent exists: for `OS_DATABASE_URL=file:/proc/os-x/ctl.db` it looped `mkdir("/proc/os-x")` and `mkdir("/proc")` without end, holding the event loop. `os start` printed `→ Starting server...` and hung with no timeout and no message. The `sqlite-wasm` arm (`wasm-sqlite://…`, or `OS_DATABASE_DRIVER=sqlite-wasm`) did the same. `os dev` hung the same way in its own process when its default database lived under a state directory that cannot be made (for example `OS_HOME=/proc/os-x`). + + **What an operator sees now.** The boot exits non-zero within its normal start-up time, naming the database file, the directory and the operating system's error: + + ```text + ✗ [StandaloneStack] SQLite database file '/proc/os-x/ctl.db' cannot be created: its directory '/proc/os-x' could not be made: ENOENT: no such file or directory, mkdir '/proc/os-x' + ``` + + `os dev` with an unusable state directory prints `✗ The default database '…/data/objectstack.db' cannot be created: its directory '…/data' could not be made: …`, followed by the remedy: point `OS_HOME` at a directory that can be created, or choose the database with `--database`. + + Nothing else changes. A creatable path is still made, every missing level of it. The read-only boot (`os migrate plan` over an absent database file) still creates no directory. The refusal names the path, never the database URL. +- 9d4fb3c: fix(runtime): the standalone host's database-URL refusals name the URL's scheme and never print the URL, so a password or token in `OS_DATABASE_URL` no longer reaches the console or the boot log + + Clause-②: no + + The standalone host is what `os dev`, `os start` and `os serve` boot over a plain config, and what `os migrate` boots. Its URL refusals interpolated the whole database URL, and the CLI prints that message as it exits. A URL is where postgres / mysql / mongodb credentials ride, so whenever such a URL was refused, the password and any token in its query string were printed. Each refusal now names only the scheme (the letters before `://`), which cannot hold a username, a password, a host, a path or a query. + + - **An unsupported scheme.** With `OS_DATABASE_URL=redis://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN`: + - FROM: `[StandaloneStack] Unsupported database URL scheme: redis://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN. Supported schemes: …` + - TO: `[StandaloneStack] Unsupported database URL scheme "redis://". Supported schemes: …`. The list of supported schemes is unchanged. `os migrate … --json` carries the same text in its `error` field. + - **A `memory://` or `mingo://` URL (the retired in-memory engine).** + - FROM: `The database URL "memory://…"` (or `OS_DATABASE_URL "memory://…"`) followed by the full URL. + - TO: `A database URL with the "memory://" scheme` (or `An OS_DATABASE_URL with the "memory://" scheme`). A declared default datasource reads `(driver: memory, URL scheme "memory://")`. The rest of the sentence, which names the SQLite replacements, is unchanged. + - **An explicit `sqlite` driver over a URL that is not a file path** (for example `OS_DATABASE_DRIVER=sqlite` beside a `postgres://…` URL). + - FROM: `sqlite driver was selected but the URL does not look like a file path: "postgres://PLACEHOLDER-USER:PLACEHOLDER-SECRET@…"` + - TO: `sqlite driver was selected but the URL does not look like a file path (its scheme is "postgres://")`. A bare `file:` now reads `(it names no file after "file:")`. + + What does not change: which URLs resolve, and to which driver; the URL handed to the driver, which keeps its credentials; and where each refusal is raised, with the same error class. Each message keeps the phrase that identifies it: `Unsupported database URL scheme`, `selects the in-memory (mingo) engine`, `does not look like a file path`. +- c5cf546: fix(runtime): the standalone host refuses an `OS_DATABASE_URL` with an opaque scheme (`jdbc:postgresql://…`, `redis:h`), and a `.wasm.db` ending no longer turns an unrecognised scheme into a SQLite file + + Clause-②: no + + This is the runtime's standalone host: `os dev`, `os start` and `os serve` over a plain config or an artifact, every `os migrate` subcommand, and every embedder of `createStandaloneStack`. + + - **Before, an opaque scheme (two or more characters and a `:` with no `//`).** `OS_DATABASE_URL=jdbc:postgresql://127.0.0.1:5999/db os serve --dev` printed `✓ Server is ready` and opened a SQLite file at a path made of the URL, creating the directories `./jdbc:postgresql:/127.0.0.1:5999/`. An operator who believed they were on Postgres wrote to that local file. This broke the host's own rule that an unknown URL scheme is refused and never falls back to SQLite. + - **Before, a `.wasm.db` ending.** It was tested ahead of every scheme check, so `OS_DATABASE_URL=redis://h/x.wasm.db` booted on a WASM SQLite file written under `./redis:/h/`. + - **After.** Both shapes get the refusal an unrecognised `://` already got, unchanged: `[StandaloneStack] Unsupported database URL scheme "jdbc:". Supported schemes: …`. The boot stops before any plugin starts and creates nothing on disk. The message names only the scheme, never the URL, so credentials in the URL are not printed. A `*.wasm.db` name still selects the WASM engine for a `file:` URL or a path with no scheme. The CLI's legacy `os serve` path already refuses these values, so the two hosts now give the same answer. + - **A one-letter drive is a path, not a scheme** (`C:\data\app.db`, `C:/data/app.db`). A file name whose first segment contains a colon now reads as a scheme, as it does on the CLI path: `backup:2026.db` was a SQLite file and is now refused. Write `./backup:2026.db` or `file:backup:2026.db` for that file. + + `postgres://`, `pg://`, `mysql://`, `mongodb://`, `libsql://`, `wasm-sqlite://`, `file:`, `sqlite:`, `:memory:` and paths with no scheme resolve as before. An explicit `OS_DATABASE_DRIVER` still wins over the URL, which is then not classified. +- 2e15620: fix(cli,runtime): a state, output or cache directory that can never be made is refused at once, naming the path and the operating system's code, instead of hanging + + Clause-②: no + + **What was wrong.** These commands and runtime paths created their directories with Node's recursive `mkdir`. Node's recursive mkdir never returns when a level answers `ENOENT` although its parent exists — what `/proc` and `/sys` answer for a name they do not have. It looped `mkdir("/proc/x")` and `mkdir("/proc")` without end, holding the event loop, so the command printed nothing more and no timeout could fire: + + - `os start` with `--home` / `OS_HOME` on such a path hung after its header. + - `os serve` hung the same way on the empty-boot artifact stub under `OS_HOME`, and on the cache of an `http(s):` `OS_ARTIFACT_URL` under `OS_HOME`. + - `os create --dir`, `os init `, `os compile --output`, `os generate` (`--dir`, and `--output` for types, client and migration), `os i18n extract --out` and `os plugin build --out` hung on an output path there. + - `os login` and `os cloud login` never finished writing credentials under a `HOME` there, and the package enable / disable state the packages routes write under `OS_HOME` held the event loop of the host serving them. + + **What an operator sees now.** The command exits non-zero at once with the operating system's own error, which names the level that cannot be made: + + ```text + ✗ Cannot create home directory at /proc/os-x: ENOENT: no such file or directory, mkdir '/proc/os-x' + ``` + + Not covered yet: the runtime state file `os serve` writes into `OS_HOME` once it is listening. + + Nothing else changes. Every directory these paths made before is still made, every missing level of it; every path that was refused before is still refused, with the operating system's code. The one difference is the path that was never answered at all. +- 0cb45e9: fix(runtime): an explicit `sqlite` or `sqlite-wasm` driver selection refuses a database URL that is not a file path, instead of opening a SQLite file at a path made of the URL + + Clause-②: no + + An explicit selection (`OS_DATABASE_DRIVER`, `--database-driver`, or the `databaseDriver` option) skips the URL classification, so the only check its URL meets is the SQLite file-name rule, `sqliteFilenameFromUrl`. Both hosts call that rule: the runtime's standalone host (`os dev`, `os start` and `os serve` over a plain config or an artifact, every `os migrate` subcommand, every embedder of `createStandaloneStack`) and the CLI's legacy `os serve` path, which re-raises its refusal as the fatal `UnsupportedDriverError`. + + - **Before, an opaque scheme (two or more characters and a `:` with no `//`).** `OS_DATABASE_DRIVER=sqlite OS_DATABASE_URL=jdbc:postgresql://127.0.0.1:5999/db os serve --dev` printed `✓ Server is ready` and created the directories `./jdbc:postgresql:/127.0.0.1:5999/`. `redis:h` opened a file named `redis:h`. This happened under both kinds. + - **Before, a server URL under `sqlite-wasm`.** The WASM kind refused nothing: `OS_DATABASE_DRIVER=sqlite-wasm` beside `OS_DATABASE_URL=postgres://USER:PASSWORD@127.0.0.1:5999/db` booted ready and created `./postgres:/USER:PASSWORD@127.0.0.1:5999/`, which put the credentials in a directory name. `redis://h/x.db` opened a file too. + - **After.** Under either kind, all of these get the refusal the `sqlite` kind already gave a `postgres://` URL. The kind is named in the sentence: `[StandaloneStack] sqlite-wasm driver was selected but the URL does not look like a file path (its scheme is "postgres://"). Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.` The message names the scheme only, never the URL, so credentials in the URL are not printed. The boot stops before it creates anything on disk. + - **One file name changes answer.** Under an explicit selection a first path segment that holds a colon now reads as a scheme, as it already does when the URL selects the driver: `backup:2026.db` was a SQLite file and is now refused as the scheme `backup:`. Write `./backup:2026.db` or `file:backup:2026.db` for that file. A one-letter drive is a path, not a scheme (`C:\data\app.db`, `C:/data/app.db`). + - **A `sqlite:` alias on a rung that does not normalize it.** `sqlite:` and `sqlite://` are aliases of `file:` in `OS_DATABASE_URL`, `--database` and the `databaseUrl` option. A declared default datasource's `url` and `TURSO_DATABASE_URL` reach the rule as written. There, under an explicit selection, `sqlite:./x.db` is now refused as the scheme `sqlite:`, where it used to open a file named `sqlite:./x.db`. `sqlite:///…` was already refused there under `sqlite` and is now refused under `sqlite-wasm` as well. + + `file:./x.db`, `./x.db`, `:memory:`, `wasm-sqlite:///path` (under `sqlite-wasm`), `sqlite:./x.db` in `OS_DATABASE_URL`, and paths with no scheme resolve to the same file as before. An empty name under `sqlite-wasm` is still `:memory:`. +- 605aa01: fix(runtime): the SQLite "does not look like a file path" refusal names what the operator can change, instead of telling an operator who set `OS_DATABASE_DRIVER` to set it explicitly + + Clause-②: no + + `sqliteFilenameFromUrl` raises this refusal, and both hosts print it: the runtime's standalone host with its `[StandaloneStack]` tag, and the CLI's legacy `os serve` path, untagged, as the fatal `UnsupportedDriverError`. The first clause is unchanged and only the advice after it changes. The same URLs are refused, with the same error class and the same exit, and the message still names the URL's scheme and never the URL. Nothing that resolved before resolves differently. + + - **A URL with a scheme.** This is how an explicit `sqlite` or `sqlite-wasm` selection (`OS_DATABASE_DRIVER`, `--database-driver`, or the `databaseDriver` option) meets a server URL, an opaque scheme such as `jdbc:`, or a `://` nothing recognises. + - FROM: `… (its scheme is "jdbc:"). Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.` The operator who reads this has already set the driver explicitly: that selection is what led here. + - TO: `… (its scheme is "jdbc:"). Use file:/path/to/db.sqlite to keep the sqlite driver, or use a driver that matches the URL: unset the driver selection (OS_DATABASE_DRIVER, --database-driver or the databaseDriver option) so the URL selects it, or set the selection to the URL's driver.` With the selection unset, a scheme no driver claims, such as `jdbc:`, gets the unsupported-scheme refusal, which lists the supported schemes. + - **A `file:` that names no file** (`OS_DATABASE_URL=file:`, whether the URL or an explicit `sqlite` selection chose the driver). + - FROM: `… (it names no file after "file:"). Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.` + - TO: `… (it names no file after "file:"). Name the file after "file:", as in file:/path/to/db.sqlite.` A `file:` URL is a SQLite URL whichever way the driver was chosen, so changing the driver never fixed it. +- 9e797a8: fix(runtime): the standalone server hands every `/api/v1/keys` verb to the `keys` domain, so an anonymous caller is answered `401 UNAUTHENTICATED` whatever the verb + + Clause-②: no + + The v18 text for this door reads "`/keys`: `401 UNAUTHENTICATED` for every verb". The `keys` domain takes that decision first, but the standalone server (the one `os dev` and `os serve` run) mounted `/api/v1/keys` for `POST` only. Any other verb never reached the domain: the HTTP adapter answered it itself, with a `405` that named `POST` as the allowed method. + + What a `GET`, `PUT`, `PATCH` or `DELETE` call to `/api/v1/keys` is answered on the standalone server: + + - **Anonymous caller.** Before: `405 METHOD_NOT_ALLOWED` with `Allow: POST`, from the adapter. Now: `401 UNAUTHENTICATED` in the dispatcher's standard envelope, `{ success: false, error: { code: 'UNAUTHENTICATED', message, httpStatus: 401 } }`. + - **Signed-in caller.** Before: `405 METHOD_NOT_ALLOWED` with `Allow: POST`, from the adapter. Now: still `405 METHOD_NOT_ALLOWED`, answered by the `keys` domain in the same standard envelope (`error.httpStatus: 405`, no `error.details`), and without an `Allow` header. + + `POST /api/v1/keys` is unchanged, and so is every answer on a host that reaches the dispatcher through `@objectstack/hono`'s catch-all, which already answered this way. A `HEAD` request follows `GET`: `401` anonymous, `405` signed in. A sub-path such as `/api/v1/keys/` is still not mounted on the standalone server and is answered `404`. +- 66d2757: feat(spec)!: every installed-package row declares where it came from, a closed `source: 'code' | 'marketplace' | 'authored'`, and the `GET /packages` rows declare the `writable` verdict they already served (#23972) + + Clause-②: yes (narrowing — the served row gains two declared keys, and the record schema, the served-row union and the registry's installPackage signature each refuse what they accepted before) + + **BREAKING** — three published accept sets narrow. `InstalledPackageSchema` refuses a row without `source`, or with a value outside `code` / `marketplace` / `authored`. The served-row union `InstalledPackageAtEitherStageSchema` refuses a row without a boolean `writable`. `SchemaRegistry.installPackage` and `EngineSchemaRegistryView.installPackage` take `source` as their second argument and refuse a call without it. The migration is below. None of it is mechanically convertible, because only the door that registers a package knows where it came from. + + **What is new on the wire.** `GET /api/v1/packages` and `GET /api/v1/packages/:id` serve two declared keys on every row: + + - `source` names the package's origin. `code` means the deployment's own code registered it (a `defineStack()` host, a plugin's bundled manifest, a platform package). `marketplace` means the marketplace install-local plugin installed it, and that holds after a restart too, when the plugin rehydrates it from its ledger. `authored` means it is a database-authored package (a Studio base, a duplicate, an HTTP install), including after the restart that rehydrates it from `sys_packages`. The door that registers the package stamps the value. No reader derives it. + - `writable` is the server's own writability verdict (ADR-0070 D2), served on these rows since #14375 and now declared. It is computed per read and never stored, so it is declared on the served row (`InstalledPackageAtEitherStageSchema`) and not on the registry record. + + FROM → TO for a reader of these rows: + + | you did | do instead | + | --- | --- | + | treated `writable: true` as authored, and looked up each non-writable row in the marketplace's installed list (the control plane's, else install-local's) to tell marketplace from code | read `row.source` | + | read the undeclared `writable` | read the declared `writable` (unchanged value) | + + The two keys are separate statements: `source` says where the package came from, and `writable` says what this server lets an org do to it. + + **`InstalledPackageSchema` requires `source`.** It is `InstalledPackageSourceSchema`, exported from `@objectstack/spec/kernel`. A row without it, or with a value outside the closed set, is refused at the `source` key. + + **API change for code that registers packages (`@objectstack/objectql`).** The registry records the origin and never infers it: + + | you wrote | write instead | + | --- | --- | + | `registry.installPackage(manifest)` | `registry.installPackage(manifest, 'code')` (or `'marketplace'` / `'authored'`, whichever is where the package came from) | + | `registry.installPackage(manifest, settings, scope)` | `registry.installPackage(manifest, source, settings, scope)` | + | `ctx.getService('manifest').register(artifact)` for a marketplace install | `ctx.getService('manifest').register(artifact, { source: 'marketplace' })` | + + `SchemaRegistry.installPackage` throws a `TypeError` naming the closed set when `source` is missing or unknown, before anything is recorded. `ObjectQL.registerApp(body, scope?, source?)` and the `manifest` service record `'code'` when no source is given. They accept `'code'` or `'marketplace'` and refuse `'authored'`. The `IObjectQLEngine` registry contract (`@objectstack/spec/contracts`) declares the same `installPackage(manifest, source, settings?)`. + + **The one-line fix:** pass the package's origin as `installPackage`'s second argument. + + Readers: objectui's Setup → Packages Source column and its `?source=` filter (it switches from the two-read join to `source` in its own follow-up), and the client SDK's `packages.list()` / `packages.get()` row types. + + +- 6e43b7b: fix(runtime): the `keys` and `actions` dispatcher domains answer their `405 METHOD_NOT_ALLOWED` with an `Allow: POST` header + + Clause-②: no + + RFC 9110 requires a `405` to carry an `Allow` header naming the methods the target resource supports. The `mcp`, `meta` and `ai` dispatcher domains already send one. The `keys` and `actions` domains answered their `405` without it, so a client could see that its verb was refused but not which verb the route takes. + + Both domains serve one method, `POST`, on every path they claim, and their `405` now carries `Allow: POST`: + + - **`/api/v1/keys`.** A signed-in `GET`, `PUT`, `PATCH` or `DELETE` is answered `405` with `Allow: POST`, on the standalone server (`os dev`, `os serve`) and on a host built with `@objectstack/hono`'s `createHonoApp`. + - **`/api/v1/actions/...`.** A signed-in non-`POST` request that reaches the `actions` domain is answered `405` with `Allow: POST`. That is every such request on a `createHonoApp` host. The standalone server mounts these paths for `POST` only, so its HTTP adapter answers other verbs itself, and that answer already carried `Allow: POST`. + + The status, the body and its `error.code` are unchanged: `{ success: false, error: { code: 'METHOD_NOT_ALLOWED', message: 'Method not allowed', httpStatus: 405 } }`. Each domain still decides an anonymous caller before it looks at the method, so an anonymous `GET` (or any other verb the anonymous decision refuses) that reaches either domain is answered `401 UNAUTHENTICATED`, with no `Allow` header. +- 6dede7a: fix(runtime, plugin-sharing): on the standalone server, an anonymous caller of any `/api/v1/notifications` verb or any `/api/v1/share-links` path off the two token faces is answered `401 UNAUTHENTICATED` in the dispatcher's standard envelope + + Clause-②: no + + The v18 text for these two doors reads "`/notifications`: `401 UNAUTHENTICATED` on every face" and "`/share-links`: `401 UNAUTHENTICATED` on the create, list and revoke faces and on every path the domain does not serve", in "the dispatcher's standard envelope … with the shared message every other dispatcher domain answers with". A host that reaches the dispatcher through `@objectstack/hono`'s catch-all already answered that way. The standalone server (the one `os dev` and `os serve` run) did not: + + - `/api/v1/notifications`, `/api/v1/notifications/read` and `/api/v1/notifications/read/all` were each mounted for the one verb they serve, so any other verb never reached the `notifications` domain and the HTTP adapter answered `405`, naming the allowed methods, to an anonymous caller as well. + - `/api/v1/share-links` is served there by `@objectstack/plugin-sharing`'s routes, not by the dispatcher. They registered five routes and nothing else, so another verb or path got the adapter's `405` or `404`, and the create, list and revoke faces answered `401` with a sentence of their own ("Sign in to list share links") and no `error.httpStatus`. + + What the standalone server answers now: + + - **Anonymous caller.** `401 UNAUTHENTICATED`, `{ success: false, error: { code: 'UNAUTHENTICATED', message: 'Authentication is required to access this endpoint.', httpStatus: 401 } }`, on every verb of the three notifications paths, and on every verb and path under `/api/v1/share-links` except `GET /share-links/:token/resolve` and `GET /share-links/:token/messages`, which the capability token still admits. A body the server cannot parse does not outrank this answer anywhere under `/api/v1/share-links`, nor on the notifications verbs no face serves. + - **Signed-in caller of a verb or path that is not served.** Still refused. On the notifications paths the domain answers it: `404 ROUTE_NOT_FOUND` from the dispatcher (or the `501` capability answer on a deployment with no inbox), where the adapter used to answer `405`. Under `/api/v1/share-links` the share-link routes answer it themselves: `404 ENDPOINT_NOT_FOUND`, naming the request, where the adapter used to answer `405` or `404`. No `Allow` header is sent on either. + + Every request that was served is served as before. A notifications sub-path no face names, such as `/api/v1/notifications/`, is still not mounted on the standalone server and is answered `404`. +- 727091c: fix(runtime): every PUT and PATCH route the dispatcher mounts answers with an `X-Request-Id` and reports its 5xx to the error reporter, as its GET, POST and DELETE routes always did + + Clause-②: no + + The dispatcher wraps each route it mounts in its observability instrumentation. That wrapper echoes a valid inbound `X-Request-Id` (and mints a `req_`-prefixed hex id otherwise), sets `req.requestId`, hands a 5xx to the configured `observability.errorReporter`, and gives the `[5xx]` log line its method, route and request id. It wrapped routes registered through `get`, `post` and `delete` only, so every route mounted through `put` or `patch` answered without any of that: `PUT` and `PATCH /api/v1/keys`, the `PUT` and `PATCH` mounts of the three `/notifications` paths, `PATCH /packages/:id` (and its `/enable` and `/disable`), `PUT /automation/:name`, the `PUT` and `PATCH /ai/*` wildcards, and PATCH routes a service emits on `ai:routes`. It now wraps all five verbs. + + - **On the wire:** a `PUT` or `PATCH` answer from these routes now carries the `X-Request-Id` header (or the header `observability.requestIdHeader` names), as "Debugging with Request IDs" in the error-handling guide describes. + - **For an operator:** a 5xx on one of these routes now reaches `observability.errorReporter`, and its `[5xx]` log line now carries the method, route and request id. + - **Metrics:** on the standalone Hono server nothing changes. The transport already counted and timed these requests through `afterResponse`, and it still does, once per request. On a transport without `afterResponse`, `PUT` and `PATCH` requests on these routes are now counted in `http_requests_total` and timed in `http_request_duration_ms`, as the other three verbs were. + + What changes for you: nothing to rewrite. +- eda6f14: feat(spec)!: an install through `POST /api/v1/packages` may declare `manifest.scope` `project` only; `cloud` and `system` are refused, so an authored install can no longer lock itself + + `cloud` and `system` are the scopes of packages the platform ships, and the ADR-0070 D2 read-only rule (`isWritablePackage`) judges a registered package by its stored `manifest.scope`. An install through `POST /api/v1/packages` authors the package, yet the install declaration admitted all three scopes. So a manifest that copied `scope: 'cloud'` or `'system'` from a shipped package answered `201` and produced an authored package that `DELETE /packages/:id`, `PATCH /packages/:id/disable` and `POST /packages?overwrite=true` then refused with `422 WRITABLE_PACKAGE_REQUIRED`, and the stored row came back on every restart. The install declaration now narrows the install manifest's `scope` to `project` (new export `PackageInstallManifestSchema`, `ManifestSchema` with that one key narrowed), on both body forms of `PackageInstallBodySchema` and on `PackageInstallRequestSchema.manifest`. `ManifestSchema` itself is unchanged: platform packages still declare all three scopes, and they reach the registry by code boot, not over this door. The door already parses its body through that declaration, so it answers the refusal with no rule of its own; its bare-form refusal sentence now re-reads the union's own bare branch, so it carries the prescription too. + + Clause-②: no (narrowing) + + + + **BREAKING** (a published request declaration's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes. + + - **FROM:** `POST /api/v1/packages` with a manifest declaring `scope: 'cloud'` or `scope: 'system'`, in the wrapped body `{ manifest }` or as a bare manifest, with or without `overwrite`, answered `201`; `PackageInstallBodySchema` and `PackageInstallRequestSchema` parsed it green. + - **TO:** the same request answers `400` with `error.code: 'VALIDATION_ERROR'`, names `scope` and the value sent, and installs nothing; both schemas refuse it with an `invalid_value` issue at `manifest.scope` (wrapped) or `scope` (bare). + - **Unchanged:** a manifest with `scope: 'project'` or no `scope` installs as before (`201`), and the door still stores the manifest as sent, so an absent `scope` stays absent. `ManifestSchema` and every other producer of it keep `cloud` / `system`. + - **The fix for an integrator:** remove `scope` from the manifest you install (it defaults to `project`), or write `scope: 'project'`. + - **A package that already locked itself this way** (an authored `sys_packages` row whose manifest declares `cloud` or `system`) stays read-only after this change, because the read-only rule reads the stored scope. No API door removes it; an operator deletes its row from the environment's database (`DELETE FROM sys_packages WHERE id = 'PACKAGE_ID'`) and restarts the server. +- d702b18: fix(cli,runtime): a dangling symlink standing as the SQLite database's directory is refused at once by `os dev` and `os serve`, naming the database file and the directory, instead of failing the boot later in a plugin's start + + Clause-②: no + + **What was wrong.** `os dev` (for its default database under `OS_HOME/data`) and the standalone host behind `os serve` and `os start` (for a `file:` or `wasm-sqlite://` database URL) make the database file's directory before the driver opens it. Both accepted any `EEXIST` answer while making a missing level. A symbolic link that points nowhere answers `EEXIST` to `mkdir`, so it was taken for a directory, and the boot went on until the driver could not open the file. `os dev` then exited 1 with `Plugin com.objectstack.auth failed to start - rollback complete: The database refused to run this query for object 'sys_organization'`, and `os serve` with `Plugin package-service failed to start - rollback complete: The database refused to run a raw statement`. Neither message named the file, the directory or the link. + + **What an operator sees now.** Both make the directory with the same bounded walk as the other `OS_HOME` paths, which accepts `EEXIST` only when a directory stands there. A dangling link as the directory is refused before the boot starts: `os dev` prints `The default database '…/data/objectstack.db' cannot be created: its directory '…/data' could not be made: EEXIST: …` and exits 1, and `os serve` prints `[StandaloneStack] SQLite database file '…' cannot be created: its directory '…' could not be made: EEXIST: …` and exits 1. Remove the link, or point it at a directory that exists. A dangling link higher up the path was already refused at once, with `ENOENT` at the level below the link; it is now refused with `EEXIST` at the link itself. + + Nothing else changes. A directory that can be made is still made, every missing level of it; a directory that exists, or a symbolic link to one, is used as it stands; and a path the operating system refuses for any other reason (`ENOENT`, `ENOTDIR`, a regular file holding the directory's name) is refused with the same code and text as before. +- 5a9a80b: fix(runtime): an OAuth MCP agent acts in the organization the user's consent bound, so the user's organization-scoped grants count for it + + Clause-②: no + + - **What was wrong.** On the `/mcp` door, `resolveExecutionContext` built a verified OAuth access token's principal with no organization. The agent's delegator (the user it acts for, ADR-0090 D10) was then resolved with no active organization and held only its global grants. Every grant an administrator assigns is scoped to an organization, so an ordinary user's agent was refused every object operation: `on-behalf-of principal may not 'find' … the delegator lacks that grant (ADR-0090 D10 intersection)`. + - **What changed.** The principal is resolved in the organization the consent bound: the `organizationId` that `IAuthService.verifyMcpAccessToken` returns from the token's `organization_id` claim. It is handed to the shared resolver where a session carries its active organization, so it is vetted on every request by the rule a browser session's organization gets (`vetOrganizationClaim`, `@objectstack/core`). The user's grants scoped to that organization count for the agent; grants scoped to the user's other organizations do not. It is never the union of the user's organizations, never the organization the user's browser session has selected now, and never a widened global grant. + - **A membership that is gone.** Under a walled tenancy posture (`isolated`, `group`), a token whose organization the user no longer belongs to resolves with no organization, so only global grants apply. Under `isolated`, a former member's agent gets an empty `200` read and a refused tenant write. The same holds for an organization the user never belonged to, whatever put it on the token. Under `single`, or with no `tenancy` service, the organization stands as claimed, exactly as a browser session's does there. + - **Unchanged.** A token with no `organization_id` (minted before consents bound one, or by a user with no active organization at consent) resolves with no organization, as before: global grants only, until the user re-confirms the consent once. A platform admin's global grant applies as before. The API-key and session tracks are untouched. +- 8eccf51: fix(runtime): on the standalone server, an anonymous request whose JSON body cannot be parsed gets the domain's `401`, not the transport's `400` + + Clause-②: no + + On the standalone server (`os dev`, `os serve`: `HonoServerPlugin` with the dispatcher plugin), the routes the dispatcher mounts read the request body before handing the request to `dispatch()`. The transport refuses a body it cannot parse when the body is read, so the refusal answered first: an anonymous `POST /api/v1/keys` with `{"name": "probe"` (the closing brace missing) got `400 INVALID_REQUEST`, while the same request with a well-formed body got the promised `401 UNAUTHENTICATED`. The anonymous answer depended on whether the body parsed. + + The mounts now hand the body on unread, and each domain reads it once its anonymous decision has admitted the caller. An anonymous caller gets the same answer whatever its body, on every mounted route that reads one, unscoped and `/environments/:environmentId`-scoped: + + - `401 UNAUTHENTICATED` on `POST /analytics/query` and `/analytics/sql`; `/keys` (every verb); `POST /notifications/read` and `/notifications/read/all`; `POST /automation`, `PUT /automation/:name` and the two `trigger` doors, `toggle`, `clone`, `resume`, `cancel` and `restore-suspension`; the three `POST /actions/...` shapes; the `/ai/*` routes; the `/packages` routes; `POST /security/_activation/:type/:name` and `POST /security/permission-sets/:name/clone`. + - `/mcp` answers its own `401`, or, ahead of it, its `404` when MCP is switched off and its `501` when no MCP service is installed. None of the three reads the body, so a signed-in caller gets the `404` or `501` too. + - `/mcp/skill` is public and reads no body: a `POST` or `DELETE` gets its own `405` + `Allow: GET`, signed in or not. + + Otherwise a signed-in caller with an unparseable body still gets the transport's `400 INVALID_REQUEST`: the body is read after the anonymous decision, not dropped. A `createHonoApp` host (`@objectstack/hono`) is unchanged; it already raised the refusal only when a domain read the body. +- 1bf5f6e: fix(runtime): `ctx.title()` in a hook body answers a title formula that reads through a lookup, as a read of the record shows it + + A junction object's composed title, `record.book.title + ' · ' + record.reader.name`, is shown by every read of the record, but a hook body's `ctx.title()` answered `null` for it, and `ctx.title('')` answered `null` for a related record whose own title reads through a lookup. + + - **`ctx.title()`** now reads the one hop the title formula reads, through the body's own `ctx.api` channel: one read per lookup the title reads through, with the caller's permissions, on the transaction the body has open. It follows the lookup's value in this write, so a `beforeInsert` names the payload's book and a `beforeUpdate` that changes `book` names the new book. A title over the record's own columns still costs no read. + - **`ctx.title('')`** returns the title its `findOne` served, the related record's own one-hop title included, instead of re-computing it without the hop. + - A related record the caller may not read leaves the title `null`, as the read of the same record shows no title to that caller. It is never a partial title and never the hidden name. A title that would need a second hop is `null`, as it is on a read. + + What to do. Nothing. A hook that composed a junction object's title by hand can call `ctx.title()` instead. +- af284b0: feat(spec): the remaining `getSession` readers read `AuthSessionApi`'s declared result, and none reads a top-level `activeOrganizationId` + + Clause-②: yes (widening) + + - **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains three optional `user` members**, each declared for a named reader: `isPlatformAdmin` (`boolean`, the ADR-0068 D2 rung plugin-auth's `customSession` derives) and `role` (`string | null`, better-auth's `admin` plugin scalar), both read by `judgePlatformAdmin` in `@objectstack/plugin-auth`; and `name` (`string`), read by the runtime dispatcher's AI-route `req.user` resolver as the display name. Every member is optional, so every implementer and caller typechecks unchanged. + - **The readers read through the declared type instead of `any` or a cast** (types only, runtime behaviour unchanged): `judgePlatformAdmin` and the four platform-admin mounts in `@objectstack/plugin-auth` (better-auth's own session type now has to fit the declaration at those reads), the REST server's two session reads (`@objectstack/rest`), the dispatcher's posture read, `resolveSessionData` and both `HttpDispatcher` reads (`@objectstack/runtime`), the cloud-connection and install-local session reads (`@objectstack/cloud-connection`), the webhook redeliver door (`@objectstack/plugin-webhooks`), and the storage session bridge (`@objectstack/service-storage`). + - **Fix — the top-level `activeOrganizationId` fallback is removed** from the storage upload stamp (`@objectstack/service-storage`) and from install-local's `resolveActiveOrgId` (`@objectstack/cloud-connection`). The active organization is read from `session.activeOrganizationId` alone, where the contract declares it. No in-process producer answers a top-level `activeOrganizationId` (better-auth's read is `{ user, session }`), so for the shipped auth plugin nothing changes. An auth service that answered only the flattened shape now gets no organization: the upload is not stamped, and install-local reports no active organization. To fix it, answer the active organization on `session.activeOrganizationId`, as `AuthSessionApi` declares it. +- cbfa5e4: fix(runtime): on the standalone server, every verb on `/api/v1/actions/...` reaches the `actions` domain, so an anonymous caller is answered `401`; and an `Allow` header no longer lists verbs a route mounts only to refuse + + Clause-②: no + + On the standalone server (`os dev`, `os serve`: `HonoServerPlugin` with the dispatcher plugin), a request that matched no route under its verb was answered by the HTTP adapter: `405 METHOD_NOT_ALLOWED` with an `Allow` header built from the verbs registered on the path. Two answers were wrong. + + - **`/api/v1/actions/...` (all three path shapes, unscoped and `/environments/:environmentId`-scoped).** Only `POST` is mounted, so an anonymous `GET`, `HEAD`, `PUT`, `PATCH`, `DELETE` or `TRACE` was answered `405` + `Allow: POST` instead of the published `401 UNAUTHENTICATED`. Every such request now reaches the `actions` domain, as it does on a `createHonoApp` host: an anonymous caller gets `401 UNAUTHENTICATED` with no `Allow`, and a signed-in caller gets the domain's `405` + `Allow: POST`. An anonymous `OPTIONS` (only reachable with CORS off) gets the domain's `405` + `Allow: POST` on both hosts, because the shared anonymous decision lets a CORS preflight through. + - **`Allow` on `/api/v1/keys`, the three `/api/v1/notifications` faces and `/api/v1/mcp/skill`.** These paths mount verbs that exist only so the domain can refuse them. A verb that cannot be mounted (`OPTIONS` with CORS off, `TRACE`, an extension method), and `PUT`/`PATCH` on `/mcp/skill`, got the adapter's `405` with those refusal-only verbs listed as supported (`/keys`: `Allow: DELETE, GET, HEAD, PATCH, POST, PUT`). They now reach the domain too: `/keys` answers `401` anonymous and `405` + `Allow: POST` signed in, `/notifications` answers `401` anonymous and `404 ROUTE_NOT_FOUND` signed in, and `/mcp/skill` answers `405` + `Allow: GET`. + + Every request that was served is served as before, and no route is added: the dispatcher's existing fallback seam hands these requests to the domain. A transport that implements `setFallbackHandler` but not `resolveMountedRoute` keeps the adapter's answer, and the dispatcher says so once at boot, at `warn`. +- 9822b05: fix(hono): on a `createHonoApp` host a `HEAD` is answered as its `GET` (no body), as the standalone server answers it; `HEAD /api/v1/mcp/skill` no longer answers `405` with `Allow: GET` + + Clause-②: no + + `createHonoApp`'s `${prefix}/*` catch-all handed the dispatcher the request's raw verb. Hono answers a `HEAD` from the `GET` route, but the verb still read `HEAD`, and the dispatcher's domains decide by comparing it with `GET`, so a `HEAD` was not answered as its `GET` on this host. Measured: `HEAD /api/v1/mcp/skill` answered `405 METHOD_NOT_ALLOWED` with `Allow: GET`; `/health`, `/ready` and the scoped root `/environments/:id` answered `404 ROUTE_NOT_FOUND`; signed in, `/analytics/meta` answered `404` and `/ai/agents` `501`; anonymous, the public `/share-links/:token/resolve` answered `401` where `GET` answers `501`. With an auth service, `HEAD /auth/config` answered `404` (it was forwarded to better-auth instead of answered as the `GET`). + + - **`@objectstack/hono`.** The catch-all now dispatches a `HEAD` as `GET`, and the `/auth/config` branch answers a `HEAD` as its `GET`. Every dispatcher route answers `HEAD` with its `GET` answer's status and headers, and Hono drops the body. A path that serves no `GET` keeps its `GET` answer for `HEAD` (its `405` and `Allow`, or `401` to an anonymous caller). The standalone server (`HonoServerPlugin` with the dispatcher plugin) already answered `HEAD` this way. Only the verb changes: a domain that forwards the request (the `/mcp` transport, the `/approvals/act` page) is still handed the client's `HEAD` request. + - **`@objectstack/runtime`.** The `/approvals/act` domain decides whether a request is a `HEAD` from the forwarded request's own method, not from the dispatcher's verb. So a `HEAD` still reaches the approvals member as a `GET` and is answered with no body. +- 889bf12: fix(runtime): an anonymous caller of `/api/v1/mcp` is answered `401 UNAUTHENTICATED` whatever `OS_MCP_SERVER_ENABLED` or the `mcp` service says + + Clause-②: no + + ADR-0138 D2 takes the anonymous decision once per domain, as the domain's first statement. The `/mcp` domain checked the `OS_MCP_SERVER_ENABLED` switch and the `mcp` service before it, so an anonymous caller was answered `404 RESOURCE_NOT_FOUND` with the switch off and `501 NOT_IMPLEMENTED` with no MCP service installed, and learned whether the deployment runs MCP before it was refused. Measured on both hosts, the standalone server (`HonoServerPlugin` with the dispatcher plugin) and a `createHonoApp` host. + + - An anonymous request on `/api/v1/mcp` (any verb) is now refused `401 UNAUTHENTICATED` first, in every composition. Only the order of the refusals moves: no request that was refused before is accepted. + - A signed-in caller, or a system principal, keeps the switch's `404` and the service's `501`, in that order. + - The `401` advertises RFC 9728 resource metadata (`WWW-Authenticate: Bearer … resource_metadata="…"`) only where the resource is served: the switch on, an `mcp` service that can take the request, and an auth service that answers a metadata URL. With the switch off or no service the `401` is the plain API-key one, so it never points a client at a protected resource the deployment does not serve. +- 7a8733f: fix(runtime): a request to a declarative `apis:` endpoint path gets its `404` or `401` whatever its JSON body, not the transport's `400` + + Clause-②: no + + Only the order of refusals moves: the `404` and the `401` now answer ahead of the body's `400`. No request that was refused before is accepted, and no export changes. + + On the standalone server (`os dev`, `os serve`: `HonoServerPlugin` with the dispatcher plugin), the fallback that serves metadata-declared `apis:` endpoints (`/api/v1/apps/NAMESPACE/...` under the default prefix) read the request body before it matched an endpoint or applied `authRequired`. The transport refuses a body it cannot parse when the body is read, so that refusal answered first: + + - a `POST /api/v1/apps/ns/x` that no declaration owns, with `{"params": {"days": 30}` (the closing brace missing), got `400 INVALID_REQUEST`, while the same request with a well-formed body got `404 ENDPOINT_NOT_FOUND`, signed in or not; + - an anonymous `POST` to an `authRequired: true` endpoint with that body got `400 INVALID_REQUEST`, while a well-formed body got `401 UNAUTHENTICATED`. + + The fallback now hands the endpoint step the body unread, and the step reads it once the endpoint is matched and its policies (`rateLimit`, `authRequired`) have admitted the caller, just before `inputMapping`. A path no declaration owns answers `404`, and an anonymous caller of an `authRequired: true` endpoint answers `401`, whatever the body. A caller the endpoint admits still gets the transport's `400 INVALID_REQUEST` for an unparseable body, and a readable body reaches the target as before. +- 51ad8e4: Clause-②: yes (widening) + + fix(cli,runtime): the 17 → 18 organization-ownership upgrade of a single-posture database that holds no organization leaves no row without one, the boot the ceremony prescribes writes nothing before it is refused, and a ceremony that did not verify is never served (ADR-0131 D3, D10) + + A 17.x single-posture install whose seed ran before any organization existed holds rows of organization-owned objects with `organization_id` NULL and an empty `sys_organization`. Upgrading it took this path: the ceremony's attribution checkpointed itself done at zero rows; the boot its rewrite step asks for created the Default Organization and let the inline seed stamp some rows before it refused; the second `--apply` did not run attribution again, dropped the columns and recorded discrepancies with `verified_at` NULL; and `os start` then served that database. + + - **The ceremony creates the Default Organization (`@objectstack/cli`).** Under the `single` posture, on a database that holds no organization and rows no anchor names, `os migrate organization-ownership` plans those rows as the Default Organization's and says so in a plan warning (`defaultOrganizationCreatedByApply: true` in the plan file). `--apply` creates it before its first write, through plugin-auth's boot invariant (the row a single-posture boot creates), and gives it every such row. A run that cannot create it — a boot without the auth family that declares `sys_organization` — blocks at attribution, naming why, instead of attributing nothing and certifying. Nothing is created under a walled posture, or when the database holds organizations. + - **The ceremony names the Default Organization as the boot does (`@objectstack/cli`).** Under `single` it is the organization with `slug = 'default'`, else the sole organization whatever its slug — the boot invariant's and the tenancy service's definition (ADR-0093 D3). Before, the plan required `slug = 'default'`, so a database whose only organization had another slug kept its unanchored rows NULL through the ceremony while v18 derives every owner from that organization. Several organizations, none of them `default`, still name none, and their rows are reported. In the plan file (the command's own output, not a published schema) `defaultOrganization.slug` is now that organization's own slug, no longer always `'default'`. + - **A checkpointed attribution runs again (`@objectstack/cli`)** whenever the plan derives an owner for rows still NULL: a row the operator made attributable since, or one an earlier run found no Default Organization for. It writes only where `organization_id` is NULL, so no owner already written is derived again. A database left by an earlier release's ceremony in that state is completed by re-running `--apply`. + - **The boot guard judges before any plugin writes (`@objectstack/runtime`, `@objectstack/plugin-auth`).** `OrganizationOwnershipBootGuardPlugin` judges in its own `start()`, after the engine's schema sync (the column the rewrite step waits for is still added) and before every plugin that writes rows at start: `AppPlugin` (the inline seed), `AuthPlugin` and the identity-objects plugin (the Default Organization) name it (`com.objectstack.organization-ownership-boot-guard`) in `optionalDependencies`. A refused boot leaves every row as it found it. + - **A ceremony that did not verify is refused (`@objectstack/runtime`).** With no table left in the v17 shape, a boot whose `adr-0131-organization-ownership` marker exists and is not verified — the post-check found rows the ceremony has not carried — is refused with `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`, naming the re-run. A database with no marker (a fresh one) boots as before. + - **The refusal now arrives as a `cause` (`@objectstack/runtime`).** A failed `start()` is reported by the kernel as its own error ("Plugin … failed to start"), with the plugin's error as its `cause`. A host that composes the guard and inspects the boot error asks `isOrganizationOwnershipBootRefusal` of each `cause` down the chain: asked of the kernel's own error it now answers `false`. `assertOrganizationOwnershipMigrated(engine)` still throws the refusal itself. `os serve` and `os dev` read it from the chain. + - **One new export (`@objectstack/plugin-auth`, `minor`).** `ensureDefaultOrganizationExists(ql, options?)` — the Default Organization's boot invariant, find-or-create under `single` — for the ceremony, so the organization it creates is the row a single-posture boot creates, from one producer. A host composing a kernel still never calls it: `AuthPlugin` and the identity-objects plugin keep the invariant on every boot. No other package entry changes. + + Unchanged: the ceremony's steps, fates and refusals otherwise; a walled posture's unattributable rows (reported, never assigned); the boot of a fresh or verified database; and every plugin's order on a kernel that does not compose the guard. Repairing a database whose organization-scoped seed load already inserted twins of NULL-organization rows is an operator's data repair. +- c4526a7: fix(runtime): a hook's inline `handler` function is handed the same `ctx.title(field?)` a hook `body` gets, so a source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over an `objectstack.config.ts`) answers it as `objectstack dev` does (#24289) + + Clause-②: no + + A source boot runs a hook's inline `handler` in-process, and the accessor existed only in the sandbox a `body` runs in. So a hook calling `await ctx.title()` or `await ctx.title('account_id')` (a lookup column) answered on `objectstack dev` and an artifact boot, and under `bootStack(config)` refused every write with `TypeError: ctx.title is not a function`. `bindAppArtifactHandlers` now hands each inline handler function a view of the engine's hook context that answers `title` through the same accessor the sandbox installs: the same two forms, the same served formula title and related-record read on the context's own `ctx.api`, and `null`, never the record's id, when there is no title. Every other read and every write goes to the engine's context unchanged, and that context gains no key. A `handler` that names a function (the deprecated string form) is not covered by this change. +- fa26308: fix(objectql,runtime): a hook whose `handler` names a function gets `ctx.title(field?)` in-process, as an inline handler and a `body` do + + Clause-②: yes (widening) + + - **What was wrong.** A hook whose `handler` is a string naming a function ran in-process without `ctx.title`: `defineStack({ functions })` plus a hook naming an entry on a source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over a config), and a string `handler` resolved from the artifact's `runtimeModule` (what `objectstack build` ships for a callable it could not lower to a `body`). The engine's binder resolved the name after the runtime had wrapped the inline handlers, so the function was called with the bare engine context and `await ctx.title()` threw `TypeError: ctx.title is not a function`. + - **What changed.** `bindHooksToEngine` (`@objectstack/objectql`) takes a `wrapHandler(handler, hook)` option, applied at bind to every handler a hook runs in-process, after a string `handler` resolves: an inline function, a name from the bind's `functions`, or a name the same package registered on the engine. A `body`'s handler (built by `bodyRunner`) never takes it. `ObjectQL.bindHooks` and the `IObjectQLEngine.bindHooks` contract (`@objectstack/spec`) carry the option. `bindAppArtifactHandlers` (`@objectstack/runtime`) passes the existing `withInProcessHookTitle` through it, in place of wrapping inline handlers before the bind, so the inline and the named handler take one wrap. + - **Unchanged.** A bind that passes no `wrapHandler` binds every handler exactly as resolved, so a host or harness that calls `bindHooks` itself, and a hook registered with `ql.registerHook(...)`, are not handed `ctx.title`. `HookContext` still declares no `title`. +- 6868514: Clause-②: no + + Ten user-facing strings the runtime's HTTP doors answer no longer call the system the caller is in + "this environment". The permission-set clone door's success notice (`PERMISSION_SET_CLONE_NOTICE`, the + `notice` beside the copy) now says the copy is "a new permission set authored in this system", the + wording the console's clone hint uses. The other nine drop the noun: `/mcp` and `/mcp/skill` on a + deployment that opts out answer `404` "MCP server is not enabled"; a share-link request with no + share-link service answers `501` "Sharing is not configured"; and the webhook-redelivery, approval + action-page and inbound automation-hook doors answer `501` "… no webhooks service is registered." (or + approvals, or trigger-api) when the service is missing, and "… the registered webhooks service does not + implement handleRedeliver." (or `handleActionPage`, or `handleInboundHook`) when it lacks its member. + Only the human-readable text moves: every HTTP status, error code and response shape is what it was. +- Updated dependencies [5a871e4] +- Updated dependencies [5a871e4] +- Updated dependencies [f8ef144] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [3ae0b77] +- Updated dependencies [913fd6d] +- Updated dependencies [8eac321] +- Updated dependencies [de43524] +- Updated dependencies [a5a781b] +- Updated dependencies [04669a4] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [4840720] +- Updated dependencies [23dae89] +- Updated dependencies [1e8b628] +- Updated dependencies [4ff18d3] +- Updated dependencies [3566c08] +- Updated dependencies [14ccb1d] +- Updated dependencies [7879391] +- Updated dependencies [a789148] +- Updated dependencies [d61ed6a] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [959c209] +- Updated dependencies [c28f317] +- Updated dependencies [17e4425] +- Updated dependencies [7d7943d] +- Updated dependencies [aa71c4d] +- Updated dependencies [19bb55e] +- Updated dependencies [19bb55e] +- Updated dependencies [ba8af87] +- Updated dependencies [6a49efb] +- Updated dependencies [99801d8] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [5c9bfb0] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [e7e6544] +- Updated dependencies [149294c] +- Updated dependencies [990341a] +- Updated dependencies [9646991] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [2fbe1fd] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [8c105e3] +- Updated dependencies [e02833c] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [1abfc58] +- Updated dependencies [8a399b2] +- Updated dependencies [db87a02] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [6befe19] +- Updated dependencies [8caa131] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [f85a83b] +- Updated dependencies [2015c54] +- Updated dependencies [73a0a6b] +- Updated dependencies [f2a45db] +- Updated dependencies [aa9447c] +- Updated dependencies [3d91885] +- Updated dependencies [8fc50b7] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [ae97841] +- Updated dependencies [e67ba80] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [4935c66] +- Updated dependencies [c6fe02d] +- Updated dependencies [c6fe02d] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [6ff6ed6] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [bb4f5cc] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4049cac] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [ffb31fc] +- Updated dependencies [0b997ea] +- Updated dependencies [35afb15] +- Updated dependencies [81bd9fa] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [0ee2d15] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [b1f7a7a] +- Updated dependencies [345d3f3] +- Updated dependencies [f2626c7] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [3f80f17] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [1884948] +- Updated dependencies [8a995b8] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [00bee27] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [46692c1] +- Updated dependencies [f66c440] +- Updated dependencies [440bed6] +- Updated dependencies [3ca71b6] +- Updated dependencies [94379f4] +- Updated dependencies [166a94f] +- Updated dependencies [b2549a8] +- Updated dependencies [7806a14] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [ce3d0ad] +- Updated dependencies [e148ca9] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [86f53a4] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d303b3e] +- Updated dependencies [018b077] +- Updated dependencies [0fea05f] +- Updated dependencies [86bf9ed] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [25be876] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [18d9990] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [cb3bb93] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [4b7c630] +- Updated dependencies [87cd458] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [c63028e] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [3b5475a] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [9859e4e] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [680a86b] +- Updated dependencies [d952358] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [c11b758] +- Updated dependencies [f9ebb7d] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [3fdb92d] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [4663801] +- Updated dependencies [d294951] +- Updated dependencies [6a6a4d3] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [0a29bec] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [646303e] +- Updated dependencies [e9bc60b] +- Updated dependencies [52ff308] +- Updated dependencies [d1f082d] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [8024e12] +- Updated dependencies [c4b84ad] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [0511341] +- Updated dependencies [dfb3ba2] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [aa5bfdf] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [c6139f2] +- Updated dependencies [3490bf5] +- Updated dependencies [920bc12] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [6f4c518] +- Updated dependencies [08b38cb] +- Updated dependencies [87d0619] +- Updated dependencies [917b1a0] +- Updated dependencies [d9d7edd] +- Updated dependencies [78c13bd] +- Updated dependencies [9543e9c] +- Updated dependencies [4e0838d] +- Updated dependencies [d45d0e9] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [0d34066] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [e790cfc] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [09ef77c] +- Updated dependencies [404fc47] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [24e064f] +- Updated dependencies [0f05907] +- Updated dependencies [47d569e] +- Updated dependencies [2de9c03] +- Updated dependencies [5b68524] +- Updated dependencies [5e725b8] +- Updated dependencies [6d96721] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [0be162c] +- Updated dependencies [02a691a] +- Updated dependencies [e4ec6ec] +- Updated dependencies [fc1a55a] +- Updated dependencies [249130f] +- Updated dependencies [b1e9280] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [b9dbc3d] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- Updated dependencies [1692cd1] +- Updated dependencies [0d784da] +- Updated dependencies [be50633] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [7cb33cf] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [cac0be8] +- Updated dependencies [1f3ac4f] +- Updated dependencies [57994c3] +- Updated dependencies [1eac7ad] +- 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 [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [e67ac4b] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [7dc7d22] +- Updated dependencies [e611e2b] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [954bf72] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [c7dd48a] +- Updated dependencies [30c33e3] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cad343e] +- Updated dependencies [cbe7712] +- Updated dependencies [cd86e41] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [f77ba41] +- Updated dependencies [76560a1] +- Updated dependencies [9bb9370] +- Updated dependencies [53646fc] +- Updated dependencies [23d0bc7] +- Updated dependencies [14aff67] +- Updated dependencies [069fd40] +- Updated dependencies [abb6df0] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [cf07168] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [0cfd0c4] +- Updated dependencies [ae4dfaf] +- Updated dependencies [099cc9d] +- Updated dependencies [6422da5] +- Updated dependencies [2034975] +- Updated dependencies [61e5ee9] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [a33f3d5] +- Updated dependencies [6e18016] +- Updated dependencies [d050a8f] +- Updated dependencies [b25d98a] +- Updated dependencies [5fb8d15] +- Updated dependencies [c4111c6] +- Updated dependencies [5c2ac83] +- Updated dependencies [4ffdebe] +- Updated dependencies [03ac4ec] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [aefab7c] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [077b75f] +- Updated dependencies [c1c0a90] +- Updated dependencies [ab3ba2c] +- Updated dependencies [d6bd98a] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [f792290] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [f3174ef] +- Updated dependencies [e46ce7b] +- Updated dependencies [a06f0d4] +- Updated dependencies [a06f0d4] +- Updated dependencies [9f024d8] +- Updated dependencies [9975c26] +- Updated dependencies [2ac0616] +- Updated dependencies [2ac0616] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [aaaeeac] +- Updated dependencies [8a88901] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [aa31569] +- Updated dependencies [8259ea0] +- Updated dependencies [9040945] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [d537a31] +- Updated dependencies [1bf5f6e] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [2bcd406] +- Updated dependencies [e74ec56] +- Updated dependencies [2bcd406] +- Updated dependencies [2bcd406] +- Updated dependencies [58a2faa] +- Updated dependencies [e74ec56] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [83dfbc0] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [51ad8e4] +- Updated dependencies [ec749fb] +- Updated dependencies [ec749fb] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [41c7405] +- Updated dependencies [370833a] +- Updated dependencies [2f569a9] +- Updated dependencies [69147ab] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [9112bcd] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [94e60c0] +- Updated dependencies [9524666] +- Updated dependencies [d65826f] +- Updated dependencies [741aa85] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf16a0d] +- Updated dependencies [cf540a7] +- Updated dependencies [cf540a7] +- Updated dependencies [44a5011] +- Updated dependencies [1827571] +- Updated dependencies [adb7910] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [e22defb] +- Updated dependencies [45bec43] +- Updated dependencies [33a45cc] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/metadata-protocol@18.0.0 + - @objectstack/objectql@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/formula@18.0.0 + - @objectstack/plugin-auth@18.0.0 + - @objectstack/metadata@18.0.0 + - @objectstack/plugin-security@18.0.0 + - @objectstack/driver-sql@18.0.0 + - @objectstack/rest@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/service-datasource@18.0.0 + - @objectstack/types@18.0.0 + - @objectstack/service-i18n@18.0.0 + - @objectstack/driver-turso@18.0.0 + - @objectstack/driver-sqlite-wasm@18.0.0 + - @objectstack/driver-memory@18.0.0 + - @objectstack/service-cluster@18.0.0 + - @objectstack/observability@18.0.0 + ## 18.0.0-next.5 ### Minor Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index d0a6147c567..e6950ca66d7 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "18.0.0-next.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "ObjectStack Core Runtime & Query Engine", "type": "module", diff --git a/packages/sdui-parser/CHANGELOG.md b/packages/sdui-parser/CHANGELOG.md index a56171a9c30..5c3d608e38a 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,9 @@ # @objectstack/sdui-parser +## 18.0.0 + +No changes in this release. + ## 18.0.0-next.5 No changes in this release. diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index 38884f56bde..f13c28667d9 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "ObjectStack constrained JSX-source → SDUI SchemaNode tree compiler (parse, never execute). Isomorphic, zero React. ADR-0080.", "main": "dist/index.js", diff --git a/packages/services/service-analytics/CHANGELOG.md b/packages/services/service-analytics/CHANGELOG.md index a9a8fc29d3a..5cdf5a33b80 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,530 @@ # Changelog — @objectstack/service-analytics +## 18.0.0 + +### Minor Changes + +- c565813: feat(spec,analytics): a dataset answer's measure column states its aggregate, labelled or not (`fields[].aggregate`) + + Clause-②: yes (widening) + + - **What a renderer can now read.** Each measure column of a dataset answer (`POST /analytics/dataset/query`) carries `fields[].aggregate`: the aggregate its dataset measure declares, in the closed `AggregationFunction` vocabulary (`count`, `sum`, `avg`, `min`, `max`, `count_distinct`). It is there whether or not the author gave the measure a `label`. So a chart can tell a count from a sum, for example to draw whole-number axis ticks for a count instead of 0.75 / 1.5 / 2.25. + - **What was missing.** The only aggregate on the wire was `builtinAggregate`, and it is present only when the measure has no `label`. A labelled measure, such as a `count` named "Tasks", reached the wire as `{ name, type: 'number', label }`, with nothing to say what kind of number it was. + - **Where it is set.** `AnalyticsService` writes it in the one step that describes a dataset answer's columns from the dataset's own measures. That step runs for both the live query and the draft-data preview, so the two answers agree. A measure's `__compare` column carries the same aggregate. + - **Where it is absent.** Dimension columns. Derived measures, which combine other measures and have no single aggregate (a stray `aggregate` written beside `derived` is ignored when the dataset compiles, so it is not stated here either). And a cube query answer (`POST /analytics/query`), which does not run through the dataset column step. + - **Unchanged.** `builtinAggregate` keeps its meaning: present only on a label-less measure column, to mark a header that is the server's default. No authoring key is added; `aggregate` is a response member only. `AnalyticsResultResponseSchema` and the `AnalyticsResult` contract declare the member, and the REST route relays it as it does every other column key. +- 156ddfa: fix(service-analytics)!: the analytics door judges the two generic-exit declarations every other door judges — an object's `enable` block and a field's `internal: true` (#22634) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Every generic exit judges an object's `enable` block through the spec's one exposure decision, `apiExposureDenialReason`: the REST data routes, the runtime dispatcher, the MCP data tools, and the cross-object search for the off switch. Every generic exit also withholds a field declared `internal: true`: the data door omits it from rows, the write responses strip it, and the engine's `aggregate()` refuses it as a group key. The analytics door read neither declaration. + + **FROM.** On both strategies (native SQL and ObjectQL), and for a member holding read and an administrator alike: + + - The ad-hoc query, the SQL face, the configured-cube door and the dataset door served the rows of an object declaring `apiEnabled: false`, and of an object whose `apiMethods` whitelist does not grant `list`. + - On the native-SQL strategy, a field declared `internal: true` was served as a group key, as an aggregate input and as a filter operand. + - On the ObjectQL strategy, the engine refused the same field as a group key or an aggregate input with an undeclared `500`. It still admitted the field as a filter operand. + + **TO.** Ahead of strategy selection, the door asks the spec's decision for the `aggregate` operation over every object a query reads: the base object, declared joins and relationship hops. It answers with the data door's codes for the same declaration: + + - `404 OBJECT_API_DISABLED` for `apiEnabled: false`; + - `405 OBJECT_API_METHOD_NOT_ALLOWED` for a whitelist that does not grant the verb. + + On the ad-hoc doors this happens before any cube is inferred, so a refused request mints nothing. A member that reads a field declared `internal: true` is refused `400 INVALID_FIELD`, naming the object and the field. This holds in every position: dimension, measure input, filter or sort key, dataset filter, and relationship hop. + + - **Refused, not withheld.** On the data door, the row path omits such a field because a row is a projection. Every analytics member is evaluated instead: a group key, an aggregate input, a predicate. There is nothing to omit, and the data door's own aggregate face already refuses the field as a group key. + - **Every caller.** Neither declaration takes a user, so administrators are refused exactly as members are. There is no system carve-out. + - **Fails closed.** A declaration lookup that throws refuses the query with `403 PERMISSION_DENIED`, logged at `error`. `AnalyticsServicePlugin` wires the lookup from the data engine and refuses when no engine can answer. A host constructing `AnalyticsService` by hand with no `getObjectDeclaration` hook gets no gate, and is told so once. + - **Unchanged.** An ordinary object and an ordinary field are served as before. An object that merely has an `internal` field is still served for every other field, and so is a bare count over it. + + **Measured producers.** On `origin/main` `f368b7e980`, no authored cube, dataset, dashboard or example in this repository names an `apiEnabled: false` object, an object whose whitelist omits `list`, or an `internal` field. The platform's System Overview datasets count `sys_user`, `sys_organization` and `sys_session`, and group `sys_audit_log` by two ordinary fields. Each of those objects grants `list`, and none of those members is `internal`. Deployed and cloud-held analytics definitions were NOT MEASURED. +- 3b5475a: fix(metadata-protocol,service-analytics)!: a read that follows a lookup asks the TARGET object its declared exposure — the data door's `$expand` at every level, and the dataset door's dimension-label passes (#22661) + + Clause-②: no + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The data routes judge the ADDRESSED object's `enable` block through the spec's one exposure decision (`apiExposureDenialReason` / `canServeApiOperation`): `enable.apiEnabled: false` answers `404 OBJECT_API_DISABLED`, and an `enable.apiMethods` whitelist that does not grant the verb answers `405 OBJECT_API_METHOD_NOT_ALLOWED`. A read that FOLLOWS a lookup reaches a second object nobody addressed, and two such reads never asked it. + + **FROM.** For an administrator and a member alike: + + - **`$expand`** — `GET /api/v1/data/:object?$expand=`, `GET /api/v1/data/:object/:id?expand=`, the relation map on `POST /api/v1/data/:object/query` (every level of the tree), and the export door (which expands every lookup to name it) served the row fields of a lookup's target whose declaration refuses it on every data route. + - **Dataset dimension labels** — `POST /api/v1/analytics/dataset/query` rendered a reference-class dimension's grouped id as the target record's display name, and sorted an `order` on that dimension by those names, for the same targets. + + **TO.** Each read asks the spec's one decision of the TARGET object, for `get` — the read both perform is "turn an id the caller holds into the record it names", the read `GET /api/v1/data/:target/:id` performs: + + - An `$expand` entry whose target the decision does not serve is withheld at the generic data door (`@objectstack/metadata-protocol`), at every level: the field answers as an UNEXPANDED lookup, its stored id — the answer the door already gives for a related record the caller may not read. Nothing below a withheld entry is read. + - A dimension label is not read for such a target (`@objectstack/service-analytics`): the stored id renders, and an `order` on the dimension sorts by it — the answer a target the reader's row scope hides already gets. A declaration that cannot be read withholds the labels too (fail-closed, at `warn`); the query itself still answers. + + Concretely, a target declaring `apiEnabled: false`, the deny-all `apiMethods: []`, or a whitelist without `get` (for example `['list']` or `['create']`) is withheld from both; a target with no `enable` block, or a whitelist that grants `get`, is served exactly as before. + + **Bindings.** + + - **Every caller.** The decision takes no user, so an administrator, a member and a system context get the same tree and the same labels. + - **The engine's privileged callers are untouched.** A flow's `config.expand` and a hook's own reads call the engine directly and never pass the generic data door, the same split the write results and the `internal: true` evaluate positions keep. + - **No new error code.** Both reads withhold rather than refuse, so no response status changes. + + **Measured producers.** Read on `origin/main` `eae3368a` over every `.ts` source under `packages/` and `examples/`: ten objects refuse `get` by declaration, and exactly one lookup points into any of them, from an object that is itself `apiEnabled: false`. No example declares `enable.apiEnabled` or `enable.apiMethods`. So no shipped object's served answer changes. Deployed and cloud-held object definitions were NOT MEASURED. +- 7399080: fix(service-analytics)!: a configured cube, or a dataset `registerDataset` registers, over an object the API does not serve is refused at registration, and `GET /analytics/meta` no longer lists it (#22663) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Since the analytics door started judging an object's `enable` block, every query over an object whose `enable` the spec's `apiExposureDenialReason` denies for the `aggregate` operation is refused: `404 OBJECT_API_DISABLED` for `apiEnabled: false`, `405 OBJECT_API_METHOD_NOT_ALLOWED` for an `apiMethods` whitelist that does not grant it. The registration doors did not ask the same question. + + **FROM.** A configured cube (`analyticsCubes`, or `AnalyticsServicePlugin({ cubes })`), or a dataset registered through `registerDataset` (or the service's `datasets` option), whose base object or declared join is such an object registered silently. `GET /analytics/meta` listed it, and every query of it then answered 404 or 405. Nothing told the author at registration time. + + **TO.** Registration asks the same decision, for the same operation, over the same objects the query door's first check reads: the base object and the declared joins. + + - **A configured cube** over such an object is not registered. The service logs one warning per cube that names the cube, the object and the declaration (`enable.apiEnabled: false`, or the `enable.apiMethods` whitelist and the operations it grants). Every other cube still registers, and the boot continues. This is the channel a pre-registered dataset that cannot compile already takes. + - **`registerDataset`** throws, registering nothing. The error names the dataset, the object and the declaration, and carries the query door's code and status for that declaration (`OBJECT_API_DISABLED` / 404 or `OBJECT_API_METHOD_NOT_ALLOWED` / 405) with the object. + - **A cube written straight to the service's public `cubeRegistry`** is refused by the registry's own admission in the same words. + - **`GET /analytics/meta`** never lists a refused definition. + + **Unchanged.** + + - A cube or dataset over an exposed object registers and lists exactly as before. + - A host that wires no `getObjectDeclaration` hook registers every definition as before. `AnalyticsServicePlugin` always wires it. + - Registration judges only what is known when it runs, and does not block otherwise. An object that is not registered yet when the analytics plugin's `init()` registers the configured cubes is not refused, and neither is one whose lookup throws. Objects declared by a plugin that inits later, or in a `start()`, fall in this class. Such a cube registers and lists as before, and the query door still refuses every query of it. + - A relationship hop that one member walks is judged at query time, per member, and does not refuse the whole cube. + + **Measured producers.** On `origin/main` `ee3ae0360d`, this repository has one authored cube, `showcase_delivery` (`showcase_task` joined to `showcase_project`), and no `registerDataset` producer outside tests. Neither object declares an `enable` block. The eleven example and platform datasets (six in `examples/**`, five in `platform-objects`) are queried through the request-scoped dataset door and never enter the registry. Deployed and cloud-held definitions were NOT MEASURED. + +### Patch Changes + +- 4ad3ce5: fix(lint, service-analytics): a total over a formula field names the stored-number remedy, not a count + + Clause-②: no + + - **What changed.** A dataset or cube measure taking `sum`, `avg`, `min` or `max` over a `formula` field is still refused (`measure-aggregate-field-type-refused` in `os validate`, `400 DATASET_INVALID` at the dataset compile). The prescription changed. It used to name `count` / `count_distinct` as the way out, and a count read as a total is a plausible wrong number (3 records shown as "total leave days" where the true total is 10). It now reads: "The field is computed on read and stores no value: store the value as a number field (kept by a hook) and aggregate that. `count` / `count_distinct` answer a different question: how many records." + - **One source.** The sentence lives in `@objectstack/spec/data` as `VIRTUAL_FIELD_AGGREGATE_REMEDY`, returned by `virtualFieldAggregateRemedy(aggregate, fieldType)` for a refused value-reading aggregate over a virtual type (`SEARCH_VIRTUAL_TYPES`, which is `formula`). The function returns `undefined` for every other pair. The lint hint, its `os explain measure-aggregate-field-type-refused` entry and the dataset compile leg all print this one sentence. + - **What stays the same.** Nothing is newly accepted or refused. Every other refused pair keeps its own hint, including `count_distinct` over a JSON-stored or `multiple: true` field. The analytics door's cube-query refusal (`400 INVALID_FIELD`) and the engine's `aggregate` door keep their own wording. +- 036bf1d: fix(service-analytics): a default boot no longer warns that no "security" service is registered; the warning moves to the first query that finds none + + Clause-②: no + + - **What changed.** `AnalyticsServicePlugin` used to log a WARN at init, `[Analytics] No admitObjectRead configured and no "security" service registered at init …`, on every default boot. `@objectstack/plugin-security` registers the `security` service in its `start()`, after every plugin's `init()`, and the object-level read bridge resolves that service per query. So the warning described a consequence that did not apply to the running app. Init now logs the same situation at `info`, as the sibling row-scope bridge already did: `[Analytics] admitObjectRead bridged to the "security" service; that service is not registered yet at init and will be resolved per query`. + - **Where the warning went.** When an analytics query needs the object-level gate and the plugin context returns no `security` service, the bridge logs one WARN: `[Analytics] No admitObjectRead configured and no "security" service registered when an analytics query needed one (first: "OBJECT") …`. It is logged once per plugin instance, not once per query. + - **Unchanged.** What the bridge enforces. When the plugin context answers a lookup for `security` with nothing, the query is admitted, as before. A lookup that throws, or a service that answers neither `canReadObject` nor `explain`, still denies the query fail-closed and logs an `error`, as before. The in-repo kernels (`ObjectKernel`, `LiteKernel`) throw on a lookup for a service nobody registered, so on those kernels a query with no security service ever registered is still denied, with an `error` per query, as before. A deployment that supplies `admitObjectRead` sees no change. +- 0e9371f: fix(service-analytics): a picklist-bound select dimension now renders its category labels in the request's locale + + Clause-②: no + + A select field that references a shared list (`picklist: ''`) is served with the list's options and with `picklist` kept, and its option labels translate under `picklists..options.`. The analytics label pass translated a dimension's options through a synthetic field that carried `options` only, so the translator never looked at the `picklists` address. Every chart and report category on such a dimension showed the authored (usually English) labels under every `Accept-Language`, while `GET /api/v1/meta/object/:name` showed the same field translated. + + The synthetic field now carries the dimension field's `picklist`. `POST /api/v1/analytics/dataset/query` answers the same labels the object-metadata endpoint serves for that field: a field-level entry (`objects..fields..options.`) wins when one exists, and otherwise the list's label applies. A dimension with inline `options` renders exactly as before. + + An `order` on a select dimension now sorts by the label the row renders in the request's locale, and a `limit` keeps the first rows in that order. The sort used the authored labels, so under `Accept-Language: zh-CN` or `ja-JP` the order followed the English text, for inline `options` and, once translated, for picklist-bound dimensions too. With no locale, or no translation for it, the order is unchanged. + + Nothing you author changes: no key, export or parameter is added or removed. +- e175479: docs(service-analytics): the shipped API docs no longer say a deployment with no security service keeps analytics reads open + + Clause-②: no + + The doc comments on `AnalyticsServiceConfig.admitObjectRead` and `AnalyticsServiceConfig.getReadableFields`, and on the service's read gates, said that a missing hook means "the deployment has no security service" and that such a deployment keeps its analytics behaviour. Both parts were wrong. `AnalyticsServicePlugin` always wires both hooks. On `ObjectKernel` and `LiteKernel`, looking up a `security` service that was never registered throws, so the bridges refuse the query, fail-closed: `PERMISSION_DENIED` / 403 naming the object, plus an `error` line. The docs now say so. A missing hook now means only a host that constructs `AnalyticsService` itself without one. + + Only comments change. No behaviour, export or type moves. The text changes in the published `dist/index.d.ts` and `dist/index.d.cts`, and in the JSDoc kept in `dist/index.js` and `dist/index.cjs`. +- 746637e: fix(service-automation)!: retire the `RunProvenanceContext` type; `RunDataContext` names only what `resolveRunDataContext` returns + + Clause-②: no (narrowing) + + + + **BREAKING** for TypeScript code that imports from `@objectstack/service-automation`: one type export is removed and one type narrows. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes in any package. + + **What is removed.** The `RunProvenanceContext` type export, the `{ flowRunId }`-only "provenance" envelope, and its arm of `RunDataContext`. No code in this repository builds or reads one. Since ADR-0096 D5, the data engine's security middleware refuses a context that carries no principal and is not a system context (`403 PERMISSION_DENIED`, for every verb). So the type described a context the engine refuses, while its docstring said such a context was "indistinguishable from passing no context at all" and was let through. + + **What `RunDataContext` is now.** Exactly what `resolveRunDataContext` returns: `isSystem: true` for a `runAs: 'system'` run, and the triggering user's principal for a `runAs: 'user'` run. A run that resolves no principal is refused before any data operation (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`), as before. + + **If your code named the type.** There is no replacement, because the context it described is refused. A custom data node builds its context with `resolveRunDataContext(context)`, typed `RunDataContext`. + + **`@objectstack/plugin-security`, `@objectstack/runtime`, `@objectstack/service-analytics`, `@objectstack/platform-objects`:** comment and docstring text only. Shipped comments that said the security middleware hands through, skips or falls open for a context with no principal now say that it used to, and that ADR-0096 D5 refuses such a context. Nothing these packages do changes. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 3b1ff992299..609796dd745 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Analytics Service for ObjectStack — implements IAnalyticsService with multi-driver strategy pattern (NativeSQL, ObjectQL, InMemory)", "type": "module", diff --git a/packages/services/service-automation/CHANGELOG.md b/packages/services/service-automation/CHANGELOG.md index fc0bfaee4b5..421f7646a62 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,1625 @@ # @objectstack/service-automation +## 18.0.0 + +### Major Changes + +- f66fdc7: The maps a flow node hands to a CALLEE are value slots now: a `subflow` node's `input`, a `map` node's `input` (evaluated once per item) and a `script` node's `inputs`. Each value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`, evaluated in the calling flow's scope and handed over as the value it computes, or a literal written as it is. A `{…}` template token there is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling, as in every other value slot. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. These three maps are computed values: each one becomes the callee's input. Until now each executor handed its map to the single-brace interpolator, so a `{token}` resolved there, and a CEL envelope written there reached the callee as the object `{ dialect: 'cel', source: '…' }` it spells, with the run reporting success. + + **What changes for a value that may be absent.** Where a whole token resolved to nothing, the template handed the callee nothing. A child flow then seeded its input variable from the variable's `defaultValue`. Under CEL an absent variable or key fails the run, and the guarded form `has(vars.x) ? vars.x : null` hands `null`. A `null` the caller supplies is a value: it wins over the child's `defaultValue`. To keep the default for an absent value, write it in the guard, `has(vars.x) ? vars.x : 'standard'`. Leave the key out where the value is never meant to be supplied. A `script` function is handed `null` where the template handed `undefined`. The refusal at those positions says this. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `input: { ownerId: '{record.owner}' }` on a `subflow` | `input: { ownerId: { dialect: 'cel', source: 'record.owner' } }` | an absent `owner` fails the run; guarded, it hands `null`, which wins over the child's `defaultValue` | + | `input: { ownerId: '{x}' }` where the child's default should apply when `x` is absent | `input: { ownerId: { dialect: 'cel', source: "has(vars.x) ? vars.x : 'standard'" } }` | the default is written in the guard, because a supplied `null` is not absent | + | `input: { row: '{item}' }` on a `map` | `input: { row: { dialect: 'cel', source: 'item' } }` | evaluated once per item, with the item variable bound; a list or a record is handed over with its type | + | `inputs: { lines: '{lines}' }` on a `script` | `inputs: { lines: { dialect: 'cel', source: 'lines' } }` | the function receives the list; guarded, an absent value is `null` where it was `undefined` | + | `input: { message: 'Task "{record.title}" is done.' }` | `input: { message: { dialect: 'cel', source: "'Task \"' + record.title + '\" is done.'" } }` | one CEL concatenation; wrap a hole that may be null in `coalesce(…, '')` | + + **The one-line fix: write each value of a `subflow` / `map` `input` and a `script`'s `inputs` as a CEL value envelope, and write a child's default into the guard where it should apply.** + + **Who is affected, measured.** In this repository: 5 values in 3 nodes of the showcase example (`showcase_task_completed`'s `script`, and the `subflow` nodes of `showcase_task_done_notify_owner` and `showcase_project_closure`), 3 values in the todo example's `task_completion` `script`, 2 docs pages, the service-automation README, 2 dogfood fixtures and the test fixtures; all are migrated in this change. hotcrm's sites at these positions were not measured. + + **Still accepted, unchanged.** A CEL value envelope and every literal. + + ### The kit + + - **The contract.** `SubflowConfigSchema.input`, `ScriptConfigSchema.inputs` and `MapConfigSchema.input` take `FlowValueSlotSchema` per value (`@objectstack/spec/automation`), so the executor's contract parse refuses a token as a guard. Each published JSON Schema declares the dropped refinement (`dropped-refinements.baseline.json`). + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains `subflow.input.*`, `map.input.*` and `script.inputs.*` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries `MapConfigSchema`. `registerFlow`, `objectstack validate` and `flow-bare-dollar-reference` / `flow-double-brace-interpolation`'s value-slot hints follow the ledger, so all of them cover the three maps. + - **The executor.** One per-key resolver, shared with the CRUD `fields` map, evaluates an envelope through `AutomationEngine.evaluateValueEnvelope` in the parent's scope and run context. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 9fdb127: A `loop`'s (with a `body`) and a `map`'s `collection` is a value slot now: the list the node iterates is a CEL value envelope, `{ dialect: 'cel', source: 'tasks' }`, that the executor evaluates in the run's scope, or the items written as an inline array, iterated as written. A string there is refused at `objectstack validate`, at `registerFlow` and by the executor: a `{…}` template token, naming its CEL spelling, and a variable's bare name, naming that variable in an envelope. The envelope is newly accepted there; the contract refused it before this change. + + Clause-②: yes (narrowing: a string is refused at loop.collection and map.collection, a {…} token and a variable's bare name alike; widening: both accept a CEL value envelope they refused, their published JSON Schema and inferred types gain the envelope arm, and FlowCollectionSlotSchema is a new export) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (both slots accept a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. The list a `loop` or `map` iterates is a computed value. Until now the executors ran a string `collection` through the single-brace interpolator. The contract also declared a variable's bare name there (`collection: 'tasks'`), but the structured `loop` and `map` never read one: the interpolator handed the name back unchanged, the value was no list, and every run failed at the node ("collection 'tasks' did not resolve to an array"), although `registerFlow` and `objectstack validate` accepted the flow. That arm retires with the token, and the refusal names the envelope that iterates. + + **What changes where the list may be absent.** The envelope must evaluate to a list, and anything else fails the node, `null` included, as a token that resolved to nothing did. One difference remains on an absent variable: the template failed the node with a returned failure (the step's `NODE_FAILURE`), and CEL fails it with an evaluation fault naming the variable (`EXECUTION_ERROR`). A `fault` edge routes both. To iterate nothing when the list may be absent, guard it to answer `[]`: `has(vars.tasks) ? vars.tasks : []`. + + **What does not change.** An inline array is iterated as written, and its elements are data: a `{…}` inside one was never interpolated, and is not judged now. A `loop` with no `body` is the legacy flat-graph form: it still reads `collection` as a bare variable name and sets `$loopItems` and `$loopIndex`, and no door judges it. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `collection: '{tasks}'` | `collection: { dialect: 'cel', source: 'tasks' }` | an absent `tasks` fails the node with an evaluation fault instead of a returned failure | + | `collection: '{record.items}'` | `collection: { dialect: 'cel', source: 'record.items' }` | as above | + | `collection: '{tasks}'` where an absent list should iterate nothing | `collection: { dialect: 'cel', source: 'has(vars.tasks) ? vars.tasks : []' }` | the node iterates nothing where the template failed it | + | `collection: 'tasks'` (a bare name, on a `loop` with a `body` or a `map`) | `collection: { dialect: 'cel', source: 'tasks' }` | the node iterates; it failed every run before, so decide whether it should | + | `collection: ['a', 'b']` | unchanged | an inline array is the list | + | `collection: 'tasks'` on a `loop` with no `body` | unchanged | the legacy flat-graph loop is untouched | + + **The one-line fix: write a `loop`'s or a `map`'s `collection` as a CEL value envelope naming the list, `{ dialect: 'cel', source: 'tasks' }`, or as an inline array.** + + **Who is affected, measured.** In this repository: the showcase example (two `loop` collections and one `map` collection), the todo example (two `loop` collections), the flows guide, the service-automation README, and test fixtures; all are migrated in this change. hotcrm writes a `{…}` collection on eleven structured loops in nine flow files (and one test fixture) and no bare name; it is on spec 17.7.0, so nothing there moves until it upgrades. + + **Newly accepted.** A CEL value envelope at `loop.collection` and `map.collection`: the published JSON Schema for both gains the envelope arm (a list, or the envelope object), and so do the inferred `LoopConfig['collection']` and `MapConfig['collection']`, which lose `string`. `FlowCollectionSlotSchema` (`@objectstack/spec/automation`) is the contract both take. + + **Still accepted, unchanged.** An inline array at both, and on a body-less `loop` any `collection` its legacy form reads. + + ### The kit + + - **The contract.** `LoopConfigSchema.collection` and `MapConfigSchema.collection` take `FlowCollectionSlotSchema`: a list or a CEL value envelope, so the executor's contract parse refuses a string as a guard, in the words above. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS`' `loop.collection` and `map.collection` rows take the `value` role, and `loop.collection` is read only on a `loop` with a `body` (`onlyWithKey`). The `loop` and `map` descriptors declare the slot with `xExpression: 'value'`. The single-brace `flow-template` role, which recorded these two slots and no other, is gone from `FlowNodeExpressionRole`, so a descriptor marker `'template'` fails the reconciliation ratchet as unknown. + - **The executors.** The `loop` and `map` nodes evaluate an envelope through `AutomationEngine.evaluateValueEnvelope`, as every value slot does, and iterate an inline array as written. A value that is not a list fails the node, naming what it evaluated to and its source. + - **The lint.** `validate-flow-template-paths` reads a loop iterator's object off an envelope `collection` that names a variable, as it read the token before. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 615c696: Each operand of a `get_record`, `update_record` or `delete_record` node's `filter` is a value slot now. A field's value, an operator's comparand and each element of a list comparand take a CEL value envelope, `{ id: { dialect: 'cel', source: 'record.id' } }`, which the executor evaluates in the run's scope before the query, or a literal handed to the query as written. A query placeholder — `{current_user_id}`, `{current_org_id}`, `{today}`, `{30_days_ago}` and the rest of the date macros — is such a literal: the query engine resolves it, and it passes untouched, as before. Every other single-brace `{…}` token at an operand is refused with its envelope: at `objectstack validate`, at `registerFlow`, at the metadata save door and by the executor's parse. An operand envelope that evaluates to `null` refuses the node, as a token that resolved to nothing did. + + Clause-②: yes (narrowing: a flow {…} token at a get_record, update_record or delete_record filter operand is refused, a whole-string query placeholder excepted, an object naming a string dialect at an operand must be a valid CEL value envelope, an operand envelope that evaluates to null refuses the node, and @objectstack/lint no longer exports validateFlowFilterTokens, FLOW_FILTER_TOKEN_UNKNOWN or FlowFilterTokenFinding and retires the flow-filter-token-unknown rule id; widening: a filter operand gains the CEL value envelope arm, the exported FlowNodeExpressionPath type gains the optional filterOperands field, ValueSlotTemplateOptions gains filterOperand, FLOW_NODE_EXPRESSION_PATHS gains the three filter rows, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains get_record and delete_record, the published GetRecordConfig, UpdateRecordConfig and DeleteRecordConfig JSON Schemas mark filter as a value slot, and flowFilterOperands, FlowFilterOperand, classifyFlowFilterOperandString and FlowFilterOperandStringKind are new exports) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL — its contract table names "filter values" — and Decision 3 deletes the single brace. A CRUD filter was the last value position that read the single-brace dialect: the executor interpolated the whole filter, resolving a flow token against the run's variables and handing a token no variable resolved on to the query engine when it was a query placeholder. Two `{…}` vocabularies met in one string. The query placeholders are the query-filter surface, which ADR-0032 puts out of its scope, so they stay; the flow token goes. + + **One classifier.** `classifyFlowFilterOperandString` (`@objectstack/spec/automation`) draws the line: a string that is one whole wrapped token the query engine resolves on the server (`isKnownFilterToken`) is a placeholder and passes; any other string carrying a `{…}` token is refused; the rest is a literal. The node contracts, `registerFlow`, `objectstack validate` and the executor's parse all read it, through the value-slot judge. + + **What an operand is.** The operands are the positions a value sits at, read the way the filter grammar reads a `FilterCondition` (`flowFilterOperands`): a field's value, each entry of an operator map (`$gt`, `$in`, `$between`, …), each element of a list comparand, inside `$and`, `$or` and `$not` and inside a nested-relation condition. An envelope there is one operand, and may evaluate to a list (`{ id: { $in: { dialect: 'cel', source: 'record.ids' } } }`). The filter's keys, operators and combinators are its structure, never evaluated. The top-level `filter` is never an operand. + + **What changes where a value may be absent.** The template erased a condition whose token resolved to nothing, and the node was refused. Under CEL an absent variable or key fails the run before any query, naming it, and an envelope that evaluates to `null` — a guarded form, a field that holds no value — refuses the node with the guard's message: a condition whose comparand is `null` or nothing does not narrow a query, it widens it. So route around the node with a `decision` where the value may be absent, rather than guarding it to `null`. To match an empty field, write the literal `null` (or `{ $null: true }`), never a computed one. + + **A flow variable named like a placeholder no longer shadows it.** Under the template a flow variable named `today` or `current_user_id` won over the placeholder of the same name. No flow token is read at an operand now, so the placeholder is always the query engine's; read the variable with an envelope, `{ dialect: 'cel', source: 'today' }`. No flow, variable or output variable in this repository is named like a placeholder. + + **A related field named `dialect`.** An object at a field's value that names a string `dialect` is read as an envelope. A nested-relation condition on a related object's field named `dialect` is written through an operator map, `{ rel: { dialect: { $eq: 'x' } } }`. No object in this repository declares such a field. + + **The lint rule `flow-filter-token-unknown` retires.** It reported a `{FUNC()}` token neither the flow template dialect nor the query placeholders define at a flow filter. Every token it reported is refused now by the value-slot judge at the same position and severity, with the envelope that replaces it, so the rule, its `os explain` entry and its exports (`validateFlowFilterTokens`, `FLOW_FILTER_TOKEN_UNKNOWN`, `FlowFilterTokenFinding`) are removed rather than left reporting the same defect a second time with the retired dialect's remedy. A consumer moves to the value-slot judge's findings: rule id `flow-filter-token-unknown` → `expression-invalid` (`EXPRESSION_INVALID`); `validateFlowFilterTokens(stack)` → `validateStackExpressions(stack)`, which reports each refused operand located at its node and `config.filter.`; `FlowFilterTokenFinding` → `ExprIssue`; `FLOW_FILTER_TOKEN_UNKNOWN` → `EXPRESSION_INVALID`. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `filter: { id: '{record.id}' }` | `filter: { id: { dialect: 'cel', source: 'record.id' } }` | an absent `record.id` fails the run before the query instead of refusing the node | + | `filter: { amount: { $gt: '{record.cap}' } }` | `filter: { amount: { $gt: { dialect: 'cel', source: 'record.cap' } } }` | a `null` cap refuses the node, as before | + | `filter: { id: { $in: ['{a}', '{b}'] } }` | `filter: { id: { $in: [{ dialect: 'cel', source: 'a' }, { dialect: 'cel', source: 'b' }] } }`, or one envelope evaluating to the list | — | + | `filter: { owner: '{$User.Id}' }` | `filter: { owner: { dialect: 'cel', source: 'current_user.id' } }` | in a run with no user `current_user` is `null` and the read fails: skip the node with a `decision` on `current_user != null` | + | `filter: { due: { $lt: '{TODAY() - 3}' } }` | `filter: { due: { $lt: { dialect: 'cel', source: 'isoDate(daysAgo(3))' } } }` | none for a whole number of days | + | `filter: { owner: '{current_user_id}' }`, `{ due: { $lt: '{today}' } }` | unchanged | a query placeholder stays the query engine's | + | `filter: { owner: '{today}' }` meaning a flow variable named `today` | `filter: { owner: { dialect: 'cel', source: 'today' } }` | the placeholder no longer shadows the variable | + + **The one-line fix: write each filter operand the flow computes as a CEL value envelope, and keep the query placeholders as they are.** + + **Who is affected, measured.** In this repository, at the base: seven flow tokens at a filter operand in the shipped examples (`app-crm` 2, `app-showcase` 3, `app-todo` 2 — `app-todo`'s two query placeholders stay), five in the docs (`getting-started/common-patterns`, `kernel/runtime-services/examples` 2, the `service-automation` README 2), the flows guide's and the spec's own remedy texts (a `$User.Email` read named `filter: { id: '{uid}' }`; a `recordId` refusal named `filter: { id: '{record.id}' }`) and the `FlowSchema` example; all are migrated in this change. hotcrm was not measured. + + **Still accepted, unchanged.** Every literal operand, every query placeholder, a filter's keys, operators and combinators, an empty filter, and a filter on `create_record`, which has none. The `filters` alias still converts to `filter` at load. + + ### The kit + + - **The contract.** `GetRecordConfigSchema`, `UpdateRecordConfigSchema` and `DeleteRecordConfigSchema` judge each filter operand: an envelope must be a valid CEL value envelope, and a `{…}` token in a literal is refused by the value-slot judge in its filter mode, naming the operand's envelope and what `null` does there. Each `filter`'s `describe()` and TSDoc is restated, and so are the `recordId` guidance and the run-user remedy, which named a single-brace filter value. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains a `filter` row per node, with the new `filterOperands` flag: both walks emit each operand at its own path (`filter.amount.$gt`, `filter.id.$in[0]`), never the filter itself. `GetRecordConfigSchema` and `DeleteRecordConfigSchema` join `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`. + - **The executor.** The CRUD executors parse their config as authored, evaluate each operand envelope through `AutomationEngine.evaluateValueEnvelope` before the query, hand every other operand on as written, and refuse the node — as a guard a `fault` edge cannot route — when an operand evaluates to `null`. The single-brace `interpolateFilter` is deleted. + - **The lint.** `objectstack validate` refuses a token at each operand position and judges an operand envelope as CEL through the shared judges; the wrong-syntax hints at a filter operand name its envelope; `flow-filter-token-unknown` retires. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 968acc8: An `http` node reads each config key by what it is now. Its `url` and every `headers` value are text slots, like a notify `title`: they render `{{ }}` holes, `'https://api.example.com/v1/orders/{{ record.id }}'`. Its `body` is a value slot: a CEL value envelope, `{ dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }`, that the executor evaluates in the run's scope to the payload, or a literal sent as written. Its `method`, `durable`, `timeoutMs` and `signingSecret` are literals, read as written. A single-brace `{…}` token at any of them is refused, naming its `{{ }}` spelling at a text slot, its CEL spelling in the body, and at a literal the remedy to write the value: at `objectstack validate`, at `registerFlow` and by the executor, except at `signingSecret`, which the build doors leave to the run because the flow credential store may hold the secret it signs with, and which the executor's parse refuses there. + + Clause-②: yes (narrowing: a {…} token is refused at http url, each headers value, any string of a literal body, method, durable, timeoutMs and signingSecret, and as a whole headers map, and a body value that names a dialect must be a valid CEL value envelope; widening: the exported FlowNodeTextSlot type gains the optional eachValue field, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains http, and the published HttpConfig JSON Schema marks body as a value slot) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a url and a header are text. Until now the executor handed its whole config to the single-brace interpolator and parsed the result, so every key read the retired dialect, a whole token in a typed key (`timeoutMs: '{t}'`) resolved to its value's type, and a CEL envelope written as the body was sent to the endpoint as the object it spells. + + **The request is rendered once.** The executor parses the config as authored — after the flow credential store's `signingSecret`, unchanged — then renders the url and the headers and evaluates the body, before either mode. The durable outbox enqueues exactly the request the inline call would send, and nothing renders at delivery. + + **What changes where a value may be absent.** A url that renders to no text refuses the node, as an empty one did. A header that renders to no text is not sent, where the template sent it empty. In the body an absent variable or key fails the run under CEL, naming it, where the template wrote `null` for a whole token and nothing inside text; guard a value that may be unset, `has(record.owner) ? record.owner : null`. CEL holds a map's values to one type, so in a CEL map literal a literal or a text beside a field read is wrapped in `dyn(…)`. + + **The literals.** `method`, `durable`, `timeoutMs` and `signingSecret` passed through the interpolator too, but no shipped flow computed one: the census found no `{…}` token at any of them in this repository's examples, docs or dogfood fixtures, only in the tests that pinned the interpolation itself. So no value slot is built there. They are read as written, and a `{…}` token in one is refused with the literal's remedy; an envelope there is refused too, since nothing evaluates it. A token written as the whole `headers` map, which the template resolved to a map, is refused the same way. A signing secret that arrived through a template (`signingSecret: '{signing_key}'`) is refused: write the secret itself, which a flow saved through the metadata API keeps in the write-only flow credential store, or `''` to send unsigned on purpose. + + **What does not change.** Every literal is sent as written. The method still defaults to `GET` inline and `POST` durable. An authored `signingSecret: ''` still sends unsigned, and a secret the credential store holds still takes the literal's place. A url or header with no token, and a body with no token, behave as before. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `url: 'https://api.example.com/v1/orders/{record.id}'` | `url: 'https://api.example.com/v1/orders/{{ record.id }}'` | a `Date` in the url renders as its ISO text, not JSON-quoted | + | `headers: { 'X-Ref': 'r-{record.id}' }` | `headers: { 'X-Ref': 'r-{{ record.id }}' }` | a header that renders to no text is not sent | + | `body: { id: '{record.id}', status: 'done' }` | `body: { dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }` | an absent `id` fails the run instead of sending `null` | + | `body: { text: 'Done: {record.title}' }` | `body: { dialect: 'cel', source: "{'text': 'Done: ' + record.title}" }` | an absent `title` fails the run instead of sending `'Done: '` | + | `body: '{payload}'` | `body: { dialect: 'cel', source: 'payload' }` | an absent `payload` fails the run instead of sending no body | + | `timeoutMs: '{timeout}'`, `durable: '{flag}'`, `method: '{verb}'` | the value itself: `timeoutMs: 5000`, `durable: true`, `method: 'POST'` | the key is a literal now; nothing computes it | + | `headers: '{headers}'` | the map itself: `headers: { 'X-Ref': '{{ record.id }}' }` | a map is not computed into `headers` | + | `signingSecret: '{signing_key}'` | the secret itself, or `''` to send unsigned | a secret read from a flow variable is refused at the run | + + **The one-line fix: double the braces in `url` and the `headers` values, write `body` as one CEL value envelope that builds the payload, and write `method`, `durable`, `timeoutMs` and `signingSecret` as the values themselves.** + + **Who is affected, measured.** In this repository, at the base: the showcase example (three bodies holding four tokens) and the flows guide (one body); both are migrated in this change. No url, header, method, `durable`, `timeoutMs` or `signingSecret` in the examples, docs or dogfood fixtures carries a token. hotcrm was not measured at these keys. + + **Newly refused in the body.** An object naming a `dialect` was data before and was sent as written; it is an expression now, evaluated per run, and a malformed one is refused. An envelope nested inside a literal body is still data, sent as written. + + **Still accepted, unchanged.** Every literal at these keys, and the published shapes of `url` (a string), `headers` (a map of strings), `method`, `durable`, `timeoutMs` and `signingSecret`. + + ### The kit + + - **The contract.** `HttpConfigSchema`: `url` and each `headers` value refuse a single-brace token through the text-slot judge; `body` takes `FlowValueSlotSchema`; `method`, `durable`, `timeoutMs` and `signingSecret` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. The token refusals at `method`, `durable`, `timeoutMs` and a whole `headers` map are marked read-as-written, so the build doors' value arm reports them at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains the `http` body row. `FLOW_NODE_TEXT_SLOTS` gains `http.url` and `http.headers`, whose new `eachValue` flag reads each value of the map at its own path (`headers.X-Ref`). `HttpConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the body's marker to the reconciliation ratchet. The build doors' carve-out for a config parsed after interpolation is gone with the interpolation. + - **The executor.** The `http` node parses its config as authored, renders the url and headers through the text renderer, evaluates the body through the shared value-slot resolver, and reads the literals as written; it no longer imports the single-brace interpolator. + - **The lint.** The `flow-double-brace-interpolation` hint names the http url and headers among the text slots, and at an http literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract. + - **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused`, `flow-text-slot-unbound-dollar-root-refused` and `flow-builtin-node-config-values-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 111ffff: A `notify` node's computed values are value slots now: `recipients`, `sourceId` and the values of `templateData` take a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, that the executor evaluates in the run's scope, or a literal written as it is. `recipients` holds one recipient or a list of them, each entry a string or an envelope, and an entry that evaluates to a list adds each of its recipients. The node's link, `actionUrl`, is a text slot like `title`: it renders `{{ }}` holes, `'/crm_case/{{ record.id }}'`. Its `sourceObject`, `actorId` and `payload` are literals, read as written: no flow computed one, so nothing is evaluated there. A single-brace `{…}` token at any of these positions is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling at a value slot, its `{{ }}` spelling at `actionUrl`, and, at a literal, the remedy to write the value. + + Clause-②: yes (narrowing: a {…} token is refused at notify recipients (the value or a list entry), templateData values, sourceId, actionUrl, sourceObject, actorId and any string of a payload value, a templateData value that names a dialect must be a valid CEL value envelope, and a CEL value envelope written as a payload value is refused; widening: recipients and sourceId accept a CEL value envelope they refused, a recipients list accepts envelope entries, and their published JSON Schema and inferred types gain the envelope arm) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (four positions accept a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a link with holes is text. Until now the executor ran each of these positions through the single-brace interpolator, and one of its answers reached nobody: a whole token holding a list, written as an entry of a `recipients` list — `recipients: ['{owners}']` with `owners = ['u1', 'u2']` — was handed to the messaging service as ONE recipient, `'u1,u2'`, and the run reported success. A token holding a record there became the recipient `'[object Object]'`, and so did one in a link or a `sourceId`. + + **What changes where a value may be absent.** A recipient envelope that evaluates to `null` or a blank string adds no recipient, as a token that resolved to nothing did, and a node left with none fails, naming the expressions that evaluated to nothing. An absent variable or key fails the run under CEL, naming it, where the template dropped the recipient; guard a field that may be unset, `has(record.assignee) ? record.assignee : null`. A relation hop on a scalar id (`record.account.owner` without the start node's `expand`) fails the evaluation itself, naming the key it could not read, where the template resolved it to nothing. `sourceId` must evaluate to a string; `null` leaves the key unset, and a half-specified click-through pair is still dropped. A recipient, or a `sourceId`, that evaluates to anything else — a map, a number — fails the node, naming what it evaluated to. + + **The literals.** `sourceObject`, `actorId` and the values of `payload` passed through the single-brace interpolator too, but no flow computed one: the census found no `{…}` token at any of them, in this repository or in hotcrm. So no value slot is built there. They are read as written, and a `{…}` token in one — in any string of a `payload` value, at any depth — is refused with the literal's remedy: a single-brace token is not resolved here; write the value. An envelope written there is refused too, since nothing evaluates it (a deeper one inside a `payload` value is data, as it always was). + + **What does not change.** A literal at every value slot is written as it is: a recipient string (`'team:ops'`, a user id, an email address) is one recipient, as before, and is not split. `template`, `topic`, `channels` and `severity` are read raw, as before. An explicit `actionUrl` still wins over the link synthesized from `sourceObject` / `sourceId`, and a link that renders no text yields to it, as an empty one did. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `recipients: '{record.owner}'` | `recipients: { dialect: 'cel', source: 'record.owner' }` | an absent `owner` fails the run instead of leaving the node without a recipient | + | `recipients: ['{record.assignee}']` where it may be unset | `recipients: [{ dialect: 'cel', source: 'has(record.assignee) ? record.assignee : null' }]` | none: the `null` is dropped, as the unresolved token was | + | `recipients: ['{owners}']` (a list variable) | `recipients: [{ dialect: 'cel', source: 'owners' }]` | each of its recipients is notified, where the template sent one joined string | + | `recipients: ['{$User.Id}']` | `recipients: [{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }]` | none: a run with no user adds no recipient | + | `sourceId: '{record.id}'` | `sourceId: { dialect: 'cel', source: 'record.id' }` | an absent id fails the run instead of dropping the link | + | `sourceObject: '{record.object_name}'`, `actorId: '{record.owner}'`, `payload: { who: '{record.owner}' }` | the value itself: `sourceObject: 'crm_case'` | the key is a literal now; nothing computes it | + | `templateData: { name: '{record.name}' }` | `templateData: { name: { dialect: 'cel', source: 'record.name' } }` | the template gets `null` (a guard's answer) where the key was absent | + | `actionUrl: '/crm_case/{record.id}'` | `actionUrl: '/crm_case/{{ record.id }}'` | a `Date` in the link renders as its ISO text, not JSON-quoted | + + **The one-line fix: write each `notify` value position as a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, double the braces in `actionUrl`, and write `sourceObject`, `actorId` and `payload` as the values themselves.** + + **Who is affected, measured.** In this repository, at the base: the showcase example (16 recipient strings, 7 `sourceId`s and 4 links), the todo example (2 recipients and 2 `sourceId`s), the flows guide, the email-templates and common-patterns guides, two dogfood fixtures and test fixtures; all are migrated in this change, and a recipient field that may be unset is guarded. hotcrm at `1d7148bf2d` writes 19 notify nodes in 15 flow files, each with one `{…}` recipient (2 of them `{$User.Id}`), 35 `{…}` `templateData` values and a link with holes; it is on spec 17.7.0, so nothing there moves until it upgrades. + + **Newly accepted.** A CEL value envelope at `recipients` (the value, or each entry of a list) and `sourceId`: their published JSON Schema gains the envelope arm, and so do the inferred `NotifyConfig` types. At a `templateData` key an object naming a `dialect` was data before; it is an expression now, evaluated per run, and a malformed one is refused. `sourceObject`, `actorId` and `payload` keep their published shapes (a string, a string, a map of any values), with no envelope arm. + + **Newly refused at the literals.** A `{…}` token in `sourceObject`, in `actorId` or in any string of a `payload` value, and a CEL value envelope written as a `payload` value, which was handed on as the object it spells. + + **Still accepted, unchanged.** Every literal at these positions. + + ### The kit + + - **The contract.** `NotifyConfigSchema`: `recipients` takes a string, a list of strings and envelopes, or one envelope; `templateData` takes `FlowValueSlotSchema` per key; `sourceId` takes a string or an envelope; `actionUrl` refuses a single-brace token through the text-slot judge; `sourceObject`, `actorId` and `payload` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. That refusal is marked read-as-written, so the build doors' value arm reports it at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains three `notify` value rows. `recipients` carries `eachElement`: a list is read entry by entry, at its index (`recipients[1]`). `FLOW_NODE_TEXT_SLOTS` gains `notify.actionUrl`. `NotifyConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the markers to the reconciliation ratchet. + - **The executor.** The `notify` node evaluates each envelope through the shared value-slot resolver, renders `actionUrl` through the text renderer and reads the literals as written; it no longer imports the single-brace interpolator. + - **The lint.** The `flow-double-brace-interpolation` hint names `actionUrl` among the text slots, and at a notify literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract. + - **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 12b9daf: A `screen` node's three positions that hand a value to the client are value slots now: an object form's `defaults` (each field's prefill), a field's `defaultValue`, and an object form's `recordId` (the record an `edit` form opens). Each value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`, that the executor evaluates in the run's scope before the screen goes on the wire, so the client receives the value it computes, never the envelope; or a literal, served as written. A `{…}` template token there is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling, as in every other value slot. `recordId` gains the envelope arm, which it refused before this change, and its literal stays a string. + + Clause-②: yes (narrowing: a {…} token is refused at screen.defaults.*, a field's defaultValue and screen.recordId; widening: screen.recordId accepts a CEL value envelope it refused, and its published JSON Schema and inferred type gain the envelope arm) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (`recordId` accepts a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. A screen's prefill and its record id are computed values handed to the client. Until now the executor ran each through the single-brace interpolator: a `{token}` resolved there, an envelope written in `defaults` or a `defaultValue` went to the client as the object `{ dialect: 'cel', source: '…' }` it spells, and `recordId` was the text of whatever the token found, so a token that found a record served the id `[object Object]`. + + **What changes where a value may be absent.** Where a whole token resolved to nothing, the template served nothing there. Under CEL an absent variable or key fails the run at the screen, with its source, and the guarded form `has(vars.x) ? vars.x : null` serves `null`: + + - In an object form's `defaults`, the form keeps a `null` prefill as a value and opens the field blank, over the object field's own `defaultValue`, which it applied when the prefill left the field out. To keep that default for an absent value, write it in the guard: `has(vars.x) ? vars.x : 'prospecting'`. + - A field's `defaultValue` seeds the field with `null`. + - `recordId` must evaluate to the record's id, a non-blank string. Anything else, `null` included, fails the run at the screen. The template served such a screen with no record id, and its `edit` form opened with no record and could not save. Where the record may be absent, route around the screen with a `decision` on the value instead of guarding the id. + + The refusal at each position says what it serves. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `defaults: { account: '{account_id}' }` | `defaults: { account: { dialect: 'cel', source: 'account_id' } }` | an absent `account_id` fails the run; guarded, it prefills `null` and the field opens blank over the object field's `defaultValue` | + | `defaults: { stage: '{x}' }` where the object field's default should apply when `x` is absent | `defaults: { stage: { dialect: 'cel', source: "has(vars.x) ? vars.x : 'prospecting'" } }` | the default is written in the guard, because a `null` prefill is a value | + | `defaults: { parent: '{rec}' }` | `defaults: { parent: { dialect: 'cel', source: 'rec' } }` | the record is served as a map, a list as a list | + | `fields: [{ name: 'email', defaultValue: '{lead.email}' }]` | `fields: [{ name: 'email', defaultValue: { dialect: 'cel', source: 'lead.email' } }]` | guarded, it prefills `null` | + | `recordId: '{record.id}'` | `recordId: { dialect: 'cel', source: 'record.id' }` | the envelope must evaluate to a non-blank string id: `null`, a map or a list fails the run at the screen | + | `recordId: 'acc_1'` | unchanged | a string literal is the id | + + **The one-line fix: write each value of a screen's `defaults`, each field's `defaultValue` and an object form's `recordId` as a CEL value envelope; write the object field's default into the guard where it should apply, and route around a screen whose record may be absent.** + + **Who is affected, measured.** In this repository: the CRM example's lead conversion (`crm_convert_lead_wizard`: 3 `defaults` values in its two object-form steps), the object-form example in the flows guide, and test fixtures; all are migrated in this change. hotcrm's sites at these positions were not measured. + + **Newly accepted.** A CEL value envelope at `recordId`, which `z.string()` refused before this change: the published JSON Schema for `recordId` gains the envelope arm (a string, or the envelope object), and so does the inferred `ScreenConfig['recordId']`. + + **Still accepted, unchanged.** A CEL value envelope at `defaults` and at a field's `defaultValue`, and every literal at all three, a string `recordId` included. + + ### The kit + + - **The contract.** `ScreenConfigSchema.defaults` and a field's `defaultValue` take `FlowValueSlotSchema`, and `recordId` takes a string or a CEL value envelope (`@objectstack/spec/automation`), so the executor's contract parse refuses a token as a guard. Each published JSON Schema declares the dropped refinement (`dropped-refinements.baseline.json`). + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains `screen.defaults.*`, `screen.fields[].defaultValue` and `screen.recordId` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries `ScreenConfigSchema`. `registerFlow`, `objectstack validate` and `flow-bare-dollar-reference` / `flow-double-brace-interpolation`'s value-slot hints follow the ledger, so all of them cover the three positions. The build doors keep refusing a `recordId` literal that is not a string, as before. + - **The executor.** The `screen` node resolves each position through the value-slot resolver the CRUD `fields` map and a callee's input share: an envelope is evaluated by `AutomationEngine.evaluateValueEnvelope` in the run's scope, before the screen is served. A screen that passes through without pausing goes on no wire, and evaluates nothing. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 7958bdf: No flow position reads the single-brace `{…}` template dialect any more, and its resolver is deleted (ADR-0032 Sequencing 6: "delete the single-brace resolver"). `AutomationEngine.evaluateCondition` evaluates every condition as CEL: a condition carrying a `{var}` hole is refused as the CEL brace trap, naming its source, and a `template` or `flow` envelope is refused before evaluation, where the deleted path substituted the variables and compared the text. A literal in a value slot is handed on as written: a `Date` in a code-defined flow reaches the write as the `Date`, where the deleted interpolator wrote `{}`. + + Clause-②: no (narrowing) + + + + **BREAKING**: a narrowing of a published runtime method and of published lint exports, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 3 keeps one template delimiter, `{{ }}`, and its Sequencing 6 deletes the single-brace resolver. The earlier changes on this line retired the dialect position by position — every value slot, the text slots, each CRUD filter operand — so the resolver's last readers were two calls that saw literals only and a condition path that only a direct call reached. + + **What changes.** + + - **Conditions.** `AutomationEngine.evaluateCondition(condition, variables, context?)` reads every condition as CEL — bare text, an envelope with `dialect: 'cel'`, or one naming no dialect. The template path it kept for a string carrying a `{var}` hole is deleted: such a string is refused as a CEL fault that names its source and says not to wrap field references in `{…}`, and an envelope with `dialect: 'template'` or `dialect: 'flow'` is refused before evaluation. `registerFlow` already refused both at every condition slot (a start node's `condition`, an edge `condition`, a `decision` branch's `expression`, a screen field's `visibleWhen`), so no registered flow changes. What changes is a direct caller: a plugin calling the method, or `@objectstack/verify`'s `stack.automation.evaluateCondition`, which hands the condition over as given. + - **Value-slot literals.** A literal in a value slot — a `fields`, `input` or `templateData` value, an `assignment` value in any of its three shapes, a `notify` recipient, an `http` `body`, a screen's `defaults`, `defaultValue` or `recordId` — is handed on as a copy of what was written (arrays and plain objects copied, every other value as it is). The deleted interpolator walked every object's own entries, so a `Date` written in a code-defined flow reached the write as `{}`; it reaches it as the `Date`. + - **`@objectstack/lint`.** `validateFlowTemplatePaths` reads the `{{ }}` holes of a node's strings and no single-brace token; a CRUD node's `filter` is not scanned, since each operand is a value slot that renders no template. So `flow-template-unknown-field` and `flow-template-lookup-traversal` report only `warning` now: their `error` at a filter operand, and every finding on a single-brace token, are gone, and the value-slot and text-slot judges refuse those tokens at the same doors as `expression-invalid`. Messages and hints spell a hole `{{ record.x }}`. `flow-double-brace-interpolation` and `flow-bare-dollar-reference`, at a string outside every value slot, text slot and declared literal (a plugin node's own config, a `connector_action` input), say the value is read as written, where they prescribed a `{var}` token nothing reads. The lint's mirror of the retired grammar is removed with its exports. + - **`@objectstack/spec`.** The `FlowSchema` example's `connector_action` input holds a literal, since nothing reads a token there. The upgrade guide's step-18 text-slot entry says no other flow string reads the single-brace dialect, where it said every other one kept it, and its value-slot entry names every value slot the retirement reached on this line, where it named only the `assignment` values and the CRUD `fields` map. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `engine.evaluateCondition('{amount} > 100', vars)` | `engine.evaluateCondition('amount > 100', vars)` | the template substituted text and compared it; CEL compares the values and fails the run on an absent variable | + | `"{status} == 'open'"`, `'{status} == open'` | `"status == 'open'"` | a string literal is quoted | + | `{ dialect: 'template', source: "{flag} == true" }`, `dialect: 'flow'` | `{ dialect: 'cel', source: 'flag == true' }` | — | + | `classifyFlowTemplateToken(inner)` (`@objectstack/lint`) | `valueSlotTemplateRefusals(value)` / `textSlotTemplateRefusal(text)` (`@objectstack/spec/automation`), which refuse a token with the spelling that replaces it | the grammar it classified is read by nothing | + | `FLOW_TEMPLATE_DATE_FUNCTIONS`, `FLOW_TEMPLATE_VALUE_FUNCTIONS` | the CEL stdlib: `isoDate(today())`, `isoDatetime(now())`, `round`, `floor`, `ceil`, `abs`, `min`, `max` | — | + | `FlowTemplateTokenVerdict` | nothing: the judges' reading of the retired grammar is internal to `@objectstack/spec` | — | + + **The one-line fix: write a condition as CEL, without `{…}` around a reference.** + + **Who is affected, measured.** In this repository at the base: no braced condition and no `template` or `flow` condition envelope in `examples/`, the docs or any package source; the deleted path's own pins in `@objectstack/service-automation` (`engine.test.ts`, `nested-region-parity.test.ts`) and `@objectstack/verify`'s door test are re-pinned to the refusal here. No authored `Date` literal in a flow was found; a compiled artifact serialises one to text, so only a flow handed to `registerFlow` in code carried one. Neither repository imports the removed lint exports. hotcrm at `0f84c9c` carries no braced condition and no `connector_action` input; its flows still author the single-brace dialect at value and text positions earlier changes on this line refuse, which its own upgrade migrates. + + ### The kit + + - **The engine.** `AutomationEngine.evaluateCondition` (`@objectstack/service-automation`): the template path, its hole discriminator and its text comparison are deleted. The value-slot resolvers' literal arm and the `assignment` executor copy a literal (`copyLiteral`, `builtin/value-slot-map.ts`). The single-brace interpolator (`interpolate`, `interpolateString`, `resolveToken`, its function table and `FlowExpressionFunctionError`), which the package did not export, is deleted with its tests. + - **The lint.** `validateFlowTemplatePaths` and the two wrong-syntax hints (`lint-flow-patterns.ts`); `classifyFlowTemplateToken`, `FLOW_TEMPLATE_DATE_FUNCTIONS`, `FLOW_TEMPLATE_VALUE_FUNCTIONS` and `FlowTemplateTokenVerdict` are removed. + - **The verify handle.** `stack.automation.evaluateCondition`'s documentation states the engine's reading: CEL, a `{var}` hole refused. + - **ADR-0087.** No new entry: the step-18 D3 entries `flow-value-slot-template-dialect-refused` and `flow-text-slot-single-brace-refused` record the retirement. +- d85615d: A flow VALUE slot now refuses the date macros as well — `{TODAY()}`, `{NOW()}`, with an optional `± N` day offset — so it reads no single-brace `{…}` token at all. Each is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL string form: `isoDate(today())`, `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, `isoDatetime(now())`, `isoDatetime(addDays(now(), N))`. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The template dialect is retired from the value slots: one dialect per slot, with a remedy for every spelling. The date macros were the last spelling kept, because CEL had no string form for a timestamp: `today()` and `now()` reach the data engine as a `Date` object, not the ISO text the macros wrote. The CEL stdlib now has one — `isoDate(t)` (`YYYY-MM-DD`) and `isoDatetime(t)` (`YYYY-MM-DDTHH:mm:ss.sssZ`), on the UTC calendar the macros rendered on — so the macros can be refused with a remedy that writes the same text. + + **What is refused.** In every value slot — `create_record` / `update_record` `fields`, the `assignment` node's `assignments` map, and both legacy `assignment` shapes — a string carrying a date macro, alone or beside other text and tokens. A string that mixed a date macro with another token used to be kept whole, the run-user token included (`'Due {TODAY()} by {$User.Id}'`); it is refused now, with a remedy for every token. + + **Where the remedy parts from the template.** For no offset or a whole number of days the CEL form writes the template's text byte for byte, at every instant (measured live through both engines over DST transitions, month and year ends and a leap day). The refusal names the three places it does not: + + - a fractional offset: the template added it to the day of the month and truncated the sum, `addDays` truncates the offset itself — going back, a day apart on most days of a month — and `daysFromNow(1.5)` / `daysAgo(1.5)` are refused at build; + - a variable offset (`{TODAY() + days}`): the template looked the offset up as one variable name and added 0 days, without a word, when it found none or the value was not a number; CEL reads the path, and fails the run on an absent variable or a value that is not a number; + - an offset that is neither a number nor a variable name (`{TODAY() + 3d}`): the template added 0 days. + + **Where an envelope is literal data, the remedy names none there.** The refusal used to prescribe an envelope at whatever position it refused a token. Inside an object or list value, and in the legacy `assignment` shapes, the executor reads an envelope as data: the prescribed metadata registered and stored the envelope object. The refusal there now names what evaluates — the whole value built as one CEL map or list literal (mixed values each wrapped in `dyn(…)`), or the assignment moved into the `assignments` map. This holds for every token, not only the date macros. An envelope-shaped object in those positions is still data, unchanged. + + **The `has()` guard a refusal names now holds for an absent variable.** For a path that may be absent, the refusal named a guard on its last key, `has(source.id) ? source.id : null`. `has()` evaluates everything but its last selection, so where the variable itself was never bound — an `isInput` variable the caller left out, with no default — that guard failed the run (`Unknown variable: source`). The guard now tests each step off `vars`, which holds only the variables the run has bound: `has(vars.source) && has(vars.source.id) ? vars.source.id : null` answers `null` there, and where a key on the way is absent or `null`. `flow-bare-dollar-reference`'s value-slot hint carries the same guard. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{TODAY()}'` | `{ dialect: 'cel', source: 'isoDate(today())' }` | nothing | + | `'{TODAY() + 7}'`, `'{TODAY() - 3}'` | `{ dialect: 'cel', source: 'isoDate(daysFromNow(7))' }`, `{ dialect: 'cel', source: 'isoDate(daysAgo(3))' }` | nothing for a whole number of days; a fraction truncates differently, and `daysFromNow` / `daysAgo` refuse one at build | + | `'{NOW()}'` | `{ dialect: 'cel', source: 'isoDatetime(now())' }` | nothing | + | `'{NOW() + 2}'`, `'{NOW() - 1}'` | `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), 2))' }`, `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), -1))' }` | nothing — `addDays` keeps the time of day, where `daysFromNow` would land on midnight | + | `'{TODAY() + days}'` | `{ dialect: 'cel', source: 'isoDate(addDays(today(), days))' }` | an absent `days`, or one that is not a number, fails the run where the template silently added 0 | + | `'Due {TODAY()} by {$User.Id}'` | `{ dialect: 'cel', source: "'Due ' + isoDate(today()) + ' by ' + current_user.id" }` | guard the run user where a flow can run without one | + | `payload: { due: '{TODAY()}' }` (a string inside an object value) | `payload: { dialect: 'cel', source: "{'due': isoDate(today())}" }` | an envelope written at `payload.due` would be stored as an object | + | the guard an earlier refusal named, `has(source.id) ? source.id : null` | `has(vars.source) && has(vars.source.id) ? vars.source.id : null` | `null` where `source` was never bound, where the last-key guard failed the run | + | a text slot's `assignments: { due: '{TODAY() + 7}' }`, then `'Due {{ due }}'` | `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' } }`, then `'Due {{ due }}'` | nothing for a whole number of days | + + **The one-line fix: write a date macro as `isoDate(…)` / `isoDatetime(…)` in a CEL envelope, with a whole number of days.** + + **Who is affected, measured.** This repository's examples carry no date macro in a value slot; its own test fixtures and docs that did are migrated in this change. The census taken for this retirement's first change measured 15 date-macro value-slot sites in hotcrm at `c529de2` (8 `{NOW()}`, 3 `{TODAY()}`, 3 `{TODAY() + N}`, 1 `{TODAY() + var}`); they migrate there. Deployed metadata and other repositories were not measured here. + + **Still accepted, unchanged.** A CEL value envelope and every literal. + + ### The kit + + - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`) keep no token; the text-slot judge's date-macro remedy (`textSlotTemplateRefusal`) names the CEL envelope. `FlowValueSlotSchema` / `AssignmentValueSchema` say so in their descriptions. + - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the date macros and name their forms) and `flow-text-slot-single-brace-refused` (its date remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. + - **`@objectstack/lint`.** `flow-double-brace-interpolation`'s hint in a value slot names the CEL envelope for the holes the author meant, where it named `{record.title}`, which the slot refuses; `flow-bare-dollar-reference`'s value-slot hint asks the judge at the string's own position, so a nested or legacy-shape string gets the spelling that evaluates there, and carries the judge's step-by-step `has()` guard. + - **`@objectstack/service-automation`'s README.** Its *Expressions* section names the date macros' CEL forms. +- 6a06e3b: A flow VALUE slot now refuses the run-user template token `{$User.}` as well, and every CEL expression in a flow sees **`current_user`**, the run's user. `{$User.Id}` in a `create_record` / `update_record` `fields` value or an `assignment` value is refused at `objectstack validate`, at `registerFlow` and by the executor, naming `current_user.id` and, for a flow that can run without a user, its guard; every other `$User` path is refused saying it never resolved. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The template dialect is retired from the value slots, one dialect per slot, with a remedy for every spelling. The run user was the one path spelling kept, because the flow CEL scope bound no user: `current_user.id` passed `objectstack validate` and `registerFlow`, then failed the run with `Unknown variable: current_user`. The scope now binds it, so the token can be refused with a remedy that evaluates. + + **What `current_user` is.** In every flow CEL expression — a start-node `condition`, an edge `condition`, a `decision` condition, a screen field's `visibleWhen`, an `assignment` or `fields` value envelope — `current_user` is the run's user, built through `createEvalUser` from what the run context holds: `id` (the run's `userId`), `positions`, `organizationId` (the run's `tenantId`) and the derived `isPlatformAdmin`. No run carries an email or a name, so neither is bound. A run with no user — a schedule, a record change made by a system write — sees `null`, never a stand-in user: `current_user.id` then fails the run loudly, and `current_user != null` can guard it. A `runAs: 'system'` run sees the user that triggered it, as `{$User.Id}` did. A flow variable named `current_user` is shadowed by the binding, as one named `vars` is by the variables namespace; both are read as `vars["current_user"]` / `vars["vars"]`, and the refusal of a `{vars.…}` or `{current_user.…}` path token prints that spelling. + + **The expression remedy.** A refused `{…}` arithmetic token's CEL spelling now reads every variable path in it the way a lone path token's does — `{int * 2}` is `vars["int"] * 2`, `{items.0 * 2}` is `items[0] * 2` — instead of rewriting only its divisors, which printed envelopes that did not evaluate. + + **Still accepted, unchanged.** A CEL value envelope and every literal. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{$User.Id}'` | `{ dialect: 'cel', source: 'current_user.id' }` | nothing when the run has a user | + | `'{$User.Id}'`, in a flow that can run without a user | `{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }` — or skip the node with `current_user != null` as a start condition or a `decision` | the guarded form writes `null` where the template wrote nothing, so on `update_record` it clears a stored value the template left alone | + | `'{$User.Email}'`, `'{$User.Name}'`, any other `$User` path | a `get_record` on `sys_user` with `filter: { id: { dialect: 'cel', source: 'current_user.id' } }` and `outputVariable: 'me'`, then `{ dialect: 'cel', source: 'me.email' }` | these never resolved in any shipped run: they wrote nothing | + | `'Owner: {$User.Id}'` | `{ dialect: 'cel', source: "'Owner: ' + current_user.id" }` | in a user-less run, write the hole as `(current_user != null ? current_user.id : '')` | + | a text slot's `assignments: { by: '{$User.Id}' }`, then `'By {{ by }}'` | `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `'By {{ by }}'` | nothing when the run has a user; where the flow can run without one, compute `current_user != null ? current_user.id : null`, which renders nothing where the template did | + + **The one-line fix: write `current_user.id` where you wrote `{$User.Id}`, and guard it where the flow can run without a user.** + + **Who is affected, measured.** This repository's two authored value-slot sites are migrated in this change: the `examples/app-todo` quick-add screen flow (a screen flow always has a user, so the bare read) and the `os explain flow` catalog example, an `update_record` under a record-after-create trigger, which a system write fires with no user; it now gates on `current_user != null` in its start condition, so the stored value is left alone there as before. `examples/app-showcase`'s `{$User.Id}`, a `notify` `recipients` entry, is migrated to `current_user` with the `notify` value slots on this line. Other repositories and deployed metadata were not measured here. + + ### The kit + + - **The binding.** `AutomationEngine.celScope` (`@objectstack/service-automation`); `evaluateCondition` and `evaluateValueEnvelope` take the run context as an optional last argument, which every engine site and the `assignment`, `decision`, `create_record` and `update_record` executors pass. Without one, `current_user` is `null`. + - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`); the text-slot judge's run-user remedy (`textSlotTemplateRefusal`) now computes the id with the CEL envelope. + - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the run-user paths), `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` (their run-user remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. + - **`@objectstack/cli`.** `os explain flow`'s example writes `current_user.id` and gates on `current_user != null`. + - **`@objectstack/lint`.** `flow-bare-dollar-reference`'s hint for a bare `$name.path` in a value slot names the CEL envelope the value-slot refusal writes for it — `current_user.id` and its guard for `$User.Id`, the variable the reference names for any other — where it named `{source.id}` and `{$User.Id}`, both refused there. A text slot's hint keeps the `{{ }}` hole, and the hint at a node's literal, or at any other position, names the value itself. + - **`@objectstack/service-automation`'s README.** Its *Expressions* section states the value-slot envelope and `current_user`. +- 0e9e7b7: A flow VALUE slot no longer reads the single-brace `{…}` template dialect: a `create_record` / `update_record` `fields` value or an `assignment` value that carries a `{…}` token is refused at `objectstack validate`, at `registerFlow` and by the executor, with the CEL spelling of each token. A computed value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`; a string is the literal text it spells. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** Since #19938 the value slots also evaluate a CEL value envelope, so a flow had two dialects for one job — two function sets, two meanings of `/`, and a template that wrote nothing where CEL refuses. The maintainer's ruling D on the flow expression dialects put the template's retirement from these slots on the v18 train, with a remedy for every spelling and an automatic conversion only where one is lossless (ADR-0087 D2). + + **What is refused.** In every value slot — `create_record.fields.*`, `update_record.fields.*`, the `assignment` node's `assignments` map, and the two legacy `assignment` shapes the executor still reads (the `assignments: [{ variable, value }]` array and the bare `{ : }` config) — a string, or a string at any depth of an array or object value, that carries a `{…}` token the interpolator would resolve. One judge says it everywhere (`flowNodeValueTemplateRefusals` / `valueSlotTemplateRefusals` in `@objectstack/spec/automation`): `FlowValueSlotSchema`, `AssignmentValueSchema`, `CreateRecordConfigSchema`, `UpdateRecordConfigSchema` and `AssignmentConfigSchema` refuse it at the value's path; `objectstack validate` reports it as `expression-invalid` at `error`, located at the node and `config.`, which also refuses it at the metadata save door; `registerFlow` refuses the flow (a stored flow carrying one is skipped at boot with a warn naming it); and the `create_record` / `update_record` / `assignment` executors refuse the node before writing anything. Each refusal leads with `VALUE_SLOT_TEMPLATE_REFUSAL` and then names the CEL spelling of the string's tokens. + + **Not converted.** No D2 conversion rewrites any spelling: every token spelling authored in flows answers differently under CEL for some input (measured through both engines over the same variables, 13 of 25 probes the same, 12 different), so a rewrite would change what some flow writes. Where a value may be absent, whether the field should take nothing, `null` or a default was decided silently by the template; it is now the author's decision. + + **What is still accepted, unchanged.** A CEL value envelope and every literal (a token-free string, numbers, booleans, `null`, arrays, objects). A text slot (`notify` `title` / `message`, a screen `description`, …) reads `{{ }}` holes and refuses a single-brace token. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — guard one that may be absent a step at a time from `vars`: `has(vars.record) && has(vars.record.owner) ? vars.record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) | + | `'{items.0}'` | `{ dialect: 'cel', source: 'items[0]' }` | an empty list fails the run | + | `'{$error.message}'` | `{ dialect: 'cel', source: 'vars["$error"].message' }` | a `$`-named variable is read through `vars` | + | `'{round(x * 100) / 100}'` | `{ dialect: 'cel', source: 'round(x * 100) / 100.0' }` | CEL divides two integers as integers: keep a decimal operand on every division, or `123.46` becomes `123` | + | `'Renewal — {contract.number}'` | `{ dialect: 'cel', source: "'Renewal — ' + contract.number" }` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` | + | braces meant literally, `'{"a": 1}'` | `{ dialect: 'cel', source: "'{\"a\": 1}'" }` | a CEL string literal | + + **The one-line fix: write the CEL spelling the refusal names, and guard a value that may be absent.** + + **Who is affected, measured.** A TypeScript-AST census of every `create_record` / `update_record` `fields` value and `assignment` value (all three shapes, same-file spreads included): this repository at `959c209d5` carried 21 authored sites (`examples/**` and `packages/verify/src`) — 20 refused by this change (9 bare references, 10 dotted paths, 1 `$error` path, all migrated in this change) and 1 `{$User.Id}` (`examples/app-todo`), which the run-user refusal on this line refuses and migrates; hotcrm at `c529de2` carries 91 (2 of them through a same-file spread) — 71 refused by this change (31 bare references, 36 dotted paths, 4 text with holes) and 20 that the date-macro and run-user refusals on this line refuse (15 date macros, 5 `{$User.Id}`). Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `automation/flow-value-slot-template.ts` (`VALUE_SLOT_TEMPLATE_REFUSAL`, `valueSlotTemplateRefusals`, `flowNodeValueTemplateRefusals`), composed into the value-slot contracts in `automation/builtin-node-config.zod.ts`; one new dropped-refinement site (`automation/AssignmentConfig` `out.catchall`, the bare legacy shape's values). + - **The doors.** `AutomationEngine.registerFlow` and the `create_record` / `update_record` / `assignment` executors (`@objectstack/service-automation`); `validateStackExpressions` (`@objectstack/lint`), whose `warning` hint pointing a template expression at the envelope this refusal replaces. + - **The ledger.** The D3 semantic entry `flow-value-slot-template-dialect-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: no authored spelling maps losslessly. +- 2b61f2d: A flow TEXT slot — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message` — reads ADR-0032 §3's `{{ }}` template holes, rendered by the formula template engine over the flow's variables. A single-brace `{…}` token in one is refused at `objectstack validate`, at `registerFlow` and by the node's contract, with the `{{ }}` spelling of each token. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 3 fixes one template delimiter: "One delimiter, `{{ }}`; single `{ }` deleted." The notify pair was already typed as `template` slots, while the executor read the single brace, so a flow carried two template dialects and an author who met both mixed them. + + **What changes.** + + - **The renderer.** The text slots render through one renderer inside `@objectstack/service-automation` (package-internal; the package's public exports do not change): `{{ path }}` and `{{ path | formatter[:arg] }}` holes, every other character literal. A hole reads a flow variable by name (`{{ record.name }}`, a node output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or `{{ rows[0].subject }}`, and a `$`-named one: `{{ $error.message }}`). `null` and an absent path render nothing, an object or array renders as JSON, a `Date` as its ISO text; the ADR-0032 formatters make the rest explicit (`{{ amount | currency }}`, `{{ due | date:long }}`). A hole holds no logic. + - **The refusal.** One judge in `@objectstack/spec/automation` (`textSlotTemplateRefusal`, `flowNodeTextSlotSources`, `FLOW_NODE_TEXT_SLOTS`, `TEXT_SLOT_TEMPLATE_REFUSAL`): `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` refuse a single-brace token at the slot's key; `registerFlow` and `objectstack validate` (`expression-invalid`, `error`) refuse it with the same words and also compile every slot's holes, so `{{ a + b }}` or an unknown formatter is refused before a run. A stored flow carrying one is skipped at boot with a warn naming it. Past the doors, a hole that does not compile fails the node with a guard refusal, which a `fault` edge does not route. + - **`@objectstack/formula`.** A template hole's path may contain `$` (`{{ $error.message }}`): a widening of the hole grammar, so a host scope's `$`-named variable has a template spelling. An unbound one renders nothing, like any unknown path. + - **`flow-double-brace-interpolation`** no longer flags `{{ }}` on the text slots, and its hint names them as the only `{{ }}` positions; a bare `$ref.x` outside the holes of a text slot is flagged with the hole spelling. + - **Unchanged:** the value slots are CEL's. + + **Not converted.** No ADR-0087 D2 conversion rewrites `{x}` to `{{ x }}`. The two renderers were run over the same variables: a path renders the same text for a string, a number, a boolean, `null`, an absent key or variable, an ISO date string, an object or an array, but a `Date` value rendered JSON-quoted under the 17.x interpolator (`"2026-10-08T09:30:00.000Z"`, quotes included) and as its ISO text now, and a screen or `end` text that was one token holding an object, an array or a `Date` rendered `String(value)` (`[object Object]`). So the rewrite is the author's to check; the D3 record is the semantic entry `flow-text-slot-single-brace-refused`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `title: 'Deal won: {record.name}'` | `title: 'Deal won: {{ record.name }}'` | + | `message: 'Failed: {$error.message}'` | `message: 'Failed: {{ $error.message }}'` | + | `message: 'Total {amount * 2}'`, `'{round(x)}'` | compute it first — `assignments: { v: { dialect: 'cel', source: 'amount * 2' } }` — then `'Total {{ v }}'` (a number format is a formatter: `{{ v \| number:2 }}`) | + | `message: 'Due {TODAY() + 7}'`, `'By {$User.Id}'` | compute it first with an `assignment` node's CEL value envelope — `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' }, by: { dialect: 'cel', source: 'current_user.id' } }` — then `'Due {{ due }}'`, `'By {{ by }}'` | + + **The one-line fix: double the braces of every path token in a text slot (`{x}` → `{{ x }}`), and compute anything else into a variable first.** + + **Who is affected, measured.** This repository's in-tree text-slot sites — `examples/app-showcase` (24 strings, 30 tokens) and `examples/app-todo` (5 strings, 7 tokens), all of them paths — are rewritten in this change, with the docs pages that taught the single brace (`content/docs/automation/flows.mdx`, `content/docs/getting-started/common-patterns.mdx`). Other repositories and deployed metadata were not measured here. + +### Minor Changes + +- 1b40780: feat(automation)!: an approval decision resumes a flow as the person who decided + + Clause-②: yes (narrowing). The narrowing: under `runAs: 'user'`, a post-decision node that the submitter's rights admitted and the decider's do not is now refused (ruling A item 3). The widening: three published interfaces each gain one optional member, `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`) and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`). No schema shape moves: `flow.runAs` keeps its two values and only its describe changes. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A resumed flow run kept its trigger user for its whole life. In an approval flow that meant the nodes after the manager's decision wrote as the rep who submitted: the record's Last Modified By (`updated_by`) and the audit log named someone who did not act, and under `runAs: 'user'` the writes ran under the rep's grants. + + `flow.runAs: 'user'` now means the human whose act is running the current segment: the trigger user until a human decision, the decider after it. `runAs: 'system'` flows keep system privileges; after the decision their attribution user is the decider. + + - **Now, `runAs: 'user'`:** after an approval decision (approve or reject), the nodes that follow run with the decider's identity and permissions together. Their positions, permission sets and organization peers are resolved for them through the same resolver that seeds the trigger user's run, never copied from the trigger user. `updated_by` and `sys_audit_log` name the decider for those writes. The nodes before the decision keep the submitter. A second decision moves the segment to its own decider, a later pause keeps the identity the segment had, and the run's history still names the user who triggered it. + - **Now, `runAs: 'system'`:** after an approval decision, the flow keeps system privileges and its nodes run as `{ isSystem: true, userId: DECIDER }`, the shape the approvals verdict mirror already writes with. No permission is resolved and elevation is unchanged, so a read-only column still lands. Their writes, `sys_audit_log`, and `ctx.user` in the hooks those writes fire name the decider; the nodes before the decision keep the submitter. + - **Refused now:** under `runAs: 'user'`, a node after the decision that the decider may not perform (an object or field they may not edit, a record outside their reach) is refused, the run stops at that node as a repairable strand, and the decision door answers `RESUME_FAILED` (`repairable: true`) naming the run, the node and the refusal, as for any other failed post-decision node. Before, the same node ran with the submitter's rights. A repair replay (`continueRestoredRun`) continues as the decider too. + - **Unchanged:** a flow with no human decision node; an SLA auto-decision, which names no human, so the run keeps the identity it had (on both postures); a subflow parent's own nodes; and the send-back, resubmit and recall doors. + + `@objectstack/plugin-approvals` hands the decider to the engine on the service-stamped resume signal: the person the verdict mirror already writes as. The engine honours a decider only on a signal that carries the owning service's `RESUME_AUTHORITY_SERVICE` marker, so a resume built from an HTTP body cannot name one. The published types gain one optional member each: `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`), and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`). + + What changes for you: if a `runAs: 'user'` flow's nodes after an approval decision must write something the approver may not, such as a read-only field or a field the approver's permission set marks non-editable, the flow needs `runAs: 'system'`; its writes then keep system privileges and are attributed to the approver. A hook that reads `ctx.user` on a write a `runAs: 'system'` approval flow makes after the decision now sees the decider instead of the submitter. A run that was suspended before this release and is decided after it continues as the decider. +- f72ce93: **Shipped in 18.0.0-next.5**, whose CHANGELOG missed this entry; listed here one prerelease late. + + fix(service-automation): a `runAs: 'user'` flow now sees the acting user's organization members in `sys_user`, as the same user's own request does + + A `runAs: 'user'` flow's data nodes ran without `current_user.org_user_ids`, the peer list the request path resolves for every user. The shipped `sys_user_org_members` policy (`id in current_user.org_user_ids`) therefore dropped out, and a `get_record` on `sys_user` returned the acting user's own row only, while the same user's REST read returned their organization's members too. + + Run setup now seeds the peer list through the resolver the request path uses (`resolveUserAuthzGrants` from `@objectstack/core`), and every data node passes it on: + + - It is the members of the run's organization (`tenantId`) plus the acting user. A run with no organization gets the acting user alone, which is what an organization-less request gets. A user who belongs only to other organizations is never in it, even one who shares another organization with the acting user. + - It is seeded for every trigger shape: the record-change shape (`userId` and `tenantId` only) and the REST and action doors' shape (positions and permission sets already resolved). For the second shape only the peer list is taken from the resolver; the forwarded positions and permission sets stay exactly as they were, so an agent's narrowed ceiling is not re-broadened. + - A `subflow` or `map` child and a resumed leg keep the list their run was seeded with. + - If the resolver fails, the run proceeds without the list, as before, and the `[runAs]` warning now says which half degraded. Policies that read `org_user_ids` then drop out: the run sees less, never more. + - A run suspended before this release resumes without the list and keeps today's answer (its own `sys_user` row) until it ends. + + A `runAs: 'system'` flow is unchanged. The exported `RunIdentityContext` type gains an optional `org_user_ids` member. + + Clause-②: yes (widening: the exported RunIdentityContext / RunDataContext type gains the optional org_user_ids member, the key ExecutionContext already declares; no accepted input moves) +- 14ccb1d: fix(service-automation): a `runAs: 'user'` flow now carries its user's email, so a row policy on `current_user.email` admits the run on the rows it admits for the same user's own request + + Clause-②: yes (widening) + + A `runAs: 'user'` flow's data nodes ran without the acting user's email, which the request path resolves for every user. The row-level security compiler reads `current_user.email` from the execution context's `email` and nothing else, so every owner policy keyed on it (such as `assignee == current_user.email`) dropped out inside the run. A record-change flow triggered by a member whose only access to a row is such a policy was refused on the row the member had just written, and could read and update with their own request. + + Run setup now seeds the email through the resolver the request path uses (`resolveUserAuthzGrants` from `@objectstack/core`, the user's stored `sys_user` email), and every data node passes it on: + + - It is seeded for every trigger shape that names a user and carries no email: the record-change shape (`userId` and `tenantId` only) and the REST and action doors' shape (positions and permission sets already resolved). Only the missing facts are taken from the resolver; forwarded positions and permission sets stay exactly as they were. + - A context that already carries an email keeps it verbatim, as a request's session email wins over the stored one. A `subflow` or `map` child and a suspended run keep the email their run was seeded with. + - After an approval decision, the nodes that run as the decider carry the decider's own email, resolved for them. The submitter's email is removed with the rest of the submitter's grants, on both `runAs` postures. + - A user with no stored email, or a resolver failure, leaves the run without one: policies that read it drop out, and the run sees less, never more. The `[runAs]` warning on a resolver failure now names the email beside the peer list. + - A run suspended before this release resumes without the email until it ends. + + A `runAs: 'system'` flow is unchanged. The flow CEL scope's `current_user` is unchanged too: it still binds no email. + + Published types: `AutomationContext` (`@objectstack/spec/contracts`) gains an optional `email` member, and `RunIdentityContext` / `RunDataContext` and `FlowUserGrants` (`@objectstack/service-automation`) each gain an optional `email` member. +- 8fd3944: fix(service-automation)!: a `runAs: 'user'` hook fired by a user-triggered `runAs: 'system'` flow's write runs on that user's own grants + + Clause-②: no (narrowing) — a `runAs: 'user'` hook fired by a write of a `runAs: 'system'` flow that names a user now runs on that user's own grants instead of the member baseline, so for a user whose own grants admit less than the baseline its reads narrow; the published `RunIdentityContext` / `RunDataContext` types make `positions` and `permissions` optional, and no published schema or export moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A `runAs: 'system'` flow run that names a user (a manual run, a record change fired by a user's write, and every post-decision node of a `runAs: 'system'` approval flow, where the user is the decider) handed ObjectQL `{ isSystem: true, userId, tenantId, positions: [], permissions: [] }`. Its own operations never read the two arrays: elevation short-circuits every gate before they are read. But a `runAs: 'user'` hook fired by one of its writes de-elevates to that context, and the engine keeps the arrays a context carries, resolving the user's grants only when none are carried. So that hook ran on the member baseline, whoever the user was. + + - **Now:** a `runAs: 'system'` run that names a user carries no `positions` or `permissions` in its data context. A `runAs: 'user'` hook its writes fire resolves the named user's own positions, permission sets and organization peers in the run's organization, exactly as that user's own request resolves them. After an approval decision that user is the decider, so the hooks the post-decision writes fire run on the decider's grants. + - **Wider:** for a user granted more than the baseline, such a hook now reads and writes what the user may, where it was refused on objects the baseline does not grant. + - **Narrower:** for a user whose own grants admit less than the baseline, such a hook now reads only what that user's own request reads. + - **Unchanged:** the run's own elevation, `isSystem`, its `actor` label, its `userId` and its `tenantId`; a user-less `runAs: 'system'` run (a schedule), which keeps `positions: []` and `permissions: []`, and a `runAs: 'user'` hook it fires is still refused for want of a user; hooks declaring `runAs: 'system'` or `'inherit'`; and `runAs: 'user'` flows. + + The published `RunIdentityContext` and `RunDataContext` types of `@objectstack/service-automation` declare `positions` and `permissions` optional, because a `runAs: 'system'` envelope that names a user no longer carries them. Code that reads either member from `resolveRunDataContext`'s answer must allow for its absence. + + What changes for you: a `runAs: 'user'` hook that a user-triggered `runAs: 'system'` flow fires now sees exactly what that user sees. If such a hook must read or write something the user may not, declare it `runAs: 'system'`. +- 67b669e: The security catalog's activation door switches a position or permission set the environment authored, and a ledger write it refuses answers with the status its code means + + Clause-②: yes (widening) + + - **The door accepts environment-authored items** (`@objectstack/runtime`). `POST /api/v1/security/_activation/:type/:name` switched a position or permission set off and on only when a package shipped it. FROM: an item saved through the metadata door (`PUT /api/v1/meta/position/:name`, `PUT /api/v1/meta/permission/:name`) was refused `503` with `{ code: 'VALIDATION_FAILED', message: 'Package is required' }`, and nothing was written. TO: it is switched like a packaged one, `200`, and the resolver honours the row: a switched-off position stops granting its permission sets, and a switched-off set stops granting by every path. Who may call the door is unchanged (`manage_metadata`; the platform operator under a `group` or `isolated` posture), and so are its other refusals (an undeclared name `404`, a type with no switch or an audience anchor `400`, the last administrator `403`). + - **A refused ledger write keeps its code and its status together** (`@objectstack/runtime`, ADR-0112). FROM: the door kept a thrown `code` but answered `503` whenever the error declared no status, so a record validation refusal was served as `503 VALIDATION_FAILED`. TO: the door answers through the dispatcher's one thrown-error rule: a record validation refusal is `400 VALIDATION_FAILED`, a hook's refusal keeps its own pair (the last-admin guard's `403 PERMISSION_DENIED`), and an error that declares no status is `503 SERVICE_UNAVAILABLE`. + - **The ledger row of an item no package ships carries no package** (`@objectstack/platform-objects`, `@objectstack/core`). `sys_metadata_activation.package_id` is no longer required. It is empty exactly when no package ships the item. It is not part of the row identity (`metadata_type`, `name`), and no reader decides anything from it. The column was already nullable on disk (ADR-0113: `required` is a write-time check), so there is nothing to migrate. + - **One spelling for "no package"** (`@objectstack/core`). `MetadataActivationRow.packageId` is optional. FROM: `packageId: string`, with `''` the only way to say "no package". TO: omit `packageId` for an item no package ships, and the store writes no value. `ObjectStoreMetadataActivationStore.setActive` refuses `packageId: ''` with a `TypeError` before it reads or writes anything, and `list()` returns `packageId` only when the row names a package. If your code passes `''`, omit the key instead. + - **The flow row follows the shared row** (`@objectstack/service-automation`). `FlowActivationRow.packageId` is optional to match. Every flow row still names its package, because the flow toggle refuses a flow no package ships before it writes. + - **The action activation door's answer for an action no package ships changes; the action is still refused.** `POST /api/v1/actions/_activation/:object/:action` still passes `''` as the package of such an action. The write is still refused and still writes nothing, but the store's `TypeError` refuses it now, with the store's sentence. FROM: `503 VALIDATION_FAILED`. TO: `503 SERVICE_UNAVAILABLE`. An environment-authored action could not be switched on `main` before this change and cannot after it. +- b389e43: feat(metadata-protocol,runtime,service-automation,spec)!: the metadata protocol refuses every organization-scoped write, and an uninstall is environment-wide (ADR-0131 D6/D12) + + Clause-②: yes (narrowing) + + + + **BREAKING, in both directions.** It narrows: an organization-scoped metadata write is refused, and request keys retire. It widens one refusal: a package uninstall naming neither `organizationId` nor `allTenants`, refused before with `400 TENANT_SCOPE_REQUIRED`, is now accepted and runs environment-wide. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The `/meta` doors already carry no organization; now the metadata protocol itself refuses an organization-scoped write from every door, and the per-organization write path behind it is deleted. + + **What changes.** + + - Every protocol write that names an organization is refused with `403 NOT_OVERRIDABLE`, before anything is read or written, for every metadata type and every tenancy posture. This covers `saveMetaItem` (draft and publish), `publishMetaItem`, `deleteMetaItem`, `rollbackMetaItem`, `revertCommit`, `rollbackToPackageCommit`, `publishPackageDrafts`, `discardPackageDrafts`, `revertStoredPackage`, `duplicatePackage` and `reassignOrphanedMetadata`. The message's first sentence names the tenancy posture in force. The five types that declared `allowOrgOverride` (`view`, `dashboard`, `report`, `translation`, `email_template`) and the `OS_METADATA_WRITABLE` hatch no longer open an organization scope. + - The audit ledger (`sys_metadata_audit`) and the commit ledger (`sys_metadata_commit`) record `organization_id` NULL. + - The `/packages` doors of the runtime dispatcher thread no organization into any verb: publish-drafts, discard-drafts, the commit list, commit revert, rollback, revert, adopt-orphans, duplicate, delete, and the package manifest read. + - `deletePackage` retires its `organizationId` and `allTenants` request keys and both `400 TENANT_SCOPE_REQUIRED` refusals. The dispatcher's `DELETE /api/v1/packages/:id` no longer refuses an operator with no active organization. An uninstall removes every row bound to the package in this environment. Who may uninstall is the door's operator gate, as before. + - A seed published with a package no longer takes the publisher's active organization: a seed dataset names the organization it populates (ADR-0131 D9). + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `SaveMetaItem` / `PublishMetaItem` / `DeleteMetaItem` request (`@objectstack/spec`) | drop it: the write lands environment-wide. The key is stripped at a spec parse (the schemas are not strict) and refused at the protocol: a request still naming one answers `403 NOT_OVERRIDABLE` | + | `organizationId` on any other protocol write verb's request | drop it, same refusal | + | `deletePackage({ packageId, organizationId })` or `deletePackage({ packageId, allTenants: true })` | `deletePackage({ packageId })`; either retired key answers `400 INVALID_REQUEST` and removes nothing | + | `DeletePackageRequest.organizationId` / `.allTenants` (`@objectstack/metadata-protocol`) | gone from the type | + | `UninstallCleanup`'s `organizationId` argument | gone; a cleanup receives `{ packageId, actor? }` | + | `TENANT_SCOPE_REQUIRED` in the error-code ledger | retired; no producer emits it | + | `findPlatformScheduleOrgGaps`' `organizationId` input | gone: every write is platform-level | + | a `seed` draft whose records carry no `organization_id`, relying on the publisher's active organization under `group` | set `organization_id` on each record (ADR-0131 D12, item 12); under `group` a seed record that names no organization is refused at load, and under `single` the loader still derives the Default Organization | + + **What a deployment observes.** + + - Rows stored organization-scoped before this release are not touched by any write. `POST /meta/_migrate-stored` reports each one as `skipped` and names the promotion ceremony (ADR-0131 C7); the stored-flow credential move reports such a flow as not moved (`NOT_OVERRIDABLE`) and logs that its credential is still in cleartext in that row — rotate it. A legacy organization-scoped draft is no longer promoted, discarded or reverted by a package verb, a commit recorded in a legacy organization layer is refused by `revertCommit`, and `duplicatePackage` and adopt-orphans copy or adopt the environment's rows only. + - An uninstall removes the package's legacy organization-scoped rows with it, as the declared cross-tenant uninstall did. + + **What does not change.** The protocol's reads still accept an organization and still serve legacy organization rows; that narrowing is a later stage. The anonymous form doors' read of the Default Organization's layer is unchanged. +- 1920cf3: feat(platform-objects,service-automation,service-realtime)!: seven deployment-level platform tables lose their injected organization column, and reading them needs `manage_platform_settings` (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` hold deployment-level state. No writer attributes a row of any of them to an organization: every write is a system-context write whose row names none, and nothing writes `sys_presence` through ObjectQL at all. So the injected `organization_id` column only ever held NULL. ADR-0131 D7 takes it off: each object now declares `systemFields: { tenant: false }`. + + With no column there is no tenant wall, so these tables are governed by object permission. Each also declares `requiredPermissions: ['manage_platform_settings']`. Without that gate, a walled deployment's `organization_admin`, whose grant carries the superuser bits on every object, would read every other organization's job errors, queued payloads, dispatch keys and migration traces. + + **What moves for consumers.** + + - **The column.** `organization_id` is no longer a field of these seven objects. A filter, list-view column, report grouping, formula or seed key naming it on one of them is now an unknown field. Delete the reference: no organization owns a row of these tables. + - **Who reads, on a walled posture** (`group` or `isolated`). Before: the wall compared the NULL column to the caller's organization, so every reader got zero rows, platform administrators included (unless the deployment declared the table platform-global, which stood the wall down). Now: a principal holding `manage_platform_settings` (platform administrators hold it) lists every row; anyone else is refused `403 PERMISSION_DENIED`. + - **Who reads, on the `single` posture.** Before: any principal with a read grant on the object read every row, an organization administrator included. Now: only a principal holding `manage_platform_settings` reads; an organization administrator who is not a platform administrator is refused `403 PERMISSION_DENIED`. Grant the capability to an operator who needs these tables. + + **Unchanged.** Every platform writer and reader of these tables uses a system context, which no capability gate applies to, so job scheduling, the queue, flow dispatch, migration flags and the migration journal behave as before. The physical unique indexes are unchanged: none of these objects declares an organization-scoped one. + + **Existing databases.** Schema sync only adds, so the physical `organization_id` column stays on each existing table (with its index, where the deployment indexed it), and the boot drift report names it orphaned. By the writer census it holds only NULL, so dropping it loses nothing: `os migrate apply --allow-destructive` drops it, the remedy the drift report names. +- 746637e: fix(service-automation)!: retire the `RunProvenanceContext` type; `RunDataContext` names only what `resolveRunDataContext` returns + + Clause-②: no (narrowing) + + + + **BREAKING** for TypeScript code that imports from `@objectstack/service-automation`: one type export is removed and one type narrows. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes in any package. + + **What is removed.** The `RunProvenanceContext` type export, the `{ flowRunId }`-only "provenance" envelope, and its arm of `RunDataContext`. No code in this repository builds or reads one. Since ADR-0096 D5, the data engine's security middleware refuses a context that carries no principal and is not a system context (`403 PERMISSION_DENIED`, for every verb). So the type described a context the engine refuses, while its docstring said such a context was "indistinguishable from passing no context at all" and was let through. + + **What `RunDataContext` is now.** Exactly what `resolveRunDataContext` returns: `isSystem: true` for a `runAs: 'system'` run, and the triggering user's principal for a `runAs: 'user'` run. A run that resolves no principal is refused before any data operation (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`), as before. + + **If your code named the type.** There is no replacement, because the context it described is refused. A custom data node builds its context with `resolveRunDataContext(context)`, typed `RunDataContext`. + + **`@objectstack/plugin-security`, `@objectstack/runtime`, `@objectstack/service-analytics`, `@objectstack/platform-objects`:** comment and docstring text only. Shipped comments that said the security middleware hands through, skips or falls open for a context with no principal now say that it used to, and that ADR-0096 D5 refuses such a context. Nothing these packages do changes. +- b2549a8: feat(spec): `ConnectorProviderContext.resolvePackagePath` — a connector provider factory can resolve a path against the declaring app's root + + Clause-②: yes (widening) + + - **What is new.** `ConnectorProviderContext` gains one optional, host-provided member, `resolvePackagePath(relativePath): Promise`, beside `loadPackageFile`. It resolves a relative path against the root of the stack or package that declared the connector entry and returns the absolute path. `'.'` returns the root itself. It refuses (throws on) an empty path, an absolute path, and a path that escapes the root after resolution, such as `../x` or `a/../../x`. That is the same rule `loadPackageFile` follows. It reads nothing and does not check that the path exists. + - **Who hands it.** The connector materializer in `@objectstack/service-automation` hands it to every provider factory. It is anchored at the plugin's `packageRoot` option, the same root `loadPackageFile` reads under, and falls back to `process.cwd()` the same way. The two members now share one confinement check, so they cannot disagree about what is inside the root. `loadPackageFile`'s behaviour and error messages are unchanged. + - **Who reads it.** Nothing yet. `@objectstack/connector-mcp` is next: a declarative stdio transport will use it as the launched process's working directory, so a relative command or argument resolves against the app's root instead of the directory the server was started from. + - **What does not change.** Which commands a declarative stdio transport may launch is untouched. A factory that does not read the member behaves as before. + - **Nothing to migrate.** A host that builds its own `ConnectorProviderContext` may leave the member out. A factory must then keep its existing behaviour, or fail with a clear message if it needs the root. +- faf6348: feat(i18n): a refused `end` node's message translates in the run's locale (`flows..refusals..message`) + + Clause-②: yes (widening) + + - **What is new.** An `end` node declaring `outcome: 'refused'` shows its `message` to the person who started the run. Translate it under `flows..refusals..message`, keyed by the flow's `name` and the `end` node's `id`, the same addressing the `screens` keys use. Before this key, a refusal rendered in the source language on every console. + - **Keep the holes.** The translation is a `{{ }}` template like the message it translates (`'已拒绝:{{ record.name }} 是重复记录'`). A single-brace `{token}` in a translation is refused when the bundle is parsed, with the same text-slot refusal the source message gets. + - **Where it is read.** The engine picks the translated template before it renders the holes, through the `i18n` service, in the run's locale, and the run stores and returns that text (`refusalMessage`). With no translation for that locale, the authored message renders. `flowRefusalMessageKey(flowName, nodeId)` (`@objectstack/spec/system`) spells the key. + - **The run's locale.** `AutomationContext.locale` (`@objectstack/spec/contracts`) is new and optional. The trigger door (`POST /api/v1/automation/:name/trigger`, the legacy trigger route, a `type: 'flow'` endpoint) and the action door (`POST /api/v1/actions/...`, the MCP `run_action` bridge) set it from the request's resolved locale: `Accept-Language` first, then the `localization` settings. It is persisted with a paused run, so a resumed leg renders in the locale the run was started in. A run no person started (a record-change or schedule trigger) has no locale and stores the authored message. + - **`objectstack validate`** refuses a refusal key over an unknown flow, an unknown node, a completed `end` node, or a node of another type (`translation-target-unknown`, error). + - **`os i18n extract` and the coverage gate** now scaffold and demand the refusal message of every refusing `end` node in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` and has a refusing `end` with no translation now gets one missing-key issue per locale for it. + - **`AutomationEngine.setI18nServiceSource()`** (`@objectstack/service-automation`) attaches the `i18n` reader. The automation plugin wires it, so a host using `AutomationServicePlugin` needs no change. A host constructing the engine directly gets the authored message until it attaches one. +- d25b187: feat(automation): a screen's heading and body text render in the run's locale + + Clause-②: yes (widening) + + - **What is new.** The `screen` executor serves a screen's `title` and `description` in the language of the person who started the run. For each, it asks the engine for the translated template at `flows..screens..title` / `.description` through the `i18n` service, in `AutomationContext.locale`, and fills the `{{ }}` holes of that template. This is the same pick a refusing `end` node's message already makes, through the same channel (`setI18nServiceSource`). + - **When the authored text renders.** With no locale on the run (a record-change, schedule or webhook trigger), no `i18n` service, no entry or an empty one, or a translation that does not compile, the authored template renders as before. A translation that does not compile also logs a `warn` naming the key. A resumed leg renders in the locale the run was started in. + - **The heading key covers the node label.** A screen with no `config.title` shows its node label, and a translated `title` replaces whichever heading the screen shows. The body text is translated only where the screen authors one. + - **`AutomationEngine.renderFlowTextSlot(slot, variables, context)`** is the one translated-template pick, now shared by the refusing `end` node and the `screen` executor. Its argument type, `FlowTextSlotTranslation`, is exported. +- 243dd3c: fix(service-automation)!: in flow CEL, `record` is the record the run was handed, or unbound — never the run's variables + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + **What changed.** A flow CEL expression — a node's or an edge's `condition`, a `decision` branch `expression`, a screen field's `visibleWhen`, and the CEL value envelopes of an `assignment` and of a `create_record` / `update_record` `fields` map — evaluates in the scope `AutomationEngine.celScope` builds. That scope used to bind `record` to the run's variables map whenever the run held no record, so `record.assignee` read a flow variable named `assignee` instead of failing. Now `record` is bound only when: + + - an entrance handed the run a record: a record-change trigger's row, a time-relative sweep's row, the inbound hook's request body, a `type: 'flow'` action's record (the loaded row, or an empty record carrying at most the id it was given), a `subflow` or `map` parent's record, or a `map` item that carries a string `id`; + - or the flow binds a variable named `record` itself (a declared variable with a value, or an assignment target). + + With neither — a run started through the REST trigger route or a declared endpoint, a cron schedule, or a child of such a run — `record.X` fails the run with `Unknown variable: record` and the expression's source, as every other unbound root does. + + **What does not change.** A run that was handed a record reads `record.X` from that record, as before, over any variable of the same name. A bare variable name (`assignee`) and the `vars` namespace (`vars.assignee`) resolve as before. `$record`, `previous`, `vars` and `current_user` are untouched. + + ## Migration: FROM → TO + + In a flow whose run holds no record, read a variable by its name: + + | you wrote | write instead | + |:--|:--| + | `record.assignee` (a flow variable `assignee`) | `assignee`, or `vars.assignee` | + | `has(record.assignee) ? record.assignee : null` | `has(vars.assignee) ? vars.assignee : null` | + + **The one-line fix: read a flow variable by its name or through `vars`; `record` is only ever the record the run was handed.** A flow that is also launched with a record (an action on a row, a record trigger) keeps reading that record's fields as `record.X`. + + **Who is affected, measured.** Read statically over every flow CEL slot — 0 flows read `record` with no record entrance: + + - this repository at `0ec4268972` (`examples/**`, `packages/platform-objects`, `packages/qa/dogfood`): 64 flows, 51 CEL slots, no flow in `packages/platform-objects`; the 2 flows that read `record` are an `api` hook flow and a record-change flow; + - `objectstack-ai/hotcrm` at `f0afcbda07` (`src/`, `test/`): 45 flows, 57 CEL slots; the 12 flows that read `record` are all record-change flows. + + Deployed metadata and other repositories were not measured. `objectstack validate` refuses such a `record` read too, naming the root (see `@objectstack/lint`'s note). +- 572ecfb: fix: `os migrate meta --stored` lists every stored row that protocol 18 refuses and no conversion rewrites. Before, the preview counted each such row as already on protocol. + + Clause-②: yes (widening: `IAutomationService.canonicalizeStoredFlow(...).todos?` in `@objectstack/spec`, `AutomationEngine.canonicalizeStoredFlow(...).todos` in `@objectstack/service-automation` and `StoredFlowCanonicalization.todos?` in `@objectstack/metadata-protocol`, each a new output member; nothing narrows, and no door accepts or refuses anything it did not before) + + Three step-18 retirements refuse a stored shape that has no lossless rewrite. Upgrading a v17 database left those rows refused at load or at boot, and the stored preview read them as `canonical`. Each shape now has exactly one fate: **refused and listed**. The preview and `--apply` print one `TODO` line per site under its row. The line names the step-18 entry that refuses the shape, the site, and the choice the owner has to make. Nothing is rewritten, and the run's verdict does not change, because no run of the pass could clear these rows. + + **#23269, a flow value slot carrying a `{…}` token, including a `get_record` / `update_record` / `delete_record` filter operand: refuse and list.** A whole-path token maps mechanically to `{ dialect: 'cel', source: '' }`, but the rewrite is not lossless. A token the template resolved to nothing (an erased condition, so the node was refused) is a CEL fault on an absent root, and a flow variable named like a query placeholder used to shadow it. ADR-0087 D2 conversions run on the load path and must be lossless, so this one is not a D2. The chain also has no operator-applied rewrite of stored rows separate from the load path: `--stored` writes back the read path's own pass, and `--from 17` rewrites authored sources, not rows. So the flow is listed by flow, node and path (`flow-value-slot-template-dialect-refused`), and `registerFlow` goes on refusing it until the value is rewritten as its CEL envelope. A flow already in the CEL form, or using a query placeholder such as `{current_user_id}`, is not listed. + + **#23270, a `controlled_by_parent` object without exactly one required `master_detail`: refuse and list, by object (`cbp-master-relation-tiers-retired`).** No shape here converts losslessly. The upgrade never picks between two masters, either both required or neither. An explicit `required: false` is a contradiction the builder refuses. Turning a required `lookup` into a `master_detail` changes what deleting the master does. An object with no master has nothing to convert. Leaving `controlled_by_parent` changes who can read the rows, so that is the owner's choice and never a silent rewrite. The listing reads the same predicate `ObjectSchema` refuses with, so the preview and the parse refuse one set. The one lossless shape, a single `master_detail` with `required` omitted, is still converted by `cbp-master-detail-required-defaulted` and is not listed. + + **#23271, a view whose `data` binds the retired view-level API source: refuse and list, by view (`view-data-api-provider-retired`).** There is no mechanical move. The replacement is a page component's `dataSource`, so a page must exist to carry it. Deleting the view's `data` would make the view query its object instead. Views in every stored body spelling are covered, and so are an object's own `listViews`. The line shows the request URL and method and withholds every other request key, so a credential stored in a view's headers never reaches the report. A view on the `object`, `value` or `schema` provider is not listed. + + How it is wired: `applyConversions` reports these shapes through its existing `onTodo` sink, after the conversions run. Each TODO's `conversionId` is the id of the D3 entry that refuses the shape, so the published `ConversionTodoNotice.conversionId` now names either a conversion or a D3 entry. The published flow-canonicalization result gains one member, a widening in three packages: + + - `@objectstack/spec`: the contract `IAutomationService.canonicalizeStoredFlow(...)` declares an optional `todos?: ConversionTodoNotice[]` beside `notices` and `conflicts`. An implementation that reports none still conforms. + - `@objectstack/service-automation`: `AutomationEngine.canonicalizeStoredFlow(...)` returns `todos`. `registerFlow` ignores them, because its own refusal names the same sites. + - `@objectstack/metadata-protocol`: `StoredFlowCanonicalization` gains the matching optional `todos?`, and the stored pass lists a flow row's TODOs the way it lists any other row's. + + The authoring funnel and the read seams pass no sink, so nothing changes for them. +- 24e064f: fix(objectql,service-messaging,service-automation,plugin-approvals)!: a notification carries the organization of what it is about, and one no organization owns is refused instead of stored with `organization_id` NULL + + Clause-②: no (narrowing) — notification rows gain the organization they should already carry; what narrows is an emit that names no organization on an install where none is derivable, which the engine now refuses where it used to store the event row with `organization_id` NULL. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Every notification emitted from a system-context run — a scheduled flow, a record change written by a system context, an approval node — was stored in `sys_notification` with `organization_id` NULL, so reports and cleanup that filter by organization did not see it. Each emit also logged a `[notify] no organization in scope` warning. On an install holding several organizations, the deliveries of the same emit were refused while the flow step reported success, so nothing reached the inbox. + + **Who owns a notification now:** + + - **The run's organization.** A run that carries one emits exactly as before. + - **Else the record it is about.** A `notify` node in a run with no organization reads the organization of the record the notification is about. That is the declared click-through record (`sourceObject` + `sourceId`), else the run's trigger record. It is read from the stored row by id, never from the record snapshot the run carries. + - **Approvals.** Every approval notification carries its request's organization: `approval.requested`, reminders, comments, send-backs, reassignments, escalations and out-of-office notices. + - **Else the owner the engine derives for a system write** (ADR-0131 D9). `sys_notification` is now in the platform-object tenancy ledger, so an emit that names no organization gets the install's sole organization under the `single` posture. + - **The rows below follow the event.** `emit()` stamps the stored event row's organization on its delivery, inbox row and receipt, on both the inline and the outbox path. + + **Refused now:** where no organization is derivable (a walled posture, or a `single` install holding more than one organization), an emit that names none is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. Nothing is written for it. A `notify` step that hits the refusal fails with `notify failed: ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED: …` instead of succeeding. `MessagingService.emit()` rejects with the engine's error for any other producer. + + **What to do when it refuses:** give the notification an owner. Name the record it is about on the `notify` node (`sourceObject` + `sourceId`), declare the scheduled flow's acting organization (`organization` on its start node), or pass `organizationId` to `emit()` from your own code. + + The `[notify] no organization in scope` warning is gone, because the NULL row it predicted is no longer written. Rows already stored with `organization_id` NULL are not rewritten here. The v18 organization-ownership ceremony attributes them from their source record. +- d89f491: A stored flow the automation engine refuses is no longer served as running: the flow meta read serves the engine's verdict beside the item as `registration`, an envelope sibling of `lock`, and the flow list serves it as `registrations` keyed by flow name, so a refused flow reads `{ state: 'refused', reason: { code?, message } }` beside its stored `status`, which stays as authored. + + Clause-②: yes (widening) + + - **The shape** (`@objectstack/spec`). `FlowRegistrationSchema` is a closed set: `registered` (the engine holds the flow), `refused` (it refused the stored flow and holds nothing under the name; `reason` carries the engine's own `message` and, when the thrown error set one, its `code`, verbatim), and `unregistered` (it holds nothing and refused nothing: a read during boot, between a save and the engine following it, or after a deliberate unregister). `GetMetaItemResponseSchema` gains `registration` and `GetMetaItemsResponseSchema` gains `registrations`, both optional. New exports: `FlowRegistrationSchema`, `FlowRegistrationRefusalSchema` and the types `FlowRegistration` and `FlowRegistrationRefusal`. `IAutomationService` gains the optional member `getFlowRegistration(name)`. + - **Where the verdict comes from** (`@objectstack/service-automation`). The `kernel:ready` cold-boot bind records each flow it refuses, with what the engine threw, after it withdraws the flow; the `metadata:reloaded` re-sync and the metadata-change sync record a refusal that leaves the name unregistered. A later successful registration of the name, or its withdrawal, ends the record. `AutomationEngine` answers `getFlowRegistration(name)` and takes `recordFlowRefusal(name, error)`. The refusal's warn line, its level and the withdrawal are unchanged. + - **Where it is served** (`@objectstack/metadata-protocol`). The protocol reads the engine through the kernel's service table by name, as it already reads the engine's stored-flow canonicalizer; it does not import the automation service. The field is absent where no automation service declares the member, on every type other than `flow`, on a draft read and on the execution face the engine binds from: absence means no engine judged the flow, never `registered`. The verdict is the engine of the process that answered the read. + - **The default door** (`@objectstack/rest`, `@objectstack/metadata-protocol`, `@objectstack/spec`). `GET /api/v1/meta/flow/:name` on a default deployment (`metadata.enableCache: true`) is answered by the cached item branch, which now carries `registration` too. `getMetaItemCached` returns it beside `data`, read live from its delegate read on every call, and `GetMetaItemCachedResponseSchema` declares it. It is never part of the ETag, so a cached read that carries it is never answered `304 Not Modified`: a judged flow is always served in full. The cached branch's other envelope keys are unchanged. +- dc39326: fix: `os migrate meta --stored` lists the two families of stored rows it still read as on protocol: a page component whose `data` prop binds the retired view-level API source, and every other site `registerFlow` refuses in a stored flow the parse accepts. + + Clause-②: yes (widening: `ConversionTodoNotice.conversionId` in `@objectstack/spec` may now name `registerFlow`, the refusing door; `IAutomationService.canonicalizeStoredFlow(...).todos?` in `@objectstack/spec` and `AutomationEngine.canonicalizeStoredFlow(...).todos` in `@objectstack/service-automation` now also carry each site flow registration refuses; the `onTodo` sink of `applyConversions` now also receives a page component bound to the retired API source; nothing narrows, and no door accepts or refuses anything it did not before) + + **A page component bound to the retired view-level API source: refuse and list, by page and component (`view-data-api-provider-retired`).** `object-grid`, `object-map`, `object-gantt` and `object-tree` take a view data source in their `data` prop, so the step-18 retirement of the `provider: 'api'` arm reaches them through the same schema. The listing judges each stored page component with its own props row, the one the component-props gate parses, and lists it only when that row refuses the binding with the retirement's prescription; a component whose `data` is a row array, a custom type and a component's own `dataSource` binding are not listed. The line names the page, the component and its position, shows the request URL and method, and withholds every other request key. Nothing is rewritten: deleting `data` would make the component query its object instead. Left as stored, the page still loads and saves, because the page parse does not judge a component's properties, while `os validate`, `os build` and `os lint` refuse the binding in source. The D3 entry `view-data-api-provider-retired` now names these components in its scope. + + **A stored flow refused by `registerFlow` after the parse: refuse and list, by site (`registerFlow`).** The stored pass ran only one of registration's judges, the value-slot one. `canonicalizeStoredFlow` now runs registration's whole validation without registering: every check `registerFlow` runs once the parse has accepted the flow, through the one list `registerFlow` itself throws from. Each refused site is a `TODO registerFlow` line under its row, in the check's own words: a text slot still on the single-brace dialect or with a hole that does not compile, a decision's `mode`, a malformed node or edge predicate, a plugin node's undeclared config key, an `api` flow with no secret. A check added to registration later is listed the same way, with no second change. A site the chain already lists under a step-18 entry is not repeated. What the parse itself refuses, the builtin config contracts' value arm among it, is not a TODO: `canonicalizeStoredFlow` throws it, and the preview reports the row among those that could not be rewritten, in the parse's words. + + `registerFlow` refuses exactly what it refused before, with the same message: its checks now return their refusal instead of throwing it, and `registerFlow` throws the first one. The doors that canonicalize a flow without registering it (the metadata save door, a package duplication) read no `todos`, and the stored pass's run of the checks logs nothing, so they behave as before. +- 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. +- f901746: feat(spec)!: the retired callout node types `http_request`, `http_call` and `webhook` are reserved inside the open flow-node namespace; `flow-node-http-callout-rename` leaves the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** ADR-0018 unified the three outbound callout node types `http_request`, `http_call` and `webhook` into the one `http` node, and through 17 the loader rewrote an authored node typed with one of them to `http` with a notice. A flow node's `type` is an open namespace (a plugin registers its own node types), so nothing refused the old names. At 18 they are reserved: the flow parse refuses each as a node's `type` by name, the automation engine refuses to register a node executor under one, and the ADR-0087 D2 conversion `flow-node-http-callout-rename` (protocol 11) is retired from the load path (`retiredAfter: '17.7.0'`). A wait node's `waitEventConfig.eventType: 'webhook'` is a different field and is not affected. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | a flow node `type: 'http_request'` | `type: 'http'` | + | a flow node `type: 'http_call'` | `type: 'http'` | + | a flow node `type: 'webhook'` | `type: 'http'` | + | a plugin's `engine.registerNodeExecutor({ type: 'webhook', … })` (or either other name) | a type of your own, e.g. `'acme_webhook'` | + + **The one-line fix:** change the node's `type` to `'http'`; the node's `id`, `label` and `config` stay as they are (the conversion never touched `config`, so the `http` contract judges it as written). + + **What an author who still writes it sees.** The parse refuses the node at its own `type` path — a top-level node and a node inside a `loop`, `parallel` or `try_catch` region alike. `defineStack` throws `STACK_SCHEMA_INVALID` (422), `os validate`, `os build` and `os lint` (whose config calls `defineStack`) print the issue, and the metadata save door answers a NEW flow with `422 INVALID_METADATA` at the node's `type`, with or without an automation service, and stores nothing: + + > Flow node `type: 'http_request'` was removed in @objectstack/spec 18 (ADR-0087 D2) — it is one of the three callout node types ADR-0018 unified into `http`, the loader rewrote it to `http` until this release, and the name stays reserved inside the open node namespace so no plugin can claim it (ADR-0018). Use `type: 'http'`. Change the value; the conversion never touched the node's `config`, so the `http` contract judges it as written. + + The refusal is a `pattern` on `FlowNodeSchema.type`, so the published JSON Schema carries it too. + + **`@objectstack/service-automation`:** `AutomationEngine.registerNodeExecutor` (and `registerNodeAlias`, which calls it) throws when the executor's `type` or its descriptor's `type` is one of the three names, and registers nothing. No node could ever reach such an executor: an authored node with the name is refused at parse, and a stored one is rewritten to `http` before it parses. A plugin that registered its own executor under one of these names fails at that call and needs a type of its own. + + **No tool makes the rename.** The conversion belongs to protocol 11, below the migration floor, so `objectstack migrate meta` applies no edit for it and `--write` writes none: it prints each such node among the refusals the migrated stack still has, at `flows.N.nodes.M.type`, and lists the D3 entry `flow-node-http-callout-types-reserved` as the hand edit. + + **Stored metadata needs no edit.** Stored-row rehydration, the automation engine's flow rehydration and the artifact-ingestion door inside its window still replay the conversion, so a `sys_metadata` flow row written with an old name keeps loading as an `http` node. The rewrite there is now unconditional: until 18 it skipped a node whose old name a live executor owned and reported a conflict, and the engine no longer lets such an executor exist. + + ## The retirement kit + + - **The reserved-name table** `FLOW_RESERVED_NODE_TYPES` and its refusal `reservedFlowNodeTypeRefusal` (`@objectstack/spec/automation`), read by the flow parse, the engine's registration refusal and the conversion itself. + - **The conversion** is `retiredFromLoadPath: true`, `retiredAfter: '17.7.0'`; its ADR-0078 conflict guard is gone. + - **The D3 entry `flow-node-http-callout-types-reserved`.** + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` and `@objectstack/service-automation` are published, so this is breaking for consumers no telemetry was consulted for. + + +- cf07168: feat(metadata-protocol)!: the metadata save door refuses a retired flow spelling on a NEW flow, and a stored flow keeps re-saving + + Clause-②: yes (narrowing) + + **BREAKING (API save door):** a flow saved through `saveMetaItem` (`PUT /api/v1/meta/flow/:name`, the runtime dispatcher, and every caller of the protocol) with no stored row of that name at the save's address is now judged as authored input. It is canonicalized without the ADR-0087 D2 conversions retired from the authoring funnel, so a spelling the loader stopped lifting at 18 is no longer rewritten and stored: it meets the flow parse's refusal, `422 INVALID_METADATA`, with an issue located at the key and the same prescription `os validate` and `os build` print. Draft and publish mode alike. Before, the door lifted the old spelling, stored the new one and said nothing, so the client that wrote it (an AI writing flows through MCP or REST above all) was never told and wrote it again. + + What counts as a stored row: the row the save overwrites, at its state and package, and for a draft save with no draft yet the active row the draft overlays, so Studio's first draft edit of a stored flow is a re-save. + + Not changed: + + - A re-save of a stored flow keeps replaying the full chain, retired entries included, so a legacy row stays editable and is healed by the save, exactly as before. + - The server's own writes of bodies at rest keep the full chain: the stored-metadata migration (`os migrate meta --stored`), a package duplicate and an unmanaged package import. + - The automation engine's flow rehydration (`registerFlow`) and the read path, which still serves a stored flow verbatim. + + Migration, for a client that creates flows through the API: + + - `wait` node loose `config.duration` / `config.timerDuration` → `waitEventConfig.timerDuration`; `config.signal` / `config.signalName` → `waitEventConfig.signalName`; `config.eventType` → `waitEventConfig.eventType`. On the block itself, `timeoutMs` → `timerDuration` and `onTimeout` is removed. + - `connector_action` node loose `config.connectorId` / `config.actionId` / `config.input` → its `connectorConfig` block. + - CRUD node `config.filters` → `config.filter`; `config.object` → `config.objectName`. + - `map` and `subflow` node `config.flow` → `config.flowName`. + - `notify` node `config.to` → `recipients`, `subject` → `title`, `body` → `message`, `url` → `actionUrl`, `source: { object, id }` → `sourceObject` / `sourceId`. + - `script` node `config.functionName` → `function`, `input` → `inputs`; `actionType` / `template` / `recipients` / `variables` / `script` are removed. + - `errorHandling.retryDelayMs` and a `try_catch` node's `retry.retryDelayMs` → `backoffMs`; the flow keys `active` / `template`, a node's `outputSchema` and `errorHandling.fallbackNodeId` are removed. + + Each refusal names the key to write. `os migrate meta --from 16` lists the same edits for source files. Rows already stored need nothing. + + `@objectstack/service-automation`: `AutomationEngine.canonicalizeStoredFlow(name, definition, options?)` takes `{ includeRetired?: boolean }`. Omitted, it is `true` (the full chain, as every caller holding a body at rest wants). `false` is the authoring posture the save door asks for. `registerFlow` does not take the option. + + +- 6422da5: feat(automation): `getFlowRuntimeStates()` stamps `unboundCause` beside `reason` + + Clause-②: yes (widening) + + Each branch that decides why an enabled, triggered flow is not armed now answers its closed `FlowUnboundCause` together with its sentence: `deployment_policy`, `disabled_subflow`, `missing_trigger` or `binding_failure`. `GET /automation/_status` passes the rows through unchanged, so the field reaches the console with no route change. Bound, disabled and trigger-less rows carry neither `reason` nor `unboundCause`. The sentences and `getTriggerBindingAudit()` are unchanged. +- a65a59b: feat(spec)!: the ADR-0078 conversion-conflict channel is retired — `CONVERSION_CONFLICT_CODE`, `ConversionConflictNotice`, `ConversionConflictDetail`, the `onConflict` / `reservedNodeTypes` conversion options, the `conflicts` arrays and the `FLOW_CONVERSION_CONFLICT` 409 + + Clause-②: yes (narrowing) + + **BREAKING (published API):** the ADR-0087 conversion API carried a channel for a rename REFUSED because its old token was a live name in the environment: a conversion reported it through `ConversionContext.reportConflict`, `applyConversions` handed it to an `onConflict` sink as a `ConversionConflictNotice` (code `OS_METADATA_CONVERSION_CONFLICT`), the automation engine collected it in `canonicalizeStoredFlow`'s `conflicts` array, and the metadata protocol answered it with `409 FLOW_CONVERSION_CONFLICT`. Its only producer was the live-executor guard inside the flow-node HTTP callout rename. Protocol 18 reserved those three names (`FLOW_RESERVED_NODE_TYPES`), so the engine refuses an executor under one, no live owner can exist, and the guard went. Nothing can raise a conversion conflict any more, so the channel leaves the published surface rather than staying as a live-looking failure mode nothing triggers. + + ## FROM → TO + + | you wrote | write instead | + | --- | --- | + | `import { CONVERSION_CONFLICT_CODE, type ConversionConflictNotice, type ConversionConflictDetail } from '@objectstack/spec'` | delete the import — no replacement | + | `applyConversions(stack, { onConflict, reservedNodeTypes, … })` (likewise `applyConversionsToFlow`, `applyConversionsToStoredItem`, `collectConversionNotices`) | delete both options; keep `onNotice` / `onTodo` | + | a conversion's `apply` reading `context.reservedNodeTypes` or calling `context.reportConflict` | a rename over an open namespace reserves its old names instead (the `FLOW_RESERVED_NODE_TYPES` pattern), so the refusal happens at registration; a site with no lossless rewrite goes to `context.reportTodo` | + | an `IAutomationService.canonicalizeStoredFlow` implementation returning `conflicts: […]` (or a `StoredFlowCanonicalization` from `@objectstack/metadata-protocol` carrying one) | drop the member; return `parsed`, `storable`, `notices` and optionally `todos` | + | a client branch on `error.code === 'FLOW_CONVERSION_CONFLICT'` / HTTP 409 from a flow save, a package duplication or an unmanaged package import | delete the branch — no request can produce it | + + **The one-line fix:** delete every reference; the branch it guarded was already dead, so deleting it changes no behaviour. + + **What an upgrader sees.** Importing a removed name is `TS2305`; passing a removed option, or returning a `conflicts` member from a typed canonicalizer, is an excess-property `tsc` error. `ErrorCode` no longer parses `FLOW_CONVERSION_CONFLICT`. + + **Runtime behaviour is unchanged.** The removed branches could not fire: a stored flow carrying a reserved callout name still loads as `http` (the rewrite has been unconditional since the names were reserved), and `saveMetaItem`, `duplicatePackage`, `importUnmanagedPackage` and `os migrate meta --stored` answer exactly as before. No metadata document needs editing. + + ## The retirement kit + + - **`@objectstack/spec`:** the three exports leave the root; `ApplyConversionsOptions` loses `onConflict` and `reservedNodeTypes`, `ConversionContext` loses `reservedNodeTypes` and `reportConflict`, the `IAutomationService.canonicalizeStoredFlow` result loses `conflicts`; the error-code ledger drops `FLOW_CONVERSION_CONFLICT` by its own "Retiring a code" rule (a row comes out with its last emitter). + - **`@objectstack/service-automation`:** `AutomationEngine.canonicalizeStoredFlow` no longer returns `conflicts`, and the engine no longer builds a live-name set for the conversion pass. + - **`@objectstack/metadata-protocol`:** `StoredFlowCanonicalization` loses `conflicts`; the flow save door's 409, the stored-migration pass's `failed` row for a refused rename, `duplicatePackage`'s failed item and `importUnmanagedPackage`'s 409 are gone with it. + - **The D3 entry `conversion-conflict-channel-removed`.** No D2 conversion and no tombstone: no metadata document ever carried any of it. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** The three packages are published, so this is breaking for consumers no telemetry was consulted for. + + + +### Patch Changes + +- 31e0735: The flow inspector's Error Variable help for a Try / Catch node describes the caught error in words + + Clause-②: no (help text only: the `try_catch` descriptor's `configSchema` accepts exactly what it accepted before, and the value bound to the error variable is unchanged) + + - The `errorVariable` description no longer names a TypeScript type (`TryCatchErrorValue`). It now says what the bound value carries, in plain language: the id of the Try / Catch node (`nodeId`), the failure message (`message`), the platform error code when the failing step reported one (`code`), and, for a failure inside a loop, the zero-based pass number (`iteration`) and the item being processed (`item`). The default variable `$error` is named. + - The old text also left out `code`, which the engine has bound since the classified-code change; an author reading the inspector had no way to learn that a catch region can branch on it. +- 7de0265: feat(plugin-email)!: email templates resolve from the registry; the built-in seed, the declared-template boot sweep and the live projector retire + + Clause-②: yes (narrowing) + + + + **BREAKING** (retired exports and plugin options of `@objectstack/plugin-email`), graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, so this ships in an `18.0.0-next.N`. It carries ADR-0131 D6/D10 and the maintainer's ruling C on ADR-0131 §6 Q1. + + **What changes.** + + - **Templates resolve from the registry.** `sendTemplate` and `renderTemplate` read `email_template` metadata by `(name, locale)` at every send: the protocol's layered list (`GET /meta/email_template`'s items — the environment's item over each code package's declaration), or the ObjectQL registry on a host with no metadata door. The locale ladder is unchanged: the named locale exactly, then `en-US`, then — only for a call that named none — the bundle's lowest locale tag. + - **The built-in auth templates are code-declared items** of the email plugin's own package (`com.objectstack.service.email`), registered at plugin init, so they now appear in Studio's email-template list. An app that declares the same `(name, locale)` wins over the built-in; the built-in still serves the locales the app leaves out; a Studio save overrides either at environment level. Every built-in renders byte-identical to the row it used to be seeded into, in every locale (pinned on the rendered subject, HTML and text). + - **A Studio save is live on the next send**, with no projection and no restart. The `PUT /meta/email_template/:name` response no longer carries `projectionApplied`, because nothing is projected. + - **No `sys_email_template` row is written at boot or read at send.** The table and its rows stay; the organization door stays closed. + - **A malformed template fails its send loudly** with the schema's own message, instead of falling through to the built-in copy of the same slot. + + **What moves for consumers.** + + - FROM `createSysEmailTemplateLoader(engine, options?)` TO `createRegistryEmailTemplateLoader(sources)`, where `sources` is `{ protocol?, registry? }` (type `EmailTemplateRegistrySources`) or a function returning it. FROM the types `TemplateLoaderEngine` / `CreateTemplateLoaderOptions` TO `EmailTemplateRegistrySources`. + - FROM `bootstrapDeclaredEmailTemplates(...)`, `upsertDeclaredEmailTemplate(...)` or `deactivateDeclaredEmailTemplate(...)` TO nothing: templates are not copied into rows any more. Delete the call. + - FROM `mapTemplateToRow(tpl)` TO nothing: there is no row projection. To resolve a template the way the mailer does, call `createRegistryEmailTemplateLoader(...).load(name, locale)`. + - FROM `EMAIL_TEMPLATE_OBJECT` TO the literal `'sys_email_template'`, only if you still address the legacy table. FROM the type `BootstrapDeclaredEmailTemplatesResult` TO nothing. + - FROM `new EmailServicePlugin({ seedTemplates: false })` TO `new EmailServicePlugin()`: there is no seed to switch off. Delete the option. + - FROM `new EmailServicePlugin({ templates: [...] })` TO the stack's `emailTemplates: [...]` (or a `*.email-template.ts` file): declared templates resolve from the registry. Delete the option. + + **What a deployment observes.** A `sys_email_template` row an organization customized before its door closed is no longer what sends: the template's code declaration (or an environment-level Studio save) is. The v18 migration ceremony promotes those rows to environment-level templates; to make such an edit live before it runs, re-save the template in Studio. + + **`@objectstack/spec`** (patch): the `email_template` liveness ledger is re-anchored to the registry loader, and `category` and `isSystem` are graded `dead` — their only consumer was the retired row projection. The `notify` node's `template` description and refusal messages, and the `IEmailService` contract's documentation, name the email templates rather than `sys_email_template` rows. No schema shape changes. + + **`@objectstack/service-automation`** (patch): the notify node's `template` help text served at `GET /api/v1/automation/actions` names the email templates rather than `sys_email_template` rows. +- 2f70c22: A key a builtin flow node's executor contract does not declare is refused at parse, with a location, on every builtin but `try_catch`: a `notify` `bogusKey`, a screen field's `visibleIf`, a `create_record` `fieldValues`, an `http` `outputVariable` and the like no longer pass `objectstack validate` and `objectstack compile` and then get the whole flow refused at registration. + + Clause-②: yes (narrowing: an undeclared config key on 10 more builtins, every strict-contract builtin but try_catch, is refused at the build doors and the save door, where it passed; widening: a save door with no flow canonicalizer judges the D2-converted body, so a D2 alias spelling it refuses today, script functionName / input and subflow flow, is accepted again, as at every other door) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** Each of these executors parses the node's `config` against a strict contract before it acts. The build doors' executor-contract arm judged required keys and present values on these types, but held key membership back, because `registerFlow`'s undeclared-key walk judged it against the node type descriptor's `configSchema`. So a `notify` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied the key into `dist/objectstack.json`), and then registration refused the whole flow: at boot it was skipped with a warn, and a flow saved from Studio was stored and then silently not registered. + + **What is refused.** A `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node, at any depth, whose config carries a key its executor contract does not declare: at the config itself, on a `screen` field, or on one of a field's `options`. That includes a body-less legacy `loop`, which is judged on key membership alone. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`, `nodes.N.config.fields.0.visibleIf`). It comes from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share. Its message carries the contract's own sentence, with the contract's prescription for a known slip (`fieldValues` → `fields`, `bulk` → `multi: true`, `visibleIf` → `visibleWhen`, a did-you-mean for a near miss), and closes with the remedy: rename the key to one the contract declares there, or remove it. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the save door. `script` and `subflow` keys were already refused this way. The new `builtinNodeConfigKeysJudged(nodeType)` export names exactly the builtins the spec judges. + + **`@objectstack/service-automation`.** `registerFlow`'s descriptor walk (`validateNodeConfigKeys`) stands aside for every type `builtinNodeConfigKeysJudged` names, so each node type has one judge. Before the move, the declared key sets were measured equal: on each of these types the descriptor's declared keys, at every position the walk descends to, equal the keys the contract accepts there. So registration refuses exactly what it refused before. The refusal now arrives as the parse's located issue instead of the walk's `Flow '…' rejected: N undeclared config key(s)` text. + + **`@objectstack/metadata-protocol`.** The save door judges a flow in its canonical spelling on every path. When no flow canonicalizer resolved (a host with no automation service), or the canonicalizer threw (a draft with a temporary cycle), the schema gate and the runtime authoring gate now judge the body with the ADR-0087 D2 conversions applied (`applyConversionsToFlow`), for the verdict only. The stored body stays the raw request body, exactly as before. A D2 spelling the load path still rewrites (`filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`; `functionName` and `input` on a `script`; `flow` on a `subflow`) is therefore accepted at that door, as at every other one, and an undeclared key that no conversion rewrites is refused there, located. This widens the fallback path back for the `script` and `subflow` aliases the previous key arm had begun refusing there. `duplicatePackage` re-saves through the same gate. + + **What stays as it was.** + + - `try_catch`: its undeclared keys stay registration's, judged by the walk. Its contract's `retry` is the shared `RetryPolicySchema`, which strips an unknown key, while its descriptor closes `retry` to `maxRetries`, `backoffMs`, `backoffMultiplier`, `maxRetryDelayMs` and `jitter`. Moving the judge would have widened registration. + - Every plugin node type (the spec does not declare its contract): judged by the walk against its descriptor's `configSchema`, with the walk's own prescriptions. + - A key at or under a region slot (a `loop` body or a `parallel` branch, and their nodes and edges): judged at registration by the region check, as before. + - A key inside a free-form map (`filter`, `fields`, `headers`, `defaults`, `input`, `payload`, `templateData`): author data, never judged. + - `assignment`, whose top-level keys are the author's variable names, and `decision`, which parses no contract: unjudged. + - A spelling an ADR-0087 D2 conversion still rewrites at load (`object` and `filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`, the save door). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | a typo of a declared key (`titl`, `outputVariabel`) | the declared key the refusal's did-you-mean names | + | `fieldValues` on `create_record` / `update_record` | `fields` | + | `bulk`, `all`, `multiple` or `options: { multi: true }` on `update_record` / `delete_record` | `multi: true` at the top level of `config` | + | `visibleIf` on a screen field | `visibleWhen` | + | `itemVariable` on a `loop` | `iteratorVariable` | + | `outputVariable` on an `http` node | delete it: the http executor binds no output variable | + | a key nothing reads | delete it | + + **The one-line fix: rename or delete the key the refusal names.** Registration already refused such a flow, so the fix changes nothing a running flow does. + + **Who is affected, measured.** At `fbcbcf124`, every builtin flow node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (95 nodes) carries only keys its contract declares. The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys whenever the engine publishes the node's descriptor. Its fallback form (while the descriptor list is loading, or when it cannot be read) offers `outputVariable` on an `http` node and the `url` alias on a `notify` node. Registration already refused the first and the conversion rewrites the second; the designer fix is tracked in objectui. hotcrm, deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, over every builtin in `getBuiltinNodeConfigContracts()` but `try_catch`, named by the new export `builtinNodeConfigKeysJudged`; no new code joins `FLOW_SLOT_REFUSAL_CODES`. + - **The ledger.** The D3 semantic entry `flow-builtin-node-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. +- d4680d2: feat(spec)!: `NotifyConfigSchema.title` and `NotifyConfigSchema.message` are template slots: each takes a bare string or a `{ dialect: 'template', source }` envelope (the `tmpl` helper), as the expression dialect table already listed notification subjects and bodies among the `template` slots, and a blank bare string is now refused there + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing at two authorable keys (`automation/NotifyConfig:title`, `automation/NotifyConfig:message`), shipped as `minor` under the repo's launch-window convention for breaking changes. It is the grade `TemplateExpressionInputSchema`'s blank-string rule shipped with when it reached the first twelve typed keys. + + - **What widens.** Both keys are typed with `TemplateExpressionInputSchema`, the input every other `template` slot uses. Before, both were `z.string()`, so a notify node written with `` tmpl`…` `` passed `defineFlow` and registration and then failed every run at the execute-time contract parse (`expected string, received object`). It now parses and runs. + - **What narrows.** A blank bare string (`''` or whitespace-only) at either key is newly refused, by the shared template input's non-blank rule (`invalid_union`, with the `TYPED_EXPRESSION_SOURCE_REQUIRED.template` sentence). Before, every blank value parsed: + - `title: ''` then failed every run at the executor's guard ("notify: title is required"), so it fails either way, now earlier; + - a whitespace-only `title` passed that guard and was delivered as the notification title, and it is now refused; + - `message: ''` or a whitespace-only `message` was delivered as an empty or blank body, and it is now refused. + + The fix is to write the text, or to delete the key (`message` is optional). + - **Parse output.** `NotifyConfigSchema.parse(...).title` and `.message` go from `string` to `{ dialect: 'template', source }`, for both spellings, because the parse normalizes a bare string to that envelope. The exported `NotifyConfigParsed` type changes with them. Code that reads parse output reads `.source`. The `notify` executor, the one reader in this repo, now does, so both spellings of one text deliver the same `payload.title` and `payload.body`, and a bare string renders exactly what it rendered before. + - **Still refused, with a new sentence.** A value that is neither a string nor a template envelope (a number, an array, a `cel` envelope) was refused before (`invalid_type`). It is refused now as `invalid_union`, with the `TYPED_EXPRESSION_DIALECT_ONLY.template` sentence. + - **New, notify-only.** A template envelope on either key must carry a non-blank `source`. The executor renders `source` and has nothing to render from `ast` alone, so such an envelope is refused at the key instead of failing every run (`title`) or sending an empty body (`message`). An envelope never parsed at these keys before, so this refuses nothing that used to parse. + - **Placeholder spelling.** These two slots render `{{ }}` holes (`{{ record.name }}`), for a bare string and an envelope alike, and a single-brace `{token}` in them is refused. The `.describe()` on both keys now says so, and no longer says the text is "sent verbatim". + - `@objectstack/service-automation`: the `notify` executor reads `source` from the two template slots, and the descriptor's `title` / `message` descriptions state the `{{ }}` holes in place of "sent verbatim". +- 51290bc: Form help and refusals an author reads no longer carry service-interface names, ruling dates or another product's ids + + Clause-②: no + + Wording only: no schema, key, type, export or error-code change. + + - The notify node's Template help (the `NotifyConfigSchema.template` describe and the Studio + inspector's copy in `@objectstack/service-automation`) names the deployment's default locale in + product words instead of `II18nService.getDefaultLocale()`, and drops its ruling date. + - `MANIFEST_ID_EXAMPLES` is now `com.acme.crm` and `org.example.help-desk` (was `com.steedos.crm` + and `org.apache.superset`). The package-id refusal opens with the headline + "Invalid package id 'VALUE'." and names the key in the sentence after it, so the headline alone + carries no JSON path; the rule, the examples and the suggestion follow unchanged in substance. A + caller that matched the old "on KEY. Expected reverse-domain notation" wording matches the + headline, or compares against `manifestIdRefusal()` by reference, instead. + - Ruling dates leave the describes Studio renders as form help: field `required` and `multiple`, + form-view field and section `visibleWhen`, section `collapsible` / `collapsed`, the redirect + arm's `submitBehavior.url`, and page `kind` / `source` (the ADR citations stay). They also + leave the refusals for padded grouping field names, `submitBehavior.url`, `features.*` in a + form-view predicate, and the four filter comparand refusals (null ordering comparand, + `{ $field }` in a list position, null list member, blank `$between` bound). Each sentence still + states the rule, why it exists and the repair. + - The email-template form's Identity section help says how senders address a template instead of + naming `IEmailService.sendTemplate`, in all four shipped locales. + - The action `description` help no longer ends in a dangling dash left behind by an earlier + strip: "(one dialog, not two —)" now reads "(one dialog, not two)". +- 43fc500: fix(service-automation): a `get_record` whose `fields` leave out `id` answers the row's id as its `id` output on the SQL drivers + + Clause-②: no + + - **What was wrong.** On its single-row branch (no `limit` above 1) a `get_record` node answers `output: { record, id, object }`, and a later node reads that `id` as `{read.id}` (for a node whose id is `read`). The node handed the author's `fields` to the engine unchanged, and the SQL drivers return exactly the columns a projection names. So a `get_record` with `fields: ['title']` read a row with no `id` column, and `{read.id}` was empty with no error. A later `update_record` filtering on it was refused for a filter condition that resolved to nothing, and any other reference read nothing. + - **What it does now.** That branch names `id` in the projection it hands the engine, so `{read.id}` is the row's id whatever `fields` names, on every driver. The record the node serves, and binds to its `outputVariable`, carries that `id` beside the columns the author named, so the variable's `id` reads the same value. The stored-metadata tables (`sys_metadata`, `sys_metadata_history`) are read the same way: the served row is the named columns plus `id`. + - **Unchanged.** A `get_record` with no `fields`, or an empty list, still reads the whole row. A projection that already names `id` is handed over as written. The multi-row branch (`limit` above 1) answers `records` only and is untouched. +- fdfdd7e: fix(service-automation, objectql): the `runAs: 'user'` refusals and the run-setup warning say what a principal-less operation meets on both kernels + + Clause-②: no + + Three author-facing texts told a flow or hook author that a `runAs: 'user'` data operation with no trigger user "would execute UNSCOPED (elevated, RLS-bypassing)" if it were not refused. That holds only on a kernel with no security plugin. Since ADR-0096 D5, a kernel with `@objectstack/plugin-security` refuses an operation that carries no principal (`403 PERMISSION_DENIED`). Each text now says both: without a user the operation would carry no principal, refused by the security plugin where one is composed, unscoped where none is. + + - `@objectstack/service-automation`: the `UnscopedRunDataAccessError` message, and the `[runAs]` warning logged at run setup for a flow whose trigger resolved no user. + - `@objectstack/objectql`: the `HookUnscopedDataAccessError` message. + + Only the wording changes. The refusals, their order, their codes (`AUTOMATION_UNSCOPED_RUN_DATA_ACCESS`, `HOOK_UNSCOPED_DATA_ACCESS` with its `403` status) and their remedy are unchanged: declare `runAs: 'system'` to make the elevation explicit and intended, or arrange for the trigger to supply a user. +- 1b99388: fix(service-automation): the `sys_automation_run` row's highlight set now shows `unmeasured_count` beside `selected_count` and `acted_count`. That puts all three operands of the broken-sweep first filter (`selected_count > 0 AND acted_count = 0 AND unmeasured_count = 0`) on the run row itself. The field's description now also lists a notification queued for delivery as an uncountable effect. + + Why: on a stack composed as `serve` composes it, reliable delivery is on, so a `notify` step's in-app message is enqueued. The outbox dispatcher writes it after the run settles, and the node reports `unmeasuredEffect`. A notify sweep that did its job therefore reads `acted 0, unmeasured 1`. With only `selected` and `acted` on the row, it read the same as a sweep with nothing to do. No counter changes: an enqueued delivery stays uncountable, never a fabricated `acted`. + + For operators: read the triple (`selected`, `acted`, `unmeasured`), never `acted` alone. `acted 0` with `unmeasured` above 0 means "cannot tell", not "did nothing". +- 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. + + +- 099cc9d: Clause-②: no + + The approval, security, automation and sharing objects describe their records in plain admin + sentences. Approval Delegations now reads "While someone is away, approvals assigned to them + personally go to their delegate.", Record Shares "Records shared beyond their owner, one row per + grant.", User Positions "Which positions each user holds, one row per assignment.", Audience + Binding Suggestions "A package's suggestion to give one of its permission sets to everyone who + signs in, and the decision taken on it." and Automation Runs "Paused automation runs waiting to + resume, and the recent history of finished runs." Field help on Approval Requests, Approval + Delegations, User Positions, User Permission Sets and Audience Binding Suggestions, the + descriptions of the `organization_admin_no_bypass`, `mcp_agent_data_read` and + `mcp_agent_data_write` permission sets, the flow-dispatch ledger's description and the + reserved-position refusal no longer carry design-record numbers or runtime vocabulary; those + references stay in the code comments. The zh-CN, ja-JP and es-ES translations follow. Wording + only: no field, permission or behaviour changes. +- 2054a08: Clause-②: no + + A failed `parallel` run now shows what its branches did. Under the block's own `failure` step, the run's step log lists every branch's steps, each tagged with `parentNodeId`, `regionKind: 'parallel-branch'` and its `branch` index: a branch that completed, with its `success` step, and the failing branch's steps up to and including the one that failed, with its error. The run summary counts what those steps did. A fan-out whose notify branch delivered and whose http branch failed now reports `acted: 1` and `failed: 2` (the http step and the block), the way a failed `loop` already does. Before, the log held only the block's failure step and the summary read `acted: 0`. That reads as "nothing happened, safe to re-run" over a notification that had already been sent. + + The block now reports its failure once every branch has settled, not at the first branch to fail, so a branch that finishes later keeps its step and its effect is counted. Routing is unchanged. The failure is still the block's returned failure, so a `fault` edge on the `parallel` routes it with the same step code, error text and `$error`. A durable pause refused inside a branch still fails the run. +- 19955fb: Clause-②: no + + fix(service-automation): a resume refused because the run's flow or parked node is gone now settles the run's `sys_automation_run` row + + - **What changed.** A resume refused with `RUN_NOT_FOUND` because the run's flow is no longer registered, or because the node it parked on was edited away, now settles the run in the same call. The paused row is consumed and a `failed` history row records the refusal's own text in `error`. Before, the row kept `status: 'paused'` through every later resume, an uninstall and a cold restart. Run lists and paused-run counts kept showing a run the engine already knew could never continue, and retention never aged the row out, because it prunes terminal statuses only. + - **Unchanged:** the answer. It has the same code and the same text, and a transport still answers 404. A second resume answers `RUN_NOT_FOUND` again (`No suspended run`) and writes nothing. Retention is unchanged: `failed` was already in its scope, and paused rows are still never pruned. + - **When nothing is recorded.** Before the host seals the boot, a missing flow may be one that is not registered yet. `AutomationServicePlugin` re-arms overdue wait timers in `start()`, before flows bound at `kernel:ready` arrive. So before the seal the refusal records nothing, as before. The host seals with `sealNodeTypeVocabulary()`, which the plugin calls at `kernel:bootstrapped`. An embedded host that never calls it keeps the previous behaviour. + - **Concurrency.** The settle consumes the row through the same compare-and-set a resume takes. If a concurrent resume or cancel consumed the row first, the call writes nothing. If the store cannot answer the compare-and-set, the resume answers `STORE_UNAVAILABLE` (503), and a retry settles the run. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [5a871e4] +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [3ae0b77] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [4840720] +- Updated dependencies [1e8b628] +- Updated dependencies [4ff18d3] +- Updated dependencies [3566c08] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [8c105e3] +- Updated dependencies [e02833c] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [1abfc58] +- Updated dependencies [db87a02] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [6befe19] +- Updated dependencies [8caa131] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [2015c54] +- Updated dependencies [73a0a6b] +- Updated dependencies [f2a45db] +- Updated dependencies [aa9447c] +- Updated dependencies [3d91885] +- Updated dependencies [8fc50b7] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [ae97841] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [bb4f5cc] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [35afb15] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [b1f7a7a] +- Updated dependencies [345d3f3] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [3f80f17] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [46692c1] +- Updated dependencies [440bed6] +- Updated dependencies [3ca71b6] +- Updated dependencies [94379f4] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [cb3bb93] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [c63028e] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [3b5475a] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [6a6a4d3] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [0a29bec] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [aa5bfdf] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [c6139f2] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [78c13bd] +- Updated dependencies [9543e9c] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [47d569e] +- Updated dependencies [2de9c03] +- Updated dependencies [5b68524] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- 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 [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [e67ac4b] +- Updated dependencies [4feb2d0] +- Updated dependencies [7dc7d22] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [954bf72] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cad343e] +- Updated dependencies [cbe7712] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [9bb9370] +- Updated dependencies [23d0bc7] +- Updated dependencies [14aff67] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [cf07168] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [6e18016] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [077b75f] +- Updated dependencies [d6bd98a] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [f3174ef] +- Updated dependencies [e46ce7b] +- Updated dependencies [a06f0d4] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [2bcd406] +- Updated dependencies [e74ec56] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [ec749fb] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [41c7405] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [cf540a7] +- Updated dependencies [1827571] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [33a45cc] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/metadata-protocol@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/formula@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index c1a9a206f64..ac588c9f1a3 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Automation Service for ObjectStack — implements IAutomationService with plugin-based DAG flow execution engine", "type": "module", diff --git a/packages/services/service-cache/CHANGELOG.md b/packages/services/service-cache/CHANGELOG.md index 432ebac6a68..470c70c939e 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,381 @@ # @objectstack/service-cache +## 18.0.0 + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/observability@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index 89e6f8da112..94857d93c08 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Cache Service for ObjectStack — implements ICacheService with in-memory and Redis adapters", "type": "module", diff --git a/packages/services/service-cluster-redis/CHANGELOG.md b/packages/services/service-cluster-redis/CHANGELOG.md index c44fa9b1430..eadc3a72691 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,345 @@ # @objectstack/service-cluster-redis +## 18.0.0 + +### Patch Changes + +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0 + - @objectstack/service-cluster@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index 2db0895ba1f..25451ea47ad 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Redis cluster driver for ObjectStack — implements IPubSub/ILock/IKV/ICounter against Redis using ioredis.", "type": "module", diff --git a/packages/services/service-cluster/CHANGELOG.md b/packages/services/service-cluster/CHANGELOG.md index 91c24af00b5..fb4ce76960b 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,380 @@ # @objectstack/service-cluster +## 18.0.0 + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index 29bdf085cfc..ba181e01fa8 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Cluster Service for ObjectStack — pluggable PubSub/Lock/KV/Counter primitives. Memory driver included; postgres/redis drivers ship separately.", "type": "module", diff --git a/packages/services/service-datasource/CHANGELOG.md b/packages/services/service-datasource/CHANGELOG.md index ac9c74acf30..ca467896fba 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,580 @@ # @objectstack/service-external-datasource +## 18.0.0 + +### Minor Changes + +- 8532783: feat(service-datasource,metadata-core)!: the datasource-admin plugin reads the environment's stored `datasource` rows only; a legacy organization-scoped row is neither loaded at boot nor served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** Graded `minor` on the v18 prerelease line, as the read narrowing it completes. + + **What changes.** `DatasourceAdminServicePlugin` reads stored `datasource` rows in five places — the boot restore (`restoreRuntimeDatasources`), the by-name read behind cluster convergence and the `/meta` door's datasource projection, and the existence probes behind its own create, update and remove. Each now reads the environment's rows only, judged by the same row-value test the metadata reads apply. A row stored organization-scoped by an earlier release: + + - is no longer registered at boot, so the datasource-admin door and the metadata read door no longer serve it, and no live pool is opened from it; + - is never updated or deleted by an admin create, update or remove under its name — those write and remove an environment row of their own; + - stays at rest, and the boot report `[metadata_org_scoped_unserved]` keeps naming it. Nothing is deleted or rewritten; the v18 migration ceremony (`os migrate`, ADR-0131 D10) carries it. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | a legacy organization-scoped runtime datasource a deployment relied on | re-create it through the datasource-admin door or save it through the `/meta` door to make it environment-wide now, or wait for the v18 migration ceremony | + + `@objectstack/metadata-core` exports `isLegacyOrganizationRow`, the one row-value test for such a row; `@objectstack/objectql`'s authored hook and action binders now read it from there, with no change in what they bind. +- 08b38cb: fix(services)!: the datasource admin, share-link, webhook redeliver and storage families refuse a session the ADR-0069 authentication policy holds + + Clause-②: no (narrowing) — a session ADR-0069 holds out of protected resources is refused by families that answered it; core's existing exports only, no published schema or export moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate. REST and the runtime dispatcher already refuse such a session. Four families of routes mounted straight on the host server never asked, so they served it as an ordinary signed-in caller: + + - **`@objectstack/service-datasource`**: the datasource admin family (`registerDatasourceAdminRoutes`). Its door now asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) about the session it already reads, before the anonymous floor and the capability check. + - **`@objectstack/plugin-sharing`**: the share-link family (`registerShareLinkRoutes`). Every route, the two capability-token routes included, is now mounted through one door that refuses a context carrying `authGate`. `SharingServicePlugin`'s resolver now puts the session's posture on that context, as REST does. + - **`@objectstack/plugin-webhooks`**: the redeliver door (`WebhookOutboxPlugin`), on both of its faces: the route the plugin mounts, and the `webhooks` service's `handleRedeliver`. + - **`@objectstack/service-storage`**: the storage family, the two capability-token raw-transfer routes included. Every route is now mounted through one door. The posture is bound where every other storage gate is bound, so both the plugin's own mount and the host door `mountStorageRoutes` carry it. When a gate feature is active, the door reads the session and refuses a held one. When the gate is active but the session cannot be read, the door answers the declared `503` (`SERVICE_UNAVAILABLE`), as REST does. A direct call of `registerStorageRoutes` mounts the family exactly as before, because like the other storage gates this one is bound by the package's composition. No exported declaration changes. + + - **Refused now:** a held session gets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message`, in each family's declared error envelope, before the family does anything else. This is the answer REST gives the same session. + - **Unchanged:** a session the policy does not hold is served exactly as before, and an API-key caller is unaffected because it has no session to hold. A CORS preflight is answered as before. + + What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before these routes answer them, as on every other protected route. If you mount the storage routes through `mountStorageRoutes`, the gate is bound from the kernel's `auth` service with nothing to configure. If you mount `registerShareLinkRoutes` yourself with your own `contextFromRequest`, set `authGate` on the context it returns for a held session; the door refuses on that field. +- 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 + +- 8a399b2: The datasource admin door and the metadata door agree on a runtime datasource: a datasource saved through `/api/v1/meta/datasource/:name` is listed and editable through `/api/v1/datasources` in the same boot and after a restart, and one created through the admin door can be edited and removed through the metadata door (#21923) + + Clause-②: no + + `DatasourceSchema.origin` publishes `runtime` as "created via the Studio wizard, persisted in the runtime metadata store, environment-scoped, editable", and says `origin` is never accepted from client input. Three things broke that for a datasource the metadata door wrote, or one the admin door wrote: + + - **The admin door read `origin` from the record.** It served `origin ?? 'code'`, so after a restart a datasource saved through the metadata door (whose body carries no `origin`, or asserts `origin: 'code'`) was served as code-defined, and `PATCH /api/v1/datasources/:name` answered `400 DATASOURCE_ADMIN_ERROR` ("… is code-defined and cannot be edited at runtime."). It now serves `code` only for a name the host registers from code (the host's code-datasource set, the one the boot restore and the metadata door's refusal read), and `runtime` for every other name, whatever the record says. Boot pool rehydration follows the served origin, so such a datasource also gets its live pool after a restart. + - **A metadata-door write never reached the admin door until a restart.** The metadata door persisted the row but never registered it where the admin door lists, so in the same boot `GET /api/v1/datasources` omitted it and `PATCH` answered "not found". The datasource-admin plugin now registers the protocol's awaited `datasource` mutation projector: after a metadata-door save, publish, revert, rollback or delete, the admin door's registry follows the stored row and the live pool converges on it (opened on a create, rebuilt on a connectivity change, evicted on a delete) before the metadata door answers. A write under a name the host defines in code changes nothing here, so the metadata door's `DELETE` of a leftover row under such a name still leaves the code definition served. + - **An admin-created row could not be edited or removed through the metadata door.** The admin door stored its `sys_metadata` row with no `checksum`, the column the metadata door's optimistic lock compares, so `PUT` and `DELETE /api/v1/meta/datasource/:name` answered `409 METADATA_CONFLICT` for every admin-created datasource. The admin door now stamps the checksum the metadata door's repository stamps (`hashSpec` from `@objectstack/metadata-core`, which becomes a runtime dependency of this package). A row stored before this release has no checksum until the admin door next writes it: editing that datasource through the admin door once makes it editable through the metadata door. + + Cluster convergence (a peer replica's signal after an admin-door write) decides "code" from the same set, so a stored row under a code-defined name never opens a pool there, and every other stored row is pooled as runtime. + + **Unchanged.** A code-defined datasource stays read-only through both doors: the admin door's `PATCH` and `DELETE` still answer `400 DATASOURCE_ADMIN_ERROR`, and the metadata door's still answer `403 NOT_OVERRIDABLE`. A host that composes no code-datasource producer (neither `AppPlugin` nor `DefaultDatasourcePlugin`) registers no set, and the admin door then serves every datasource as runtime, as its boot restore already treats every stored row. +- 4f4c4ed: fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`). + + **The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`: + + - **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. + - **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none. + + **Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`. +- 6a6a4d3: fix: a refused on-demand schema sync (`syncObjectSchema`) is logged at `error`, naming what was not created and how to fix it (#22985) + + Three places create an object's storage after the boot's own schema sync has run. When the driver refused that DDL, each of them logged a `warn` and carried on, so the deployment looked healthy while a table did not exist. Each now logs one `error` line that says what was not created, that nothing else will look wrong, and what fixes it. The driver's refusal is kept out of the line itself. Nothing else changes: the boot, the connect and the publish still complete as before. + + - **`@objectstack/service-messaging`**: boot-time provisioning of the eight notification tables. A refused table logs `[messaging] could not provision
storage — its storage was NOT created. …` at `error`, once per table per boot, with the refusal in the record's meta. The fix it names: resolve the refusal, then restart or run `os migrate apply`. This used to be a `warn`. Under `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` the provisioning still issues no DDL, so this line cannot appear. + - **`@objectstack/service-datasource`**: connecting a declared datasource re-runs the schema sync of every object bound to it. A refused object logs `datasource '': object '' has NO working storage here — …` at `error`, once per object per connect, with the refusal in meta. On a logger that has no `error`, the same line is logged at `warn`. The fix it names: resolve the refusal, then restart. This used to be a `warn` reading `syncObjectSchema('') failed`. + - **`@objectstack/metadata-protocol`**: publishing an object whose storage sync is refused logs `[Protocol] object '' was published but its storage was NOT created or altered — …` with `console.error`. The driver's refusal is the second argument. The fix it names: resolve the refusal, then publish the object again or restart. The publish still answers success. This replaces the `console.warn` line `[Protocol] table sync failed for object '': …`, so a log search for that text now finds nothing. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [9e9e6f9] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [86f53a4] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [c7dd48a] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [f77ba41] +- Updated dependencies [53646fc] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [ae4dfaf] +- Updated dependencies [6422da5] +- Updated dependencies [2034975] +- Updated dependencies [61e5ee9] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [c4111c6] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [077b75f] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/driver-sql@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/types@18.0.0 + - @objectstack/driver-turso@18.0.0 + - @objectstack/driver-mongodb@18.0.0 + - @objectstack/driver-sqlite-wasm@18.0.0 + - @objectstack/driver-memory@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index bae94f24a07..47aeecd2a3e 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "The datasource service (ADR-0015): external-table federation (introspect/draft/import/validate) + runtime UI datasource lifecycle (list/test/create/update/remove + REST routes). Open-source mechanism; the tier line falls on which ICryptoProvider / driver factory a host injects.", "type": "module", diff --git a/packages/services/service-i18n/CHANGELOG.md b/packages/services/service-i18n/CHANGELOG.md index e5efe4422c6..b99de18ffdc 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,412 @@ # @objectstack/service-i18n +## 18.0.0 + +### 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 + +- 3c1a046: fix: a route's catch-all `500` answers its own generic text, never the caught error's message, and `@objectstack/types` exports the writer that does it, `sendServerFault` + + Clause-②: yes (widening) — `@objectstack/types` gains one export, `sendServerFault`; nothing is removed, renamed or narrowed, and no response field changes shape. + + A declared 5xx does not carry the text of the error it caught: that text can name server internals, such as a path, a host or a table. The REST layer already withheld it. The storage, settings and i18n route families and the share-link routes answer in the nested `{ success: false, error: { code, message } }` envelope and did not: their catch-all `500` sent the caught error's message to the caller. + + - **What a caller now reads.** The same `status` and `code` as before, with the route's own generic text as `error.message`: `Internal error` on the storage and i18n routes, the route's message on the settings routes (`Failed to list manifests`, `Failed to read namespace`, `Failed to write namespace`, `Action failed`), and the route's `Failed to …` message on the share-link routes. On the storage routes this includes the metadata store's outage sentence, which an operator now reads in the server log. + - **What the operator reads.** The original error, stack included, in one `[5xx]` server-fault line. The storage byte routes, which already withheld the text, logged two lines per fault and now log one. A line names the mounted route pattern when the route passes one, never the request URL. + - **Unchanged.** Every `4xx` answer. The settings `SETTINGS_CRYPTO_UNAVAILABLE` refusal, which keeps its prescription. The authorization-store outage `503`, which keeps its declared code and message. The storage `501 NOT_IMPLEMENTED` answers. On the share-link routes, any error that declares its own `status` keeps the envelope it declares. + - **`sendServerFault(res, status, code, message, thrown, request?)`** (new, `@objectstack/types`): writes `message` in the declared envelope, and hands `thrown` to the server-fault log with its stack, as the fault's one line. Use it for an arm that answers a caught fault. Keep `sendError` for a `4xx` and for a `5xx` whose text you wrote for the reader. `request` is the mounted route pattern, never the request URL. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 0127c99f5fc..dccf9862121 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "I18n Service for ObjectStack — implements II18nService with file-based locale loading", "type": "module", diff --git a/packages/services/service-job/CHANGELOG.md b/packages/services/service-job/CHANGELOG.md index 8bfe72a8cf2..e17de74e8c5 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,434 @@ # @objectstack/service-job +## 18.0.0 + +### Patch Changes + +- f606ba2: feat(spec)!: a job `retryPolicy` block whose `maxRetries` is omitted or 0 is refused, and `retry-policy-converged` leaves the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** `maxRetries` has defaulted to 0 since 17, so a `job.retryPolicy` block that omits it, or writes 0, buys no retry: the job runs once. Through 17 the loader hid that by writing the pre-17 `maxRetries: 3` and `backoffMultiplier: 2` into such a block with a notice. At 18 the ADR-0087 D2 conversion `retry-policy-converged` is retired from the load path (`retiredAfter: '17.7.0'`), and the job parse refuses the block at `retryPolicy.maxRetries`, at every door that parses an authored job (`defineStack`, `os validate`, `os build`, and `os lint` when the config calls it): "A retry block with no attempts is no retry block: state `maxRetries` ≥ 1, for example 3, or delete the block." The rule is the job's, the same one `flow.errorHandling` applies to `strategy: 'retry'`; the shared `RetryPolicySchema` is unchanged, so a `try_catch` node's `retry` region is judged as before. + + Migration: + + - `retryPolicy: {}` or `retryPolicy: { backoffMs: 5000 }` → `retryPolicy: { maxRetries: 3, backoffMs: 5000 }` (3 is the count such a block ran with before 17), or delete the block if the job should run once. + - `retryPolicy: { maxRetries: 0, … }` → delete the block: a job with no `retryPolicy` is a single attempt. + + What an author who still writes it sees: `defineStack` throws `STACK_SCHEMA_INVALID` (422) with one issue at `jobs.N.retryPolicy.maxRetries` carrying the prescription above, and `os validate` prints the same issue. Nothing is rewritten silently any more. + + Not refused: a block that states `maxRetries` and omits `backoffMultiplier`. It keeps the declared default of 1, a flat delay, where the loader used to write 2. State `backoffMultiplier: 2` to keep the old exponential curve. + + 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. For such a block the edit writes both pre-17 numbers (`maxRetries: 3`, `backoffMultiplier: 2`). The D3 entry `job-retry-policy-without-attempts-refused` states the decision left to you: keep the written count, or delete the block. + + The flow half of the conversion (`retryDelayMs` → `backoffMs` on `flow.errorHandling` and a `try_catch` node's `retry`) needed nothing more: both surfaces already refuse `retryDelayMs` with the rename to `backoffMs`. + + Stored metadata needs no edit. Stored-row rehydration, the automation engine's flow rehydration, the metadata save door's flow fallback and the artifact-ingestion door inside its window still replay the conversion, so a job row stored without a count keeps loading with the three retries it ran with. + + `@objectstack/service-job`: a comment-only change in `runWithPolicy`, whose `maxRetries` default is now reached only by a caller that builds the options by hand. + + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index 22ecdd48948..e136c58251d 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Job Service for ObjectStack — implements IJobService with setInterval and cron scheduling", "type": "module", diff --git a/packages/services/service-knowledge/CHANGELOG.md b/packages/services/service-knowledge/CHANGELOG.md index ba2e30da0a6..e643a745e2d 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,380 @@ # @objectstack/service-knowledge +## 18.0.0 + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index 54ee21c3359..893bbc0c35c 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Knowledge Service for ObjectStack — orchestrator implementing IKnowledgeService over pluggable IKnowledgeAdapter backends (RAGFlow, LlamaIndex, Dify, in-memory).", "type": "module", diff --git a/packages/services/service-mailbox/CHANGELOG.md b/packages/services/service-mailbox/CHANGELOG.md index ddeeeb53c96..91d0236353a 100644 --- a/packages/services/service-mailbox/CHANGELOG.md +++ b/packages/services/service-mailbox/CHANGELOG.md @@ -1,5 +1,488 @@ # @objectstack/service-mailbox +## 18.0.0 + +### Minor Changes + +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. +- 3d830b7: Inbound mail has a mail source: the new `@objectstack/connector-imap` reads a mailbox over IMAP, and `@objectstack/service-mailbox` polls each mailbox's folder into its ingest seam, keeping a UID cursor on `sys_mailbox` + + Clause-②: yes (a new published package, `@objectstack/connector-imap`, and its exports; new exports and plugin options in `@objectstack/service-mailbox`, and four new fields on its published `sys_mailbox` object) + + - **`@objectstack/connector-imap` (new) — protocol and authentication only.** + - `openImapSession({ account, credentials, timeoutMs?, maxLiteralBytes?, maxCommandBytes? })` opens a read-only IMAP4rev1 session: `examine(folder)` (`EXAMINE`, never `SELECT`; answers `uidValidity`, `uidNext`, `exists`), `listMessages(fromUid)`, `fetchMessages(uids)` (`BODY.PEEK[]`, so no message is marked `\Seen`), `logout()` and `close()`. A folder opened without a UIDVALIDITY is refused. + - The account and credential abstraction is shaped for the per-user mailbox: a `MailAccount` (`{ id, username, imap: { host, port?, security? } }`) and a `MailCredentialProvider` that resolves the account's credential each time a session opens. `staticCredentials(credential)` answers one fixed credential. + - A `password` credential is sent with `AUTHENTICATE PLAIN` when the server offers it, otherwise `LOGIN`; an `oauth2` access token with `AUTHENTICATE XOAUTH2`. A server that refuses `LOGIN` and offers no `PLAIN` is refused before anything is sent. + - `security: 'tls'` (the default) is implicit TLS with the certificate verified. A plaintext connection (`security: 'none'`) is refused to any host but loopback, before a socket opens. + - Failures are `ImapError` with a `kind`: `configuration`, `connection`, `timeout`, `authentication`, `refused`, `protocol` or `oversized`. + - An IMAP server is treated as an untrusted peer. A line above 4 MiB, a literal above `maxLiteralBytes` (64 MiB by default), a response of more than `IMAP_MAX_RESPONSE_LITERALS` (32) literals, and more than `maxCommandBytes` of responses to one command (128 MiB by default) are refused as `oversized` before the excess is kept, and the connection is closed. + - `@objectstack/connector-imap/testing` exports `ImapServerDouble`, an in-process IMAP server on loopback for tests. + - No runtime dependencies. + - **`@objectstack/service-mailbox` — the IMAP sync.** + - `new MailboxServicePlugin({ imap: { accountFor, pollIntervalMs?, batchSize?, maxMessagesPerRun?, maxMessageBytes?, maxBatchBytes? } })` schedules the interval job `mailbox:imap-sync` on the composed job service. For each mailbox `accountFor` answers (asked per mailbox per run; `null` for a mailbox the host does not sync), it fetches the messages above the stored cursor, in UID order, and hands each one to `ingest` under the system context. Dedupe, association, the pool and auto-reply suppression are unchanged. + - Without `imap` — the capability boot constructs the plugin with no options — no mailbox is synced, and the boot says so once. With `imap` but no job service, the boot warns, and `plugin.syncService.syncAll({ isSystem: true })` runs a pass on the host's own schedule. A run in which a mailbox could not be synced reports `degraded` to the job service. + - `sys_mailbox` gains the cursor, written by the sync only: `imap_folder`, `imap_uid_validity`, `imap_last_uid` and `imap_synced_at`. A cursor taken under another UIDVALIDITY, or in another folder, is discarded and the folder is read from its start; the messages already stored answer `duplicate`. No credential is stored on the object: the account and its credential come from `accountFor`. + - The cursor moves after each batch past every message stored, already stored, suppressed or skipped. A store failure stops the run below the failed message, so the next run retries it. A message above `maxMessageBytes` (32 MiB by default) or one the seam refuses as unreadable is skipped and logged at `error` with its UID. + - A batch is bounded by count (`batchSize`, 25) and by listed bytes (`maxBatchBytes`, 64 MiB). The session's literal bound is `maxMessageBytes` plus a 64 KiB framing envelope, so a server that omits or understates a message's size cannot hand over more. That message is skipped as `too_large`, after the batch is read again one message at a time on a fresh session, and the rest of the mailbox keeps arriving. + - A forwarded `message/global` part (RFC 6532, the internationalized `message/rfc822`) is now read as an `attachment` source, the same as `message/rfc822`. + - New exports: `MailboxSyncService`, `MAILBOX_SYNC_JOB`, `DEFAULT_MAILBOX_POLL_INTERVAL_MS`, `DEFAULT_SYNC_FOLDER`, `DEFAULT_SYNC_BATCH_SIZE`, `DEFAULT_SYNC_MAX_MESSAGES_PER_RUN`, `DEFAULT_SYNC_MAX_MESSAGE_BYTES`, `DEFAULT_SYNC_MAX_BATCH_BYTES`, `SYNC_LITERAL_ENVELOPE_BYTES`, and the types `MailboxServicePluginOptions`, `MailboxImapSyncOptions`, `MailboxAccountResolver`, `MailboxImapAccount`, `MailboxSyncTarget`, `MailboxSyncResult`, `MailboxSyncAllResult`, `MailboxSyncSkipReason`, `MailboxSyncServiceOptions`, `MailSyncEngine` and `MailSyncLogger`. + - This supersedes the ingest seam entry's "no mail source feeds the seam": the IMAP sync does. Attachments are still not stored. +- 8258181: Inbound mail has an ingest seam: `MailIngestService`, the kernel service `mailbox`, turns one raw RFC 822 message into one `sys_mail_message` row, associates it at ingest with the records whose `mailAssociation` email fields hold a participant's address, and re-matches a mailbox on request + + Clause-②: yes (the package gains an exported ingest entry point it does not export today; a widening of the public surface) + + - **`ingest({ mailboxId, raw, receivedAt? }, context)`** reads one message and answers `stored`, `duplicate` or `suppressed`. Only the system context may call it: a message enters a mailbox from its mail source, never from a user. + - **Participants.** The addresses of From, Sender, Reply-To, To and Cc, plus the same five headers of a message forwarded as an attachment (a `message/rfc822` part of the received message). The mailbox's own address is left out. The set is stored on the row in `match_addresses`, a JSON list of `{ address, source }` where `source` is `outer` or `attachment`. A forward quoted inline in the body is not read yet, and `inline` is declared so a later reader of it can store its addresses. + - **Association.** The message is linked, one `sys_mail_association` row each (`matched_by: 'ingest'`), to every record of every object with an `email` field declaring `mailAssociation: true` whose value equals a participant address (ASCII case-insensitive), inside the mailbox's organization only. A flagged object with no organization column is skipped and logged once. No app code and no resolver service is involved. A message nothing matches is stored `association_state: 'unassociated'`: the restricted pool. + - **Dedupe.** One row per message per mailbox. The key is `mid:` plus the SHA-256 of the Message-ID, or `hdr:` plus the SHA-256 of the header block as received for a message without one. A second fetch answers `duplicate` and writes nothing, and the unique key settles a concurrent pair. + - **Threading.** `message_id`, `in_reply_to` and `reference_ids` hold the RFC 5322 ids with their angle brackets stripped. + - **Suppression.** No row is written for an auto-reply (`Auto-Submitted` other than `no`, `Precedence: bulk`, `junk` or `list`, `X-Autoreply` or `X-Autorespond` other than `no`) or a bounce (`multipart/report; report-type=delivery-status`). + - **`rematch({ mailboxId, messageIds? }, context)`** replays each message's stored participant set against the records that exist now. It adds the missing links (`matched_by: 'rematch'`) and moves a pool message to `associated` once it has one. It never removes a link. A stored set that is not the stored shape is reported in `unreadable` and not matched. The system context may run it, and so may the mailbox's owner and a `manage_mailboxes` holder in the mailbox's organization. Anyone else is refused with `PERMISSION_DENIED` (403), and another organization's mailbox answers `RECORD_NOT_FOUND` (404). + - **New exports:** `MailIngestService`, `MAILBOX_SERVICE`, `MailboxServiceError` (`code` and `status`), `MAIL_MATCH_ADDRESS_SOURCES`, `MAIL_SUPPRESSION_REASONS`, and the types `MailIngestInput`, `MailIngestResult`, `MailRematchInput`, `MailRematchResult`, `MailboxCallerContext`, `MailIngestEngine`, `MailIngestLogger`, `MailAssociationMatch`, `MailMatchAddress`, `MailMatchAddressSource`, `MailSuppressionReason`, `MailboxServiceErrorCode` and `RawMailMessage`. `MailboxServicePlugin` registers the service in `init()`. + - **New dependencies:** `postal-mime` (MIT-0, no dependencies of its own, ES module and CommonJS builds) reads the MIME structure, the encoded words and the address lists. `@objectstack/types`, already installed through `@objectstack/core`, supplies the keyset walk the re-match pages with. + - **Not yet:** no mail source feeds the seam (the IMAP connector ships separately), and attachments are not stored, so a message forwarded as an attachment keeps the forwarder's own text and not the forwarded message's body. +- 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 [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [3d830b7] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/types@18.0.0 + - @objectstack/connector-imap@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-mailbox/package.json b/packages/services/service-mailbox/package.json index a4826aa34c6..9f33db1b60c 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.5", + "version": "18.0.0", "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 b44faa4e933..b5f880dda20 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,563 @@ # @objectstack/service-messaging +## 18.0.0 + +### Minor Changes + +- 0db5ad5: fix(service-messaging)!: mark-read writes a read receipt only for a notification delivered to that user + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention: `MessagingService.markRead` — the method behind the notifications mark-read door, and behind `markReadAsCaller` — writes a `read` receipt only for a notification that was delivered to that user. A read receipt belongs to a recipient (ADR-0030 keys it by recipient). + + - **Delivered** means a receipt keyed on that user already exists (it is flipped to `read` in place, as before), or the user's inbox holds a message for that notification. The inbox message is enough on its own, because the inbox channel's `delivered` receipt is best-effort. + - **Any other id** writes no receipt, is not counted in `readCount`, and its notification's organization is not read. The response shape `{ success, readCount }` is unchanged. + - **`markAllRead`** is unchanged: every id it sweeps comes from the user's own inbox. + + What changes for you: nothing in what you write. A `readCount` lower than the number of ids sent means some of them were not notifications delivered to that user, and nothing was written for those. +- 24e064f: fix(objectql,service-messaging,service-automation,plugin-approvals)!: a notification carries the organization of what it is about, and one no organization owns is refused instead of stored with `organization_id` NULL + + Clause-②: no (narrowing) — notification rows gain the organization they should already carry; what narrows is an emit that names no organization on an install where none is derivable, which the engine now refuses where it used to store the event row with `organization_id` NULL. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Every notification emitted from a system-context run — a scheduled flow, a record change written by a system context, an approval node — was stored in `sys_notification` with `organization_id` NULL, so reports and cleanup that filter by organization did not see it. Each emit also logged a `[notify] no organization in scope` warning. On an install holding several organizations, the deliveries of the same emit were refused while the flow step reported success, so nothing reached the inbox. + + **Who owns a notification now:** + + - **The run's organization.** A run that carries one emits exactly as before. + - **Else the record it is about.** A `notify` node in a run with no organization reads the organization of the record the notification is about. That is the declared click-through record (`sourceObject` + `sourceId`), else the run's trigger record. It is read from the stored row by id, never from the record snapshot the run carries. + - **Approvals.** Every approval notification carries its request's organization: `approval.requested`, reminders, comments, send-backs, reassignments, escalations and out-of-office notices. + - **Else the owner the engine derives for a system write** (ADR-0131 D9). `sys_notification` is now in the platform-object tenancy ledger, so an emit that names no organization gets the install's sole organization under the `single` posture. + - **The rows below follow the event.** `emit()` stamps the stored event row's organization on its delivery, inbox row and receipt, on both the inline and the outbox path. + + **Refused now:** where no organization is derivable (a walled posture, or a `single` install holding more than one organization), an emit that names none is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`. Nothing is written for it. A `notify` step that hits the refusal fails with `notify failed: ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED: …` instead of succeeding. `MessagingService.emit()` rejects with the engine's error for any other producer. + + **What to do when it refuses:** give the notification an owner. Name the record it is about on the `notify` node (`sourceObject` + `sourceId`), declare the scheduled flow's acting organization (`organization` on its start node), or pass `organizationId` to `emit()` from your own code. + + The `[notify] no organization in scope` warning is gone, because the NULL row it predicted is no longer written. Rows already stored with `organization_id` NULL are not rewritten here. The v18 organization-ownership ceremony attributes them from their source record. + +### Patch Changes + +- a7a48b7: The storage store's by-id methods and the HTTP outbox's `redeliver` now pass the explicit system opt-in (`{ isSystem: true }`) on their data-engine calls. Until now they reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. + + Clause-②: no + + - **service-storage.** `StorageMetadataStore.getFile`, `updateFile`, `deleteFile`, `getSession`, `updateSession` and `deleteSession` take the opt-in inside the store. Access stays by id, and the reads stay unscoped by organization, as before. On update and delete the acting organization still reaches the driver beside the opt-in, so a row stamped for another organization is still out of reach of these doors. The doors keep the authorization they already ran. + - **service-storage, the update payload.** `updateFile` and `updateSession` now send the caller's patch alone, where they used to send the whole row read back merged with it. The engine's read-only strip, which does not run for a system write, used to take `organization_id` and the four audit columns out of that row; now the store never sends them. The stored row is the same as before, and a column another writer changed between the read and the write is no longer reverted by it. + - **service-messaging.** `SqlHttpOutbox.redeliver` takes the opt-in on both of its reads and on its reset write. The caller's `tenantId` stays on every call as the driver-level scope, so a delivery in another organization is still not found. The reset write states `bypassTenantAudit: false`, so a redelivery from a caller with no organization is still reported by the driver's tenant audit. + - None of the gates the security middleware runs before its hand-off applies to these calls. ⛔ No new export on either package entry, and no new elevation API. +- b88c356: The remaining platform producers in these four packages now pass the explicit system opt-in (`{ isSystem: true }`) on their data-engine calls. Until now they reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. + + Clause-②: no + + - **service-messaging, the inbox read state.** `listInbox` (and its unread total), the receipt read behind it, and mark-read / mark-all-read take the opt-in inside the service. Their scope is unchanged: every read of a user's rows is keyed on the user id the door derived from the session, the receipt a mark-read inserts is stamped with it, and the receipt it updates is one a user-keyed read returned. + - **service-messaging, `owner_of:` audiences.** The record read takes the opt-in, the same posture as the email lookup beside it. It reads only `id` and the owner fields, and only the owner id leaves the resolver. An `owner_of:` audience on an object whose sharing model is `private` now resolves its owner; before, it resolved to nobody. + - **service-messaging, the rest of the fan-out and the outboxes.** The `role:` and `team:` membership reads, the email and SMS recipient reads, the notification template read, the dedup lookup in `emit()`, and both outboxes' enqueue, ack and list. + - **service-storage.** `StorageMetadataStore.createFile` and `createSession` insert under the opt-in. The organization still reaches the driver beside it, so the stored organization is unchanged, and the file's `owner_id` is still the uploading user. + - **service-settings.** The `sys_secret` store the plugin builds (insert, get, update), and the read that verifies a rotation before the old secret is reaped. A store `update` now writes the `ciphertext` it is given; without a context the engine's read-only strip dropped it. No caller in this repository uses `update`. + - **metadata-protocol.** `SysMetadataRepository.getByHash`, `list`, `history` and the history replay of `watch()`. + - None of the gates the middleware runs before its hand-off applies to these calls. ⛔ No new export on any package entry, and no new elevation API. +- d294951: Boot-time system-table provisioning honours `OS_SKIP_SCHEMA_SYNC` / `skipSchemaSync` + + Clause-②: no + + `OS_SKIP_SCHEMA_SYNC=1`, or `ObjectQLPlugin`'s `skipSchemaSync` option, declares that a deployment's DDL is managed out-of-band ("Use after running migrations manually"). The engine's boot honoured it, but `AuditPlugin` and `MessagingServicePlugin` still created their system tables at `kernel:ready`. A deployment that set the flag got tables created behind its back while its boot log said no DDL ran, and a database role without DDL rights got a DDL failure it never asked for. + + - **What changes.** With the flag set, `AuditPlugin` (`sys_audit_log`, `sys_activity`, `sys_comment`, `sys_comment_reaction`) and `MessagingServicePlugin` (`sys_notification`, `sys_inbox_message`, `sys_notification_receipt`, `sys_notification_delivery`, `sys_notification_preference`, `sys_notification_subscription`, `sys_notification_template`, `sys_http_delivery`) issue no DDL and no existence probe at boot. Each logs one `info` line naming the objects it did not provision and the flag, and pointing at `os migrate apply`, which creates any that are missing (`os migrate plan` lists them as `create_table`). That path was measured to create all twelve tables, on one database, with the telemetry sibling database, and with the flag set in the migrate run's own environment. + - **Which value is read.** The one `ObjectQLPlugin` resolved: the option, else the environment variable. An explicit `skipSchemaSync: false` therefore still provisions under `OS_SKIP_SCHEMA_SYNC=1`. `ObjectQLPlugin` publishes that value on the engine instance it registers. It is a property of the instance, not a declared member, so no type or export changes. + - **What is unchanged.** With the flag unset, provisioning is exactly as before. `syncObjectSchema` is not gated, so on a flag deployment a runtime publish still gives a new object its table. +- 6a6a4d3: fix: a refused on-demand schema sync (`syncObjectSchema`) is logged at `error`, naming what was not created and how to fix it (#22985) + + Three places create an object's storage after the boot's own schema sync has run. When the driver refused that DDL, each of them logged a `warn` and carried on, so the deployment looked healthy while a table did not exist. Each now logs one `error` line that says what was not created, that nothing else will look wrong, and what fixes it. The driver's refusal is kept out of the line itself. Nothing else changes: the boot, the connect and the publish still complete as before. + + - **`@objectstack/service-messaging`**: boot-time provisioning of the eight notification tables. A refused table logs `[messaging] could not provision
storage — its storage was NOT created. …` at `error`, once per table per boot, with the refusal in the record's meta. The fix it names: resolve the refusal, then restart or run `os migrate apply`. This used to be a `warn`. Under `skipSchemaSync` / `OS_SKIP_SCHEMA_SYNC=1` the provisioning still issues no DDL, so this line cannot appear. + - **`@objectstack/service-datasource`**: connecting a declared datasource re-runs the schema sync of every object bound to it. A refused object logs `datasource '': object '' has NO working storage here — …` at `error`, once per object per connect, with the refusal in meta. On a logger that has no `error`, the same line is logged at `warn`. The fix it names: resolve the refusal, then restart. This used to be a `warn` reading `syncObjectSchema('') failed`. + - **`@objectstack/metadata-protocol`**: publishing an object whose storage sync is refused logs `[Protocol] object '' was published but its storage was NOT created or altered — …` with `console.error`. The driver's refusal is the second argument. The fix it names: resolve the refusal, then publish the object again or restart. The publish still answers success. This replaces the `console.warn` line `[Protocol] table sync failed for object '': …`, so a log search for that text now finds nothing. +- 5d63c37: The notification and HTTP outboxes read their claim credential back from the store, so `ack()` lands on a Postgres table whose epoch-ms columns are still `real` + + Clause-②: no + + Tables created while the `number` field type still mapped to `real` keep those columns as IEEE-754 binary32. Newer tables get an exact numeric column, and existing columns are not migrated. A binary32 `claimed_at` stores a rounding of the claim's `now`, to the nearest 131 072 ms at today's magnitude, and reads back a nearby decimal. `SqlNotificationOutbox` and `SqlHttpOutbox` handed out the in-memory `now` as the claim credential, so it never equalled the stored value. Every `ack()` was refused with `DELIVERY_NOT_ELIGIBLE` as a lost claim, and the dispatcher absorbed the refusal. The row stayed `in_flight` with `attempts = 0`, and every reap handed it to the next claim. The notification was re-delivered about every 131 s, with no end. + + - **What changes.** `claim()` and `claimDigest()` return `claimedAt` as the `claimed_at` value the store returned after the claiming update. The value written is unchanged. The rows a claim took are identified by candidate id, holder and status. `claimed_at` is no longer compared with the client's `now` in that read. `ack()`'s ownership check and its compare-and-set are unchanged. They now compare that stored value with the stored value, which is exact on any numeric column. + - **A stuck row heals by itself.** A row left `in_flight` by a refused ack is reaped by the visibility timeout, claimed again, sent once and acked. No operator act is needed. On a `real` column the reap still runs at binary32 granularity, up to about 131 s after `claimTtlMs`. + - **What is unchanged.** A genuinely lost claim is still refused. If another node, or this node's own later claim, reaped and re-claimed the row while a send ran, the late ack gets `DELIVERY_NOT_ELIGIBLE` and writes nothing. The `ClaimedDeliveryRecord` and `HttpClaimCredential` types are unchanged. Existing `real` columns are not migrated, and duplicate inbox rows that were already delivered stay as they are. +- 30ce56e: Clause-②: no + + Setup pages describe their records in plain admin sentences. Business Units now reads "Your + company structure — divisions, departments, regions and offices — used to scope data and + reports.", Notification Events "Every notification the platform sent, one row per event.", + Notification Preferences "Which topics and channels each user receives; admins set the + defaults.", Notification Templates "The subject and body each notification topic sends, per + channel and language." and HTTP Deliveries "Outgoing HTTP calls made by webhooks and flows, one + row per call, with its delivery status." Field help on Users, Organizations, Invitations and SSO + Providers, and the metadata-storage and notification-delivery object descriptions, no longer + carry design-record numbers; those references stay in the code comments. The zh-CN, ja-JP and + es-ES translations follow. Wording only: no field, permission or behaviour changes. +- 8f4260f: Setup's Invitations, API Keys, OAuth Applications and Notification Preferences, Subscriptions and Templates lists say what belongs on them when they are empty, and API Keys gains a Create Key button (#23852) + + Clause-②: no + + **What an admin saw.** An empty Invitations, API Keys or OAuth Applications list read "No identity records — these records are created by the authentication provider … not added by hand here", which is false for all three. The three notification lists read the generic "Nothing here yet". API Keys had no way to create a key at all. + + **What changes.** + + - Every list view of `sys_invitation`, `sys_api_key` and `sys_oauth_application` declares its own `emptyState` (a title and one sentence), and a filtered view names itself: Invitations opens on "No pending invitations", the Revoked keys view reads "No revoked keys". + - `sys_notification_preference`, `sys_notification_subscription` and `sys_notification_template` each ship one list view, `all`, declared only for its `emptyState`: it sets no columns (`columns: []`, so the grid keeps the columns it derived before), no filter, no sort and no page size. + - `sys_api_key` gains `create_api_key` ("Create Key"), a primary list-toolbar action on every API Keys list, Setup's and the Account app's. It calls the existing `POST /api/v1/keys` route with the name you give it; the key is created for you, in the organization you are working in, and the raw key is shown once in a dialog you close after copying it. Nothing about the route changes, and the data API still does not create keys. + - The new copy ships in `en`, `zh-CN`, `ja-JP` and `es-ES`. + + Unchanged: every field, index, permission and the `revoke_api_key` / `restore_api_key` row actions. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index 0f34d067aaf..19d1cbeda3c 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Messaging Service for ObjectStack — outbound notification dispatch (ADR-0012). Ships the MessagingChannel registry, emit() fan-out, and the always-on inbox channel; other channels (email/webhook/push/IM) plug in.", "type": "module", diff --git a/packages/services/service-package/CHANGELOG.md b/packages/services/service-package/CHANGELOG.md index 556ff7ec772..af38da92bb0 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,455 @@ # @objectstack/service-package +## 18.0.0 + +### Minor Changes + +- c7168ac: Clause-②: yes (widening) + + fix(service-package,cloud-connection): a stored or installed package body converts forward under ADR-0087 D2 at the `sys_packages` boot hydration and at the marketplace install, and one refused `sys_packages` record no longer stops the hydration of the rest + + The registration door (`ObjectQL.convertPackageBodyForward`, `@objectstack/objectql`) already replays the versioned forward conversion for a package body that declares its `engines.protocol` range. Two more doors that load a body at rest now go through that same entry point, before the registry judges the body: + + - **`sys_packages` boot hydration** (`PackageServicePlugin.start`). A stored record whose range admits this runtime, built before a retirement (for example a `type: 'home'` page with `isDefault: true`), now hydrates converted, with the engine's named warning, where the registry used to refuse it. The stored row is not rewritten, so it converts again at every boot. + - A record the registry still refuses is reported **by name** at `error`, with the refusal attached, and the records after it keep hydrating. Before, the first refusal threw out of the whole loop to a `debug` line, and every durable package after it was absent from the registry with nothing saying so. + - `Hydrated N package(s) from sys_packages into registry` counts the records the registry accepted in that pass. + - The ADR-0087 D1 handshake still runs first: a record whose declared range excludes this runtime's major is refused by it, named, as before. + - **The marketplace install** (`MarketplaceInstallLocalPlugin`, install and boot rehydrate). The body is converted once per load, and that one converted body is what `manifest.register`, the handler binder, the side effects (translations, seed datasets) and the rehydrate's sample-data heal read. Before, `register` converted its own copy while the side readers read the raw ledger entry. The ledger keeps the body as installed. + + Nothing is narrowed. A body with no declared range (a manifest built in code) meets the registry's refusals exactly as before at both doors. The install primitive `protocol.installPackage` (`POST /api/v1/packages`) is authoring-side and stays strict: the registry judges the manifest as handed. + +### Patch Changes + +- 84c4fff: `os migrate plan` / `apply` cover `sys_packages`, and the boot honours `OS_SKIP_SCHEMA_SYNC` for it + + Clause-②: no + + `sys_packages`, the package registry's table, is created by `PackageServicePlugin.start()` with its own DDL, because it keys on `(id, version)` and a declared object cannot carry that key. So it was never on the managed migration path: `os migrate plan` did not list it and `os migrate apply` did not create it. A deployment that sets `OS_SKIP_SCHEMA_SYNC=1` ("Use after running migrations manually") still had it created at boot, and had no documented way to create it otherwise. + + - **The migration path covers it.** `PackageServicePlugin.init()` declares the table as its `package.storage` kernel service: a read-only catalog probe (SQLite, PostgreSQL and MySQL each ask their own catalog) and the boot's own create statements. `os migrate plan` lists `sys_packages` as `create_table` when it is absent, and `os migrate apply` creates it, with the same statements the boot runs, so the table and its `idx_packages_latest` index come out exactly as a boot creates them. Whether the flag is set does not matter to either command. Where the table's presence cannot be read (a driver that names no SQL dialect), the run says it did not examine the table, and neither lists nor creates it. + - **Then the boot honours the flag.** With the flag set, `PackageServicePlugin` issues no DDL and no existence probe at boot, and logs one `info` line naming `sys_packages` and the flag, and pointing at `os migrate apply`. The `package` service is still registered. The value read is the one `ObjectQLPlugin` resolved (the `skipSchemaSync` option, else the environment variable), as `plugin-audit` and `service-messaging` read it. On a flag deployment, run `os migrate apply` before the first boot that needs the table. + - **What is unchanged.** With the flag unset, the boot creates the table exactly as before. An existing `sys_packages` table is not altered by either command; the plan lists nothing for it. No type or export changes. +- 66d2757: feat(spec)!: every installed-package row declares where it came from, a closed `source: 'code' | 'marketplace' | 'authored'`, and the `GET /packages` rows declare the `writable` verdict they already served (#23972) + + Clause-②: yes (narrowing — the served row gains two declared keys, and the record schema, the served-row union and the registry's installPackage signature each refuse what they accepted before) + + **BREAKING** — three published accept sets narrow. `InstalledPackageSchema` refuses a row without `source`, or with a value outside `code` / `marketplace` / `authored`. The served-row union `InstalledPackageAtEitherStageSchema` refuses a row without a boolean `writable`. `SchemaRegistry.installPackage` and `EngineSchemaRegistryView.installPackage` take `source` as their second argument and refuse a call without it. The migration is below. None of it is mechanically convertible, because only the door that registers a package knows where it came from. + + **What is new on the wire.** `GET /api/v1/packages` and `GET /api/v1/packages/:id` serve two declared keys on every row: + + - `source` names the package's origin. `code` means the deployment's own code registered it (a `defineStack()` host, a plugin's bundled manifest, a platform package). `marketplace` means the marketplace install-local plugin installed it, and that holds after a restart too, when the plugin rehydrates it from its ledger. `authored` means it is a database-authored package (a Studio base, a duplicate, an HTTP install), including after the restart that rehydrates it from `sys_packages`. The door that registers the package stamps the value. No reader derives it. + - `writable` is the server's own writability verdict (ADR-0070 D2), served on these rows since #14375 and now declared. It is computed per read and never stored, so it is declared on the served row (`InstalledPackageAtEitherStageSchema`) and not on the registry record. + + FROM → TO for a reader of these rows: + + | you did | do instead | + | --- | --- | + | treated `writable: true` as authored, and looked up each non-writable row in the marketplace's installed list (the control plane's, else install-local's) to tell marketplace from code | read `row.source` | + | read the undeclared `writable` | read the declared `writable` (unchanged value) | + + The two keys are separate statements: `source` says where the package came from, and `writable` says what this server lets an org do to it. + + **`InstalledPackageSchema` requires `source`.** It is `InstalledPackageSourceSchema`, exported from `@objectstack/spec/kernel`. A row without it, or with a value outside the closed set, is refused at the `source` key. + + **API change for code that registers packages (`@objectstack/objectql`).** The registry records the origin and never infers it: + + | you wrote | write instead | + | --- | --- | + | `registry.installPackage(manifest)` | `registry.installPackage(manifest, 'code')` (or `'marketplace'` / `'authored'`, whichever is where the package came from) | + | `registry.installPackage(manifest, settings, scope)` | `registry.installPackage(manifest, source, settings, scope)` | + | `ctx.getService('manifest').register(artifact)` for a marketplace install | `ctx.getService('manifest').register(artifact, { source: 'marketplace' })` | + + `SchemaRegistry.installPackage` throws a `TypeError` naming the closed set when `source` is missing or unknown, before anything is recorded. `ObjectQL.registerApp(body, scope?, source?)` and the `manifest` service record `'code'` when no source is given. They accept `'code'` or `'marketplace'` and refuse `'authored'`. The `IObjectQLEngine` registry contract (`@objectstack/spec/contracts`) declares the same `installPackage(manifest, source, settings?)`. + + **The one-line fix:** pass the package's origin as `installPackage`'s second argument. + + Readers: objectui's Setup → Packages Source column and its `?source=` filter (it switches from the two-read join to `source` in its own follow-up), and the client SDK's `packages.list()` / `packages.get()` row types. + + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [077b75f] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/metadata-core@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index 0736b54c828..ab9decbc4fa 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Package management service for ObjectStack — publish, install, and manage packages", "type": "module", diff --git a/packages/services/service-print/CHANGELOG.md b/packages/services/service-print/CHANGELOG.md index ed1c7ae85a4..7f381eb823d 100644 --- a/packages/services/service-print/CHANGELOG.md +++ b/packages/services/service-print/CHANGELOG.md @@ -1,5 +1,455 @@ # @objectstack/service-print +## 18.0.0 + +### Minor Changes + +- b15f8a1: feat(service-print): print a record's print page to a PDF in headless Chromium, as the requesting user, and archive it as a file attached to the record + + Clause-②: yes (widening) + + A new package, `@objectstack/service-print`. `PrintServicePlugin` registers the in-process `print` service (`IPrintService`): `status()` and `printRecordToFile({ app, object, recordId, page, filename? }, context)`. It adds no route, no metadata key and no error code. Compose it explicitly: + + ```ts + import { PrintServicePlugin } from '@objectstack/service-print'; + plugins: [new PrintServicePlugin()] + ``` + + - **One engine, headless Chromium, in one of two placements.** A local executable (`OS_PRINT_CHROMIUM_PATH`, option `chromiumPath`), or a remote browser's DevTools WebSocket endpoint (`OS_PRINT_BROWSER_ENDPOINT`, option `browserEndpoint`). Either one needs `OS_PRINT_CONSOLE_URL` (option `consoleUrl`): the console's URL as that browser reaches it, `/_console` included. Configuring both placements is refused. `playwright-core` is pinned exact on this package only. + - **Loud at startup.** `start()` probes the engine and logs its placement and Chromium version. An engine that is not configured, is misconfigured or does not answer is reported once, with the setting to fix, and `status()` says why. A remote endpoint is never logged beyond its scheme and host. + - **As the requesting user.** The page is rendered through a short-lived session minted for the caller's own principal by the auth service (`mintPrincipalSession`), and revoked in a `finally`. There is no system principal. + - **Refusals first, each with `code` and `status`, before any session is minted or browser started** (an engine that failed its last probe is re-probed only after them all): a caller that is not a human user acting for themselves (`PERMISSION_DENIED` / 403); an engine that is not ready (`SERVICE_UNAVAILABLE` / 503); an unknown page (`RESOURCE_NOT_FOUND` / 404); a page that is not the object's default record page or declares no `print` (`VALIDATION_ERROR` / 400); a record the caller cannot read (`RECORD_NOT_FOUND` / 404); an object without `enable: { files: true }` (`FILES_DISABLED` / 403). + - **The archive runs as the requesting user.** `@objectstack/service-storage`'s `file-attachments` service gains a caller-scoped face, `ICallerFileAttachmentService` (exported type), on the same slot and the same writer: `assertMayAttach(parentObject, parentId, context)` throws the insert's own refusal without writing (`PERMISSION_DENIED` / 403 when the caller may not create attachments, `ATTACHMENT_PARENT_ACCESS` / 403 when the attach rule refuses), and `attachFileAs(input, context)` writes one `sys_file` owned by the caller and one `sys_attachment` inserted under the caller's context, so every insert gate judges the caller and the row is stamped `uploaded_by` the caller. The print service asks `assertMayAttach` in its files step, before any session is minted or browser started. `attachFileAs` acts only in the caller's own organization: an `organizationId` other than the context's `tenantId` (or `null` when it names none) is refused `PERMISSION_DENIED` / 403. `attachFile` (the system verb) is unchanged. + + `@objectstack/spec`'s `llms.txt` counts the new package: the workspace publishes 72 packages. +- d9a749b: Clause-②: no + + feat(service-print): print a record from the console — `PrintServicePlugin` mounts `POST /api/v1/print/records`, which prints the record as the signed-in caller and answers with the file it attached + + - **What it adds.** One HTTP route on the kernel's HTTP server, mounted at `kernel:ready` whenever the plugin is composed. Its JSON body is the in-process request, `{ app, object, recordId, page, filename? }`. The route resolves the caller with the platform's one authorization resolver, hands that caller's execution context and the request to `IPrintService.printRecordToFile`, and answers `201` with `{ fileId, attachmentId, size, filename }`. The service attaches the PDF once, as the caller; the route never attaches anything itself. + - **How an app uses it.** Declare a `type: 'api'` action whose `target` is `/api/v1/print/records`, with `recordIdParam: 'recordId'` and the rest of the request in `bodyExtra` (`app`, `object`, `page`). The console sends the clicked record's id and those fields, and shows a refusal's message as the action's error. + - **What the route refuses, before the service is asked:** an anonymous request (`UNAUTHENTICATED` / 401); a sign-in held by an authentication policy (`403` with the policy's own code); a request from an impersonation session (`PERMISSION_DENIED` / 403 — a print is archived in the session user's name, so it is never made for them by someone signed in as them); a query parameter, or a body field outside the request (`VALIDATION_ERROR` / 400). A permission store that cannot be read answers its own `SERVICE_UNAVAILABLE` / 503. + - **What it relays.** Every refusal the service declares keeps its `code` and `status` (`SERVICE_UNAVAILABLE` / 503 when no print engine is configured, `VALIDATION_ERROR` / 400 for a page without `print`, `RECORD_NOT_FOUND` / 404 for a record the caller cannot read, the files and attach refusals, `TIMEOUT` / 504, the archive's own). A 4xx keeps the service's message. A 5xx answers with the route's own sentence, and the service's text goes to the server fault log. + - **Stated limits.** The route writes no audit row of its own: a print's session sign-in and sign-out are recorded as any sign-in is, and `sys_file.owner_id` and `sys_attachment.uploaded_by` name the caller. An impersonation session is recognised by the mark the auth service writes on it (`session.impersonatedBy`, stored as `sys_session.impersonated_by`); an auth provider that marks impersonation some other way is not recognised. + - **Unchanged:** the in-process `print` service and its refusals, every export, every plugin option and environment variable, and the error-code vocabulary. A kernel with no HTTP server mounts no route and says so once at `kernel:ready`. + +### Patch Changes + +- 1fee13d: **Shipped in 18.0.0-next.3**, whose CHANGELOG missed this entry; listed here one prerelease late. + + Clause-②: no + + fix(service-print): the local placement binds its browser server's control socket to `127.0.0.1` itself, instead of relying on `playwright-core`'s default host + + - **What changed.** With `OS_PRINT_CHROMIUM_PATH` (option `chromiumPath`), each render and each boot probe starts a Chromium browser server and connects to it. That server's control socket is now opened with `host: '127.0.0.1'`. Before, no host was passed, so the bind followed `playwright-core`'s default (`localhost`, loopback only in the pinned 1.63.0). Whoever reaches that socket and knows its path drives a browser signed in as the requesting user. How far it reaches is now set by this package, and a `playwright-core` upgrade cannot widen it without a diff here. + - **What it means for a deployment.** Nothing changes on a host whose `localhost` is `127.0.0.1`, which is the usual case. On a host with no IPv4 loopback address, the local placement cannot open the socket. Its boot probe logs `Chromium could not be reached at the local executable …: listen EADDRNOTAVAIL …` and printing stays off. The socket is never bound to another address instead. On such a host, use the remote placement (`OS_PRINT_BROWSER_ENDPOINT`). + - **Unchanged:** the remote placement, the port (still random), every option and environment variable, and the exports. +- b036ac5: feat(spec): `AuthSessionApi.getSession`'s result declares the fields its in-process readers read + + Clause-②: yes (widening) + + - **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains five optional members**, each declared for a named reader and typed as that reader takes it: `user.email` and `session.id` / `session.token` (read by `resolveAuthzContext`'s session arm in `@objectstack/core`), `user.authGate` as `unknown` (the ADR-0069 posture, which `normalizeAuthGate` validates against the closed posture vocabulary), and `session.impersonatedBy` as `string | null` (read by the print door's `impersonatorOf` in `@objectstack/service-print`). Before this, those reads went through `any` or a cast, so a field better-auth dropped or renamed compiled and read `undefined`. For `impersonatedBy`, that would have silently turned off the impersonation refusal. Every member is optional, so every implementer and caller typechecks unchanged. + - **`@objectstack/core`**: `resolveAuthzContext`'s session arm reads the session through the declared type instead of `any`. `ResolveAuthzInput.getSession`'s own type is unchanged. Runtime behaviour is unchanged. + - **`@objectstack/service-print`**: `impersonatorOf` reads `session.impersonatedBy` through the declared type instead of a cast. Runtime behaviour is unchanged. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [29527f2] +- Updated dependencies [7c7741d] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [a7a48b7] +- Updated dependencies [b88c356] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [29678f2] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [7ebbc01] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [3513ac7] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [59d993c] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [e36ee53] +- Updated dependencies [1884948] +- Updated dependencies [54c3ce1] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [dd986d8] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [ce3d0ad] +- Updated dependencies [557ae7c] +- Updated dependencies [f782f17] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [3d6392f] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [08b38cb] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [8d3dd6f] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [abb6df0] +- Updated dependencies [0b1179f] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [d8e11b5] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/service-storage@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-print/package.json b/packages/services/service-print/package.json index 9ecd4ee5ba9..618c7c2cbd0 100644 --- a/packages/services/service-print/package.json +++ b/packages/services/service-print/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-print", - "version": "18.0.0-next.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Print Service for ObjectStack — renders a record's print page in headless Chromium under the requesting user's own principal and archives the PDF as a file attached to the record.", "type": "module", diff --git a/packages/services/service-queue/CHANGELOG.md b/packages/services/service-queue/CHANGELOG.md index 87575f15f89..281eb565912 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,410 @@ # @objectstack/service-queue +## 18.0.0 + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index 9a08291cb12..7727b672a8d 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Queue Service for ObjectStack — implements IQueueService with in-memory and durable DB-backed (sys_job_queue) adapters", "type": "module", diff --git a/packages/services/service-realtime/CHANGELOG.md b/packages/services/service-realtime/CHANGELOG.md index 781b5d6f8fb..f81768268c6 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,472 @@ # @objectstack/service-realtime +## 18.0.0 + +### Minor Changes + +- 1920cf3: feat(platform-objects,service-automation,service-realtime)!: seven deployment-level platform tables lose their injected organization column, and reading them needs `manage_platform_settings` (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` hold deployment-level state. No writer attributes a row of any of them to an organization: every write is a system-context write whose row names none, and nothing writes `sys_presence` through ObjectQL at all. So the injected `organization_id` column only ever held NULL. ADR-0131 D7 takes it off: each object now declares `systemFields: { tenant: false }`. + + With no column there is no tenant wall, so these tables are governed by object permission. Each also declares `requiredPermissions: ['manage_platform_settings']`. Without that gate, a walled deployment's `organization_admin`, whose grant carries the superuser bits on every object, would read every other organization's job errors, queued payloads, dispatch keys and migration traces. + + **What moves for consumers.** + + - **The column.** `organization_id` is no longer a field of these seven objects. A filter, list-view column, report grouping, formula or seed key naming it on one of them is now an unknown field. Delete the reference: no organization owns a row of these tables. + - **Who reads, on a walled posture** (`group` or `isolated`). Before: the wall compared the NULL column to the caller's organization, so every reader got zero rows, platform administrators included (unless the deployment declared the table platform-global, which stood the wall down). Now: a principal holding `manage_platform_settings` (platform administrators hold it) lists every row; anyone else is refused `403 PERMISSION_DENIED`. + - **Who reads, on the `single` posture.** Before: any principal with a read grant on the object read every row, an organization administrator included. Now: only a principal holding `manage_platform_settings` reads; an organization administrator who is not a platform administrator is refused `403 PERMISSION_DENIED`. Grant the capability to an operator who needs these tables. + + **Unchanged.** Every platform writer and reader of these tables uses a system context, which no capability gate applies to, so job scheduling, the queue, flow dispatch, migration flags and the migration journal behave as before. The physical unique indexes are unchanged: none of these objects declares an organization-scoped one. + + **Existing databases.** Schema sync only adds, so the physical `organization_id` column stays on each existing table (with its index, where the deployment indexed it), and the boot drift report names it orphaned. By the writer census it holds only NULL, so dropping it loses nothing: `os migrate apply --allow-destructive` drops it, the remedy the drift report names. + +### Patch Changes + +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index 1069e7447cc..bec885d1012 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Realtime Service for ObjectStack — implements IRealtimeService with WebSocket and in-memory pub/sub", "type": "module", diff --git a/packages/services/service-settings/CHANGELOG.md b/packages/services/service-settings/CHANGELOG.md index 0aef860c113..25e361e2729 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,727 @@ # @objectstack/service-settings +## 18.0.0 + +### Minor Changes + +- b12e16a: feat(service-settings)!: the settings audit trail's global rung moves to the new deployment-level `sys_platform_setting_audit` + + Clause-②: yes (narrowing) + + + + **BREAKING** for a reader of the settings audit trail, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D7; ruling record 6257510973, Q4 A). This is the expand half of the change: it adds the new write shape, and the 18.0 rows stay where they are and readable. No row an 18.0 install wrote is moved, deleted or re-stamped. + + - **Before.** A write of a global-scope settings key appended its history row to `sys_setting_audit`, stamped with the Default Organization under `single`. Any principal the settings read door let read the namespace could list it there. + - **Now.** That row goes to the new `sys_platform_setting_audit`. It has the same row shape, `scope` is always `global`, and there is no organization column (`systemFields: { tenant: false }`). Reading it through the generic data API requires the `manage_platform_settings` capability, as `sys_platform_setting` does, and each namespace's `readPermission` applies there too. Tenant and user rows stay in `sys_setting_audit`, stamped as before. + - **18.0 rows.** Global-rung rows an 18.0 install wrote stay in `sys_setting_audit`, stamped, until the upgrade ceremony moves them. The global rung's history is both tables: the sibling, plus `sys_setting_audit` rows at `scope = global`. + + **The remedy.** If you list or filter the global rung's settings history (`sys_setting_audit` with `scope = global`), read `sys_platform_setting_audit` as well, with a principal that holds `manage_platform_settings`. Select the 18.0 rows by `scope`, never by a missing organization: they carry the Default Organization. Nothing changes for a reader of tenant or user rows. +- 6729e10: feat(plugin-audit,plugin-security)!: the compliance ledger `sys_audit_log` loses its injected organization column; the organization a row is about stays in `tenant_id`, and an organization reader is scoped on it by a platform row policy (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + + Some ledger rows are about deployment-level actions no organization owns: a change of platform-administrator standing at boot, a change to a global setting, plugin-auth's administrative user writes. An injected organization column made the tenant wall the ledger's anchor, so under a walled posture those rows were hidden from every reader, platform administrators included. ADR-0131 D7 takes the column off: the ledger is governed by object permission, and the organization a row is about is the plain attribution field `tenant_id`, which the tenant-field resolver does not claim. + + - **`sys_audit_log`** (`@objectstack/plugin-audit`) declares `systemFields: { tenant: false }`, so the registry injects no `organization_id` and a new table is provisioned without it. `tenant_id` (a lookup to `sys_organization`) is unchanged and is the only organization column. The record mirror, the record-view writer and the sign-in writer stamp it as before and no longer stamp `organization_id`. A write that still names `organization_id` on the ledger is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. + - **The read scope** (`@objectstack/plugin-security`, the shipped permission sets): a platform row policy, `sys_audit_log_org` (`tenant_id == current_user.organization_id`), in `organization_admin` (and its no-bypass variant), `member_default` and `viewer_readonly`. `organization_admin` also names `sys_audit_log` explicitly, read only and without `viewAllRecords` / `modifyAllRecords`: its wildcard's superuser bypass would otherwise skip the policy on an object with no tenant column. Under an organization wall an organization administrator or viewer reads the rows about its active organization; a platform administrator (`admin_full_access`) reads every row, the rows about no organization included. Under `single` the policy is stripped by provenance (ADR-0105 D3), as every platform tenant policy is. + - **`config_change` rows** (`@objectstack/service-settings`): a GLOBAL-scope settings change is about no organization, so its ledger row carries no `tenant_id`, whatever organization the writing session had active. Tenant- and user-scope changes keep the writer's organization. The settings writer and plugin-security's platform-admin standing writer no longer probe for or stamp `organization_id`. + - **Retention** (`@objectstack/objectql`): a tenant-scope `lifecycle.retention_overrides` window on `sys_audit_log` partitions the reaper's and the archiver's passes on `tenant_id`, and the global pass keeps the rows with no `tenant_id`. + - **`view_all_audit_log`**: its description (`@objectstack/spec/security`) now names the row scope it does not lift; the capability still lifts only the parent-record read gate. + + **What moves for consumers.** + + - **Authored references.** A filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` names `tenant_id` instead; the two held the same value on every row a writer wrote. + - **Who reads what, under a wall.** A platform administrator now also reads the rows about no organization, global settings changes included. An organization administrator reads exactly its active organization's rows. Under the `group` posture an organization reader is scoped to the ACTIVE organization's rows, not the union of its memberships. A permission set an application ships that grants `viewAllRecords` on the ledger (directly or through a wildcard) skips the row policy, as it skips Layer 1 on every object the wall does not cover. + - **Who reads what, under `single`.** The row policy is stripped under `single` (ADR-0105 D3), as every platform tenant policy is. So a deployment that holds more than one organization under `single` serves every organization's ledger rows to every ledger reader. The remedy is a walled posture. + - **Existing databases — nothing moves automatically** (ADR-0131 D14). Schema sync is additive: the physical `organization_id` column stays and the boot drift report names it orphaned. The v18 upgrade ceremony confirms its values equal `tenant_id` and drops it (`os migrate apply --allow-destructive`), reporting any row where they differ. +- c52bfb4: feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less `sys_platform_setting`, and `sys_setting.scope` no longer declares `global` (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + A settings value for a key declared at `global` scope is a deployment-wide value: the mail transport, the SMS, storage, AI and knowledge providers, auth policy, the lifecycle retention defaults. It used to be a `scope: 'global'` row of `sys_setting`, a tenant-scoped table, where the injected `organization_id` column only ever held NULL and a walled posture hid the row from every reader. ADR-0131 D7 moves the rung out: it is now stored in a new platform object, `sys_platform_setting`. + + - **`sys_platform_setting`** (registered by the settings service, beside `sys_setting`): one row per `(namespace, key)` for the deployment, with the `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns of a settings row, and no `scope`, `user_id` or `organization_id` (`systemFields: { tenant: false }`). It is governed by object permission: a generic data read needs `manage_platform_settings` (`requiredPermissions`), which platform administrators hold. Writes go only through the settings door. + - **`SettingsService`** writes a global-scope key to `sys_platform_setting` and reads the cascade's global rung from there alone. Its `sys_setting` reads exclude `scope = 'global'`, so there is one source per rung. The cascade order, the lock semantics, `SpecifierScope` and the `source: 'global'` resolution value are unchanged. A global-scope change's `config_change` audit row names `sys_platform_setting` (`CONFIG_CHANGE_GLOBAL_OBJECT_NAME`); tenant- and user-scope changes still name `sys_setting`. + - **`sys_setting.scope`** no longer declares the `global` option: no write produces such a row. `sys_setting_audit.scope` keeps it, because a global-scope change is still audited there. + - **`os secret orphans` / `os secret rewrap`**: the settings family of the `sys_secret` reference union now reads both `sys_setting.value_enc` and `sys_platform_setting.value_enc`. Without that, every credential held at the global rung would read as unreferenced and be swept. An unreadable `sys_platform_setting` gaps the family, which refuses deletion. + + **What moves for consumers.** + + - **Existing databases — nothing moves automatically** (ADR-0131 D14). A `sys_setting` row at `scope = 'global'` is no longer read; until the v18 upgrade ceremony moves it, that key answers from its next rung or the manifest default. The ceremony moves each such row to `sys_platform_setting` by namespace and key, its `value_enc` handle included. An encrypted value needs no re-encryption: the ciphertext's associated data binds the settings scope, namespace and key, never the holding object or an organization. + - **Authored references.** A filter, list-view column or seed that names `scope = 'global'` on `sys_setting` matches nothing: point it at `sys_platform_setting`, which has no `scope` column. + - **Generic data reads of the global values.** Read `sys_platform_setting`; it needs `manage_platform_settings`. The settings door (`/api/settings/:namespace`) is unchanged and keeps applying each manifest's own read and write capability. + - **Kernels that register the settings objects by hand.** Register `SysPlatformSetting` (`@objectstack/platform-objects/system`) beside `SysSetting`; `SettingsServicePlugin` already does. The service reads both on every resolution, so a kernel missing one fails the read loudly rather than answering a cascade with a rung missing. +- 7518ee3: fix(service-settings)!: the user rung answers only its owner, and a write of a user-scoped key must name its user + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A settings key declared `scope: 'user'` belongs to one user. `SettingsService` now holds that on both its read and its write side. + + - **Reads.** The user rung answers only a caller whose `SettingsContext.userId` is present and equals the row's owner. A resolve whose context names no user (no `userId`, or an empty one) has no user rung: it answers from the tenant rung, then global, then the manifest default. This holds on every read path that reaches the cascade: `get`, `getMany`, `getNamespace`, `createClient`, `runAction` and `resetNamespace`. + - **Refused now.** `set` and `setMany` refuse a user-scoped key when the context names no user, a `null` reset of one included. The refusal is a `SettingsValidationError` (`code: 'SETTINGS_VALIDATION'`) with one `fields` entry per such key (`code: 'invalid_value'`, `constraint: { scope: 'user' }`). It refuses the whole batch, before anything is written. + - **Unchanged.** A caller with a user id reads and writes only their own rows, as before. Keys declared at `scope: 'tenant'` or `'global'` resolve and write the same with or without a user id. The settings HTTP routes resolve a signed-in user before they read or write any row, so they behave as before. + + What changes for you: when your code calls the settings service for a user-scoped key, pass the user id of the person the value belongs to. A value meant for everyone belongs on a key declared at `scope: 'tenant'` or `'global'`. None of the settings namespaces this repository ships declares a user-scoped key. +- 79c35d4: fix(service-settings)!: tenant- and user-scope settings rows carry the caller's organization, and the data API read of the settings stores applies each namespace's readPermission + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `sys_setting` declares its row identity as `(organization_id, namespace, key, scope, user_id)`. `SettingsService` now carries the organization in that identity itself, on every read and write of a `tenant` or `user` row, because it reads and writes the store under its own system context, which no driver tenant scope or organization wall reaches. + + - **Writes.** A `tenant` or `user` row is written with the caller's organization (`SettingsContext.tenantId`) in its key and in its stored `organization_id`. A write by one organization updates only that organization's row. + - **Reads.** The tenant and user rungs draw on the caller organization's rows and on rows stored with no organization, and take the caller organization's own row when it has one. A row stored with no organization stays the fallback for every organization until that organization writes its own. A caller that names no organization reads only the rows stored with no organization under a walled posture (`group`, `isolated`), and every row under `single`. The global rung (`sys_platform_setting`) is unchanged and read by every organization. + - **Locks.** The lock check on a write reads the same upper rows the caller's cascade reads, so a lock on one organization's tenant row locks nothing for another organization. + - **Refused now.** Under a walled posture, `set` and `setMany` refuse a key declared `scope: 'tenant'` when the context names no organization, a `null` reset of one included. The refusal is a `SettingsValidationError` (`code: 'SETTINGS_VALIDATION'`, HTTP 400 at the settings routes) with one `fields` entry per such key (`code: 'invalid_value'`, `constraint: { scope: 'tenant' }`). It refuses the whole batch, before anything is written. The posture is the one the `tenancy` service reports; `SettingsServicePlugin` supplies it through `bindEngine`. + - **Generic read door.** `SettingsServicePlugin` registers an engine middleware on `sys_setting`, `sys_setting_audit` and `sys_platform_setting`: every non-system read (`find`, `findOne`, `count`, `aggregate`) is narrowed to the namespaces whose `readPermission` the principal holds, by the same rule `GET /api/settings/:namespace` applies. A namespace with no registered manifest reads at the default capability, `setup.access`. + - **Unchanged.** Under `single`, a caller in the default organization reads every value it read before, and a process-wide reader that names no organization reads the organization's current value. Keys declared at `scope: 'global'` resolve and write the same for every caller. + + What changes for you: write a tenant-scope setting from inside the organization it belongs to (an active organization on the session, or `SettingsContext.tenantId` in process). Rows already stored with no organization are not rewritten. +- 20949e7: fix(service-settings)!: the settings REST family refuses an anonymous caller with the shared 401 + + Clause-②: no — it narrows the anonymous accept set to the ADR-0056 D2 floor; no published schema or export moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `registerSettingsRoutes` mounts the settings REST family. Until now it ran no anonymous floor: a caller with no session was answered by the family's own capability logic, with a status other than `401`. ADR-0056 D2 denies anonymous callers by default, and ADR-0138 D2 says a surface outside its door classes answers `401`. + + - **Refused now:** every route of the family answers a caller with no user `401` with code `UNAUTHENTICATED`, in the declared error envelope, before the settings service is consulted. The decision is the platform's shared one (`shouldDenyAnonymous`), mounted once for the whole family. + - **Unchanged:** a signed-in caller is served exactly as before, and the per-namespace read and write capabilities still decide what it may see and change. An authorization-store outage still answers its `503`. + + What changes for you. Sign in before calling the settings routes. If you mount `registerSettingsRoutes` yourself with your own `contextFromRequest`, it must put the caller's `userId` on the context it returns: a context without one is anonymous now, whatever capabilities it carries. +- bae5841: fix(service-settings)!: the settings route family refuses a session the ADR-0069 authentication policy holds, and `SettingsContext` declares the posture as an optional `authGate` + + Clause-②: yes (narrowing) — an optional `authGate` on the exported `SettingsContext` widens the public surface; the family door's refusal of a held session narrows what it answers. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate. REST, the runtime dispatcher and the four sibling families already refuse such a session. The settings family (`registerSettingsRoutes`) resolves its own caller and never asked, so it served a held session as an ordinary signed-in caller. + + - **`SettingsContext.authGate`** (new, optional): the caller's posture, `{ code, message }`, the same shape and meaning as `ExecutionContext.authGate`. Present only while the session is held. + - **The family door** asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) about the context it resolved, on every route, before the anonymous floor and before any settings logic. + - **`SettingsServicePlugin`'s resolver** puts the posture on the context through the shared reader (`normalizeAuthGate`), from the same session read that resolves the caller. + + - **Refused now:** a held session gets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message`, in the family's declared error envelope. This is the answer REST gives the same session. + - **Unchanged:** a session the policy does not hold is served exactly as before, an API-key caller is unaffected because it has no session to hold, an anonymous caller gets the same `401`, and a CORS preflight is answered as before. + + What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before the settings routes answer them, as on every other protected route. With `SettingsServicePlugin` nothing needs configuring. If you mount `registerSettingsRoutes` yourself with your own `contextFromRequest`, a resolver that sets no `authGate` behaves exactly as before; set `authGate` on the context it returns for a held session (`normalizeAuthGate(session.user)` from `@objectstack/core` reads it), and the door refuses on that field. +- 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. +- d65826f: fix: framework writes on tenant-column objects name the organization they can derive (ADR-0131 D9) + + Clause-②: yes (widening) + + A write on a tenant-column object with no organization is derived under `single` and refused under a wall (ADR-0131 D9). Before the engine stops letting such writes through, each of these producers now names the organization it can derive itself. Under `single` that is the Default Organization, which is the owner the engine would derive anyway, so no reader changes. Under a wall the row names an organization only where the producer has one. The rows it cannot attribute are written exactly as before. + + - **`@objectstack/plugin-auth`.** The self-registration grant (`sys_user_permission_set`) and the auth mails about a user (verification, password reset, account invitation, magic link, change-email notice → `sys_email`) read one derivable organization: the `tenancy` service's `defaultOrgId()`. Under `single` that is the Default Organization. Under a wall it is none, because the registrant belongs to no organization yet. The verification and reset mails used to persist with no organization even under `single`. + - **`@objectstack/plugin-audit`.** An activity row (`sys_activity`) about a record with no organization of its own (a `sys_user`, a platform setting), written with no session organization (a sign-up), takes the Default Organization under `single`. The record's own organization and then the session's still win. The ledger's `tenant_id` attribution is unchanged. New optional `AuditWriterOptions.getDefaultOrganizationId()`; `AuditPlugin` wires it to the `tenancy` service. + - **`@objectstack/service-settings`.** The `sys_secret` ciphertext and the `sys_setting_audit` history row of a `tenant` or `user` value carry that value's organization, which is the same owner as its `sys_setting` row. A `global` value's rows name none. New optional `organization_id` on `SettingsSecretStore.insert`'s row and `organizationId` on `SettingsAuditWriter.write`'s entry; the built-in writers persist them. A failed `sys_setting_audit` write is now reported at `error`, not `warn`: the settings write succeeds while its history row is lost. The first report names the consequence and the fix. `buildSettingAuditWriter`'s optional logger argument keeps every sink it accepted (none, `{}`, or one with an optional `warn`) and additionally takes a sink that carries `error`. It reports through `error` when the sink has it, else through `warn`. + - **`@objectstack/verify`.** The RLS probe and position personas' grants (`sys_user_permission_set`, `sys_user_position`) belong to the organization the persona is a member of (the Default Organization under `single`). They used to be written with an explicit `organization_id: null`. A persona that belongs to no organization, or to several, names none. + +### Patch Changes + +- 66949b7: feat(plugin-security)!: the four security-catalog row objects are retired — `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` (ADR-0131 D3) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Positions, permission sets and capabilities live in the security catalog — the environment registry, fed by package declarations and by environment metadata saved through `/api/v1/meta` (ADR-0131 D3). The four row objects that mirrored them are no longer registered, so their data and REST routes and their Setup object pages are gone. No table is dropped and no row is deleted. + + FROM → TO: + + - `GET|POST|PATCH|DELETE /api/v1/data/sys_permission_set` → `GET|PUT|DELETE /api/v1/meta/permission/:name`; to copy a set, `POST /api/v1/security/permission-sets/:name/clone` with `{ name, label }`. + - `… /api/v1/data/sys_position` → `/api/v1/meta/position/:name`. A position's permission sets are its definition's `permissionSets`. + - `… /api/v1/data/sys_position_permission_set` → the position definition's `permissionSets` (`PUT /api/v1/meta/position/:name`). + - `… /api/v1/data/sys_capability` → read-only `GET /api/v1/meta/capability`; a package declares a capability with `defineCapability` (`stack.capabilities`). + - Setup → Access Control → Positions / Capabilities / Permission Sets now open Setup's catalog lists (`/apps/setup/metadata/?scope=environment`) instead of the object pages; the `sys_position` record page (`SysPositionDetailPage`) is removed. + - An object permission on one of the four names in a permission set grants nothing and can be deleted. + + What else changes: + + - `sys_user_permission_set.permission_set_id` is a plain text column now, not a lookup: same column, same stored ids, same indexes. It is not written and grants nothing; a grant names its set by `permission_set`. + - A permission set saved through the metadata door is written to no row: the `permission` mutation projector, the `sys_permission_set` and `sys_position` data-door write-throughs and the row-only position backfill are no longer registered. + - On a fresh `single`-posture database the first human is promoted to platform administrator without reading any catalog row. An unscoped grant written before `sys_user_permission_set.permission_set` existed (it names no set) still withholds the promotion until the organization-ownership migration names it. + - Uninstalling a package still revokes every grant naming a permission set the package declared, by the names the uninstalling door captures before the withdrawal (`declared`, `captureUninstallDeclared`), and now by those names alone: the cleanup reads and removes no `sys_permission_set` or `sys_position_permission_set` row, so the `sets` and `positionBindings` counts on its outcome are always 0. A grant written before `sys_user_permission_set.permission_set` existed names no set and is not reached; the organization-ownership migration names it. + - On a `group` or `isolated` deployment the data-door half of the walled catalog refusal goes with the objects: a data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability` answers `404 OBJECT_NOT_FOUND` for every caller instead of `403 PERMISSION_DENIED` below the platform operator. The refusal at the `/meta` write doors, the permission-set clone door and the `/packages` write doors is unchanged. + - `capability-reference-unknown` no longer counts a `sys_capability` seed row as a declared capability — no runtime reader consults one. Declare the capability with `defineCapability`. + - On an existing database `os migrate plan`'s unmanaged-table sweep now lists the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` tables: they keep their rows and no object declares them any more. The sweep only reports; nothing is dropped, and the tables stay for the organization-ownership migration. + + Removed exports, no replacement: + + - `@objectstack/plugin-security`: the object definitions `SysPosition`, `SysPermissionSet`, `SysPositionPermissionSet`, `SysCapability` (from `./objects`); `securityObjects` lists the remaining three. + - `@objectstack/platform-objects/pages`: `SysPositionDetailPage`. + - `@objectstack/spec`: `SystemObjectName.POSITION`, `SystemObjectName.PERMISSION_SET`; the four names leave `PLATFORM_OBJECTS_BY_PACKAGE['plugin-security']` and `PLATFORM_PROVIDED_OBJECT_NAMES`. + - `@objectstack/core`: `isRowActive` and `ActivatableRow` — a catalog row's `active` column decides nothing; deactivation is the activation ledger's (`sys_metadata_activation`). + - `@objectstack/objectql`: the published `PLATFORM_OBJECT_TENANCY` record loses its `sys_permission_set` key (the other three names were never in it). A removed key of an exported constant is a narrowing, so `@objectstack/objectql` takes `minor` under the launch-window convention for breaking changes. +- b88c356: The remaining platform producers in these four packages now pass the explicit system opt-in (`{ isSystem: true }`) on their data-engine calls. Until now they reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. + + Clause-②: no + + - **service-messaging, the inbox read state.** `listInbox` (and its unread total), the receipt read behind it, and mark-read / mark-all-read take the opt-in inside the service. Their scope is unchanged: every read of a user's rows is keyed on the user id the door derived from the session, the receipt a mark-read inserts is stamped with it, and the receipt it updates is one a user-keyed read returned. + - **service-messaging, `owner_of:` audiences.** The record read takes the opt-in, the same posture as the email lookup beside it. It reads only `id` and the owner fields, and only the owner id leaves the resolver. An `owner_of:` audience on an object whose sharing model is `private` now resolves its owner; before, it resolved to nobody. + - **service-messaging, the rest of the fan-out and the outboxes.** The `role:` and `team:` membership reads, the email and SMS recipient reads, the notification template read, the dedup lookup in `emit()`, and both outboxes' enqueue, ack and list. + - **service-storage.** `StorageMetadataStore.createFile` and `createSession` insert under the opt-in. The organization still reaches the driver beside it, so the stored organization is unchanged, and the file's `owner_id` is still the uploading user. + - **service-settings.** The `sys_secret` store the plugin builds (insert, get, update), and the read that verifies a rotation before the old secret is reaped. A store `update` now writes the `ciphertext` it is given; without a context the engine's read-only strip dropped it. No caller in this repository uses `update`. + - **metadata-protocol.** `SysMetadataRepository.getByHash`, `list`, `history` and the history replay of `watch()`. + - None of the gates the middleware runs before its hand-off applies to these calls. ⛔ No new export on any package entry, and no new elevation API. +- 98998ca: fix(service-settings): the Default timezone help speaks to administrators, not in formula-function names + + Clause-②: no + + The help under Settings → Localization → Default timezone (also shown in the first-run "set the workspace timezone" prompt) read "IANA zone for today()/daysFromNow, analytics date buckets, and rendered datetimes." It now says what the setting does: "Decides what "today" means in formulas, how reports group dates, and how dates and times are shown. Accepts any IANA time zone name (e.g. Europe/Paris)." The same meaning ships in every built-in locale (en, zh-CN, ja-JP, es-ES) and in the manifest's own `description`, which `GET /api/settings/localization` serves and the console falls back to. + + Wording only: the `timezone` key, its default (`UTC`), its accepted values (any IANA zone) and what it drives are unchanged. +- 4f4c4ed: fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`). + + **The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`: + + - **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. + - **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none. + + **Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`. +- 3c1a046: fix: a route's catch-all `500` answers its own generic text, never the caught error's message, and `@objectstack/types` exports the writer that does it, `sendServerFault` + + Clause-②: yes (widening) — `@objectstack/types` gains one export, `sendServerFault`; nothing is removed, renamed or narrowed, and no response field changes shape. + + A declared 5xx does not carry the text of the error it caught: that text can name server internals, such as a path, a host or a table. The REST layer already withheld it. The storage, settings and i18n route families and the share-link routes answer in the nested `{ success: false, error: { code, message } }` envelope and did not: their catch-all `500` sent the caught error's message to the caller. + + - **What a caller now reads.** The same `status` and `code` as before, with the route's own generic text as `error.message`: `Internal error` on the storage and i18n routes, the route's message on the settings routes (`Failed to list manifests`, `Failed to read namespace`, `Failed to write namespace`, `Action failed`), and the route's `Failed to …` message on the share-link routes. On the storage routes this includes the metadata store's outage sentence, which an operator now reads in the server log. + - **What the operator reads.** The original error, stack included, in one `[5xx]` server-fault line. The storage byte routes, which already withheld the text, logged two lines per fault and now log one. A line names the mounted route pattern when the route passes one, never the request URL. + - **Unchanged.** Every `4xx` answer. The settings `SETTINGS_CRYPTO_UNAVAILABLE` refusal, which keeps its prescription. The authorization-store outage `503`, which keeps its declared code and message. The storage `501 NOT_IMPLEMENTED` answers. On the share-link routes, any error that declares its own `status` keeps the envelope it declares. + - **`sendServerFault(res, status, code, message, thrown, request?)`** (new, `@objectstack/types`): writes `message` in the declared envelope, and hands `thrown` to the server-fault log with its stack, as the fault's one line. Use it for an arm that answers a caught fault. Keep `sendError` for a `4xx` and for a `5xx` whose text you wrote for the reader. `request` is the mounted route pattern, never the request URL. +- 0c3db0f: The settings routes' identity door resolves through `@objectstack/core`'s `resolveRequestExecutionContext`, the one request-to-`ExecutionContext` composition + + Clause-②: no + + - `SettingsServicePlugin` resolves the caller of every `/api/settings` route by calling `resolveRequestExecutionContext` with this door's differences as named parameters: `anonymous: 'fail-closed'`, `authGate: 'resolved'`, `credentials: 'api-key-and-session'`, `sessionApi: 'optional'`, `session: 'request'`, its own way to reach the `tenancy` service, `localization: 'withhold'`, no request locale, `accessToken: 'withhold'`, and no OAuth provenance or admission channel. The engine and auth-service lookups, the header normalisation, the tenancy wiring, the projection to `SettingsContext` and the fail-closed `catch` stay at the door. + - The answers are the ones the door gave before: an anonymous caller, an unrecognised cookie, a failed session read and a refused API key get `401 UNAUTHENTICATED`; a session or an API key owner the authentication policy holds gets `403` with its code and message; an unreadable permission store or tenancy service gets `503 SERVICE_UNAVAILABLE`; and an API key its own lifecycle refuses gets its `403` and code. A one-off probe of 29 fault wirings through the listing route read the same answer on both sides of the change. + - Nothing to do for a consumer: no export, route, configuration key or response shape changed. +- 747c8e9: Clause-②: no + + fix(service-settings): the File Storage, Knowledge and AI settings pages show their warnings as `info_banner` callouts with `bannerSeverity: 'warning'`, not as a sentence inside a subtitle or a field's help text + + Four warnings were written into ordinary strings with a hand-typed warning glyph, so the admin read them as one more sentence of the page subtitle or of a toggle's help. Each one is now an `info_banner` row in the section it concerns, directly under the field it warns about: + + - `storage` — `adapter_switch_warning`, under the Adapter select: switching adapter does not migrate existing files. + - `knowledge` — `adapter_switch_warning`, under the Adapter select: switching adapter does not migrate existing indices. + - `knowledge` — `enforce_rls_warning`, under the Enforce RLS on search toggle: disabling it skips the vector-store leakage safeguard. + - `ai` — `log_prompts_warning`, under the Log full prompts toggle: full prompts may leak PII. + + The namespace description or field help of each keeps its non-warning text. The translations moved with them: in the `en`, `zh-CN`, `ja-JP` and `es-ES` bundles each warning sentence left the `description` / `help` copy and sits under `settings.NAMESPACE.banners.BANNER_ID.{title,text}`, with a translated title per banner. The existing Feature Flags `beta_notice` banner gained the same copies, so no shipped locale reads it in English any more. + + No setting key, value, default or save behaviour changes. A host that renders `info_banner` rows from the manifest literals draws the English title and text; translated copy is read through `resolveSettingsBannerTitle` / `resolveSettingsBannerText` from `@objectstack/spec/system`. +- 04fd88a: Clause-②: no + + fix(service-settings): the Setup settings pages name the organization or the environment where they said "workspace" + + - **What changed.** The built-in settings manifests and their en, zh-CN, ja-JP and es-ES bundles no longer say "workspace" (工作区, ワークスペース, espacio de trabajo). Each string now uses the noun for what the setting belongs to: + - **Organization** (组织 / 組織 / organización): the Branding name field, now **Organization name**, and the Branding description; the Company legal-name help; the Feature Flags description. These settings are stored once per organization. + - **Environment** (环境 / 環境 / entorno): the Provider section of Mail and of SMS. These settings are stored once for the running instance. + - Authentication, New user membership: the help now names the explicit act as **creating an organization**. + - **Unchanged:** every setting key, including `branding.workspace_name` and its `OS_BRANDING_WORKSPACE_NAME` override; stored values; the Settings hub `category` values; the keys and shape of the translation bundles; and the exports. Only the text a reader sees changed. +- 44a5011: Clause-②: no + + Refusals and Setup help text that a user reads inside the running system no longer call that system + "this environment". They say "this system", or they drop the noun. + + - `@objectstack/plugin-auth`: the two self-registration refusals drop the noun ("Self-registration is + closed (audience posture invite_only).", "Self-registration is limited to approved email domains, …"). + So do the OAuth self-service `503` ("OIDC provider is not enabled") and the SSO `400` + `DOMAIN_VERIFICATION_DISABLED` ("Domain verification is not enabled (set + OS_SSO_DOMAIN_VERIFICATION)."). The last-administrator guard's refusals and its two warning lines say + "this system", including "nobody able to administer the system". The OIDC authorize-gate refusal + (`403 ENV_ACCESS_DENIED`) keeps "this environment". Only the cloud control plane installs that gate, + and there the noun names the environment a cloud user is signing in to. + - `@objectstack/plugin-security`: the diagnostic `message` of the packaged-permission-set lock now reads + "… is declared by package '…' and is locked — editing it here would …". The provenance-unknown + refusal says "this system could not determine …". Both `userMessage` texts are unchanged. + - `@objectstack/service-settings`: the Mail and SMS provider groups read "Choose how this system sends + outbound email." and "… outbound SMS.". The Authentication audience group reads "Who may become a user + of this system's apps.". The zh-CN, ja-JP and es-ES bundles change only the noun, to 本系统, + このシステム and este sistema. + + Only the human-readable text moves. Every HTTP status, error code, settings key and response shape + stays as it was. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index d7e9688ed04..1a86b03b400 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Settings service for ObjectStack — manifest registry + K/V resolver (OS_* env > Tenant > User > Default) + REST routes. See ADR-0007.", "type": "module", diff --git a/packages/services/service-sms/CHANGELOG.md b/packages/services/service-sms/CHANGELOG.md index 533a8b21243..7e84b924e0c 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,454 @@ # @objectstack/service-sms +## 18.0.0 + +### Minor Changes + +- 7098aca: `bootStack` mounts the always-on capability slate `objectstack serve` mounts for every app, and builds each provider from the app's configuration the way `serve` builds it + + Clause-②: yes (narrowing: a configuration whose mail or SMS settings, or the OS_EMAIL_* / OS_SMS_* environment, name a transport that cannot deliver, now fails bootStack where it booted; widening: bootStack mounts the always-on slate objectstack serve mounts for every app and hands each provider the app's configuration, so the app's analytics cubes now reach the registry, where it mounted fewer and built them with defaults) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — the composition `serve` mounts, built the way `serve` builds it.** For one configuration, `bootStack(config, opts)` already mounted the providers the app's `requires` names and the plugins in its own `plugins` array. It now also: + + - **Mounts the always-on slate** `serve` mounts for every app, whether or not `requires` names it: `queue`, `job`, `cache`, `settings`, `email`, `storage`, `sms`, `sharing`, `messaging`, `analytics` and `package-registry`. Before, a slate provider was mounted only where a mounted plugin hard-depended on it, so an app's tests ran without the inbox delivery, the mail service, the file storage and the package registry its users' server has. + - **Builds each provider from the app's configuration**, by the same rule `serve` reads: the analytics service gets the app's `analyticsCubes` (the top level, then each package body's), the email service the app's `email` block with `OS_EMAIL_*` over it, the SMS service the `sms` block with `OS_SMS_*` over it, and the storage service the `OS_STORAGE_LOCAL_ROOT` root (`.objectstack/data/uploads` under the working directory by default). Before, each was built with its own defaults: no cubes, no mail configuration. So the app's cubes now reach the analytics registry; a cube the service refuses (one over an object the API does not serve) is warned and skipped by the service, under `bootStack` as under `serve`, and no boot fails on it. + - A caller's `extraPlugins`, `security` and `analytics` instances still take precedence by identity, and are not handed the configuration. A suite that needs a provider configured its own way (a temporary storage root, a mail transport) passes its instance in `extraPlugins`. + - Not mounted, because they are decisions about a server process rather than about the configuration: `serve`'s MCP endpoint (`OS_MCP_SERVER_ENABLED`) and its pinyin search (`OS_SEARCH_PINYIN_ENABLED`). A suite that exercises either passes the provider in `extraPlugins`. + + **What now fails that booted before (the narrowing).** A provider `bootStack` constructs and cannot build fails the boot, naming the capability token and the package — whether the app declared the token or the slate appended it. `serve` logs a slate provider it cannot build and boots on; a test boot does not, so an app's tests never pass on a composition its server does not run. In practice: + + - a configuration whose `email` or `sms` settings, or the `OS_EMAIL_*` / `OS_SMS_*` environment of the test process, name a transport that cannot deliver (`provider: 'smtp'` with no host, `resend` / `postmark` with no API key, an unknown provider tag) is refused with the reader's own remedy. Set `OS_EMAIL_PROVIDER=log` / `OS_SMS_PROVIDER=log` in the test environment if it is not meant to send mail or SMS. + + **`@objectstack/core`** exports the rule both boots read: `resolveServedCapabilities` (given the tokens a stack declares, which capability tokens a served boot mounts providers for — those, `email` for a declared `auth`, the host's own defaults, the always-on slate unless the preset is `minimal`, and `job` / `queue` ahead of what schedules background work) and `resolveCapabilityArgument` (what each provider is constructed with), with their `ServedCapabilities`, `CapabilityArgumentInput` and `CapabilityArgument` types; and `resolveStorageCapabilityArg`, `resolveStorageLocalRootEnv` and `StorageCapabilityArg`, moved here from the CLI. + + **`@objectstack/plugin-email`** exports `resolveEmailCapabilityArg`, `resolveDeploymentAppName` and `EmailCapabilityArg`, and **`@objectstack/service-sms`** exports `resolveSmsCapabilityArg` and `SmsCapabilityArg` — the readers that build each provider from the deployment's configuration, moved from the CLI to sit beside the transport vocabulary they refuse against. `resolveCapabilityArgument` reads each off the provider module it is handed. + + **`@objectstack/cli`**: `serve` reads both rules from `@objectstack/core` and composes exactly what it composed before. `os migrate plan` / `apply`'s declaration boot reads the same token rule for the providers it composes (the same set as before; a declared `auth` now places `email` right after the declared tokens, as `serve` does). `os verify` boots through `bootStack`, so it now mounts the always-on slate and builds the providers from the app's configuration. The moved functions stay importable from the `serve` command module. + +### Patch Changes + +- f9c7588: A configured SMS provider delivers through its credentials in the environment or in Settings, without `OS_SMS_PROVIDER` + + Clause-②: no + + A configuration that named `provider: 'twilio'` (or `'aliyun'`) and kept the provider's keys in the environment, but did not also set `OS_SMS_PROVIDER`, booted on `LogSmsTransport` with `isConfigured() === false`, through `os serve` and `@objectstack/verify`'s `bootStack` alike. The boot logged "SMS will NOT be sent". Two readers missed the credentials: + + - **`resolveSmsCapabilityArg`** now merges the selected provider's credentials into `providerOptions`, env over `config.sms.providerOptions`, key by key, as the mail resolver does. It reads the variables the `sms` settings namespace is env-locked with: `OS_SMS_TWILIO_ACCOUNT_SID`, `_AUTH_TOKEN`, `_FROM_NUMBER` and `_MESSAGING_SERVICE_SID`, and `OS_SMS_ALIYUN_ACCESS_KEY_ID`, `_ACCESS_KEY_SECRET`, `_SIGN_NAME` and `_TEMPLATE_CODE`. + - **`SmsServicePlugin`** honours its constructor's `provider` when the `sms` settings namespace names none, and reads the namespace for that provider's credentials. The namespace's credentials overlay the constructor's `providerOptions`, so a constructor-only option such as `baseUrl` is kept. A provider named in Settings, or by `OS_SMS_PROVIDER`, still wins. + + Unchanged: a configured provider whose credentials are missing everywhere still boots onto the log transport with a warning and `isConfigured() === false`. The resolver still refuses only a provider tag no transport can deliver through. Signatures and exports are unchanged. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [c28f317] +- Updated dependencies [aa71c4d] +- Updated dependencies [19bb55e] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [e7e6544] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [4935c66] +- Updated dependencies [c6fe02d] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [6ff6ed6] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [0ee2d15] +- Updated dependencies [4f4c4ed] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [8a995b8] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [f66c440] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [e148ca9] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [f9ebb7d] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [3490bf5] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [6f4c518] +- Updated dependencies [87d0619] +- Updated dependencies [917b1a0] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [404fc47] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [0be162c] +- Updated dependencies [02a691a] +- Updated dependencies [e4ec6ec] +- Updated dependencies [fc1a55a] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [e611e2b] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [0cfd0c4] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [03ac4ec] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [ab3ba2c] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [f792290] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [8a88901] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [9040945] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [83dfbc0] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [51ad8e4] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [9524666] +- Updated dependencies [d65826f] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [44a5011] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [e22defb] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/plugin-auth@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index 0cbe8d1cdd8..1b2abe8f2fc 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "SMS service for ObjectStack — ISmsService + transport-pluggable outbound delivery (Aliyun / Twilio / log).", "main": "dist/index.js", diff --git a/packages/services/service-storage/CHANGELOG.md b/packages/services/service-storage/CHANGELOG.md index 3d738744c66..f5bb6c5c181 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,965 @@ # @objectstack/service-storage +## 18.0.0 + +### Minor Changes + +- 29527f2: fix(service-storage): the single-shot upload commit refuses a file whose bytes the storage backend does not hold + + Clause-②: no (narrowing) + + + + **What was wrong.** `POST /api/v1/storage/upload/complete` set the `sys_file` row to `committed` without asking the storage backend. A presigned upload whose byte `PUT` failed, or was never sent, therefore committed with the size it declared. The download doors serve any `committed` file, so `GET /api/v1/storage/files/:fileId/url` handed out a signed URL, and fetching it failed with `500`. + + **What the commit door does now.** Before it commits, it asks the backend through two members every adapter implements: `exists(key)`, and `getInfo(key).size` compared with the size the upload declared. When the backend holds no bytes for the file, or holds a different number of bytes, the door answers `409` with the standard code `RESOURCE_CONFLICT`. The row stays `pending`, so nothing is served for it. This is the answer the chunked completion already gives an upload that does not hold its declared bytes. + + **What changes for you.** Nothing, if your client sends the bytes before it completes, as the SDK's `storage.upload` and the console's upload provider do. A client that completes first now receives the `409`: send the file to its upload URL (start a new upload if that URL has expired), then complete again. If the storage adapter reports a fault while the door asks, the door answers `500`. The local adapter does not report an unreadable storage root as a fault: it reports it as holding no bytes, so on the local adapter the door answers `409`. + + The `eTag` a completion may carry is still stored as sent and not compared: no adapter reports the stored object's ETag. + + **The local adapter's download link.** `GET /api/v1/storage/_local/raw/:token` answers `404` `FILE_NOT_FOUND` when no file is stored under the link's key. Before, it answered `500` `INTERNAL` ("Download failed"). Every other read failure still answers `500`. +- 7c7741d: The `sys_attachment` parent gate and the `sys_comment` moderation gate give the answer a by-id update of the parent record gets + + Clause-②: no (narrowing) + + + + - Every limb that asks whether the caller may edit the parent record (attaching a file, editing or removing another user's attachment or comment, moving an attachment to another record, and `assertMayAttach`) now admits only where a by-id update of that parent admits. After record sharing and the master-detail check, the gate asks the security service's judgement of that update: its record-grained `explain` verdict for `update` on the parent, `allowed` and `record.visible` both. + - The step only adds refusals, with each limb's existing envelope (`ATTACHMENT_PARENT_ACCESS`, `ATTACHMENT_DELETE_DENIED`, `RECORD_NOT_ACCESSIBLE`, or the not-visible `PERMISSION_DENIED` for a caller who cannot read the parent). A store fault keeps its own status, and an attach naming a parent object the registry does not hold answers what a by-id update of it answers (`404 OBJECT_NOT_FOUND`). + - Posting a comment is unchanged: it stays a read rule on the parent. +- 29678f2: fix(service-storage)!: the upload commit, chunked-completion and progress doors act only for the user who started the upload + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `POST /api/v1/storage/upload/complete`, `POST /api/v1/storage/upload/chunked/:uploadId/complete` and `GET /api/v1/storage/upload/chunked/:uploadId/progress` now act only for the user who started the upload. The rule is declared once and each of the three doors consults it: the signed-in user must be the file's `owner_id`, which both upload-start doors already stamp from the session. A chunked upload reaches its uploader through its file. + + - **Refused now:** every other caller, with `403 PERMISSION_DENIED`. The answer is the same whichever organization the caller acts in, and it comes before anything is written or returned. There is no administrator exception. A file with no recorded uploader, or a chunked upload whose file is gone, is refused the same way rather than given a guessed owner. + - **Unchanged:** the uploader's own calls, who may start an upload, who may download a file (the download doors' own authorization), the organization the doors stamp and scope by, and the not-found answers. A deployment with no `auth` service, whose upload routes run without a session resolver, keeps them open as before. + + What changes for you: complete and poll an upload as the same signed-in user who started it. An upload left pending with no recorded uploader cannot be committed; start it again. +- b15f8a1: feat(service-print): print a record's print page to a PDF in headless Chromium, as the requesting user, and archive it as a file attached to the record + + Clause-②: yes (widening) + + A new package, `@objectstack/service-print`. `PrintServicePlugin` registers the in-process `print` service (`IPrintService`): `status()` and `printRecordToFile({ app, object, recordId, page, filename? }, context)`. It adds no route, no metadata key and no error code. Compose it explicitly: + + ```ts + import { PrintServicePlugin } from '@objectstack/service-print'; + plugins: [new PrintServicePlugin()] + ``` + + - **One engine, headless Chromium, in one of two placements.** A local executable (`OS_PRINT_CHROMIUM_PATH`, option `chromiumPath`), or a remote browser's DevTools WebSocket endpoint (`OS_PRINT_BROWSER_ENDPOINT`, option `browserEndpoint`). Either one needs `OS_PRINT_CONSOLE_URL` (option `consoleUrl`): the console's URL as that browser reaches it, `/_console` included. Configuring both placements is refused. `playwright-core` is pinned exact on this package only. + - **Loud at startup.** `start()` probes the engine and logs its placement and Chromium version. An engine that is not configured, is misconfigured or does not answer is reported once, with the setting to fix, and `status()` says why. A remote endpoint is never logged beyond its scheme and host. + - **As the requesting user.** The page is rendered through a short-lived session minted for the caller's own principal by the auth service (`mintPrincipalSession`), and revoked in a `finally`. There is no system principal. + - **Refusals first, each with `code` and `status`, before any session is minted or browser started** (an engine that failed its last probe is re-probed only after them all): a caller that is not a human user acting for themselves (`PERMISSION_DENIED` / 403); an engine that is not ready (`SERVICE_UNAVAILABLE` / 503); an unknown page (`RESOURCE_NOT_FOUND` / 404); a page that is not the object's default record page or declares no `print` (`VALIDATION_ERROR` / 400); a record the caller cannot read (`RECORD_NOT_FOUND` / 404); an object without `enable: { files: true }` (`FILES_DISABLED` / 403). + - **The archive runs as the requesting user.** `@objectstack/service-storage`'s `file-attachments` service gains a caller-scoped face, `ICallerFileAttachmentService` (exported type), on the same slot and the same writer: `assertMayAttach(parentObject, parentId, context)` throws the insert's own refusal without writing (`PERMISSION_DENIED` / 403 when the caller may not create attachments, `ATTACHMENT_PARENT_ACCESS` / 403 when the attach rule refuses), and `attachFileAs(input, context)` writes one `sys_file` owned by the caller and one `sys_attachment` inserted under the caller's context, so every insert gate judges the caller and the row is stamped `uploaded_by` the caller. The print service asks `assertMayAttach` in its files step, before any session is minted or browser started. `attachFileAs` acts only in the caller's own organization: an `organizationId` other than the context's `tenantId` (or `null` when it names none) is refused `PERMISSION_DENIED` / 403. `attachFile` (the system verb) is unchanged. + + `@objectstack/spec`'s `llms.txt` counts the new package: the workspace publishes 72 packages. +- 59d993c: fix(service-storage)!: the storage settings' Limits group (`max_upload_mb`, `presigned_ttl`, `session_ttl`) is enforced at the upload doors + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + Setup's File Storage page has always rendered a "Limits" group — "Max upload size (MB)", "Presigned URL TTL (seconds)" and "Upload session TTL (seconds)" — and saved it, but nothing read it. `StorageServicePlugin` now reads the group on every settings pass and its upload doors honour it, on the plugin's own mount and on a host's `mountStorageRoutes` mount alike. + + **What stops being accepted.** Where the plugin is bound to the `storage` settings namespace (its default, `bindToSettings: true`, with a settings service present), an upload larger than the resolved `max_upload_mb` is refused with `413` and the registered code `PAYLOAD_TOO_LARGE`, in the usual error envelope, with a message naming the limit and the setting. Nothing is stored: no `sys_file` or `sys_upload_session` row is written and no byte reaches the adapter. The doors that judge it: + + - `POST /storage/upload/presigned` — the declared `size`; + - `POST /storage/upload/chunked` — the declared `totalSize`; + - `PUT /storage/_local/raw/:token` (the local adapter's presigned upload) — the body, by its `content-length` before it is read and by the bytes actually read; + - `PUT /storage/upload/chunked/:uploadId/chunk/:chunkIndex` — the bytes the session has received so far plus this chunk, the same two ways. + + With nothing saved, the limit is the setting's declared default, **100 MB** (counted as MiB: 104,857,600 bytes). Uploads above it used to be stored and are now refused. The largest value the setting accepts is 10240 MB. + + **What else changes.** A saved `presigned_ttl` is now the lifetime of every presigned upload URL and of every non-gated download URL; a saved `session_ttl` is the lifetime of every new chunked upload session. URLs and sessions issued before a save keep the lifetime they were issued with. With nothing saved both are unchanged — 3600 s and 86400 s, the declared defaults and the built-in ones being the same numbers. A save reaches the doors without a restart. + + **Precedence, per key.** A saved value (an admin save or an env override) wins over the host's `presignedTtl` / `sessionTtl` option (the plugin constructor's, or `mountStorageRoutes`'), and the host's option wins over the namespace's declared default — the precedence the adapter keys already had. + + **What stays accepted.** + + - A mount with no settings namespace bound — no settings service, `bindToSettings: false`, or a `mountStorageRoutes` kernel whose `storage` service is not this plugin's — keeps no size limit and its option TTLs. + - On the S3 adapter, a presigned upload is judged by its declared size only: the bytes go straight to the bucket, so the platform never sees them. + - The gated download URL keeps its own short lifetime (`downloadTtl`, 300 s); `presigned_ttl` does not apply to it. + + **What changes for you.** If your deployment accepts uploads larger than 100 MB, set "Max upload size (MB)" in the File Storage settings to the size you need before upgrading; a larger upload receives the `413` above. + + **New API (additive).** `StorageRoutesOptions.limitsSnapshot`, the `StorageLimitsSnapshot` and `StorageLimitReading` types, and `SwappableStorageService.setLimitsSnapshot()` / `getLimitsSnapshot()` — the Limits group as the plugin last read it, which the route composition hands to the doors. +- e36ee53: fix(service-storage)!: a chunked upload completes with the file it was sent — the completion door assembles the parts the upload holds, and a re-sent chunk is counted once + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **What was wrong.** `POST /storage/upload/chunked/:uploadId/complete` assembled the parts the *request* listed. The SDK's `resumeUpload` lists only the chunks its own pass sent, so an upload whose first chunks were stored before an interruption completed with `200` and the declared `size`, while the stored file held only the resumed chunks. And `PUT …/chunk/:chunkIndex` added a re-sent chunk to the session's progress a second time, so `GET …/progress` overstated `uploadedSize`, `uploadedChunks` and `percentComplete` — the counts `resumeUpload` resumes from. + + **What the completion door does now.** It assembles the upload's own record of the chunks it holds — every chunk the chunk door stored, with the eTag the backend answered for it — and checks the request's `parts` against that record. A resumed upload therefore completes with the whole file, whatever subset of parts the client lists, and the SDK's `resumeUpload` needs no change. + + **What stops being accepted.** The door refuses with `409` and the standard code `RESOURCE_CONFLICT`, before anything is assembled, when: + + - the upload does not hold every chunk it declared (chunk indexes `0` to `totalChunks - 1`), or holds one beyond them; + - the chunks it holds do not add up to the declared `totalSize`; + - the request lists a chunk the upload does not hold, or lists one with an eTag other than the one the upload holds for it. + + `error.details` carries `missingChunks`, `unexpectedChunks`, `unheldListedChunks` and `mismatchedChunks` (zero-based chunk indexes), with `totalChunks`, `totalSize` and `heldBytes`. The session stays `in_progress` and the file stays `pending`: upload the missing chunks with `PUT …/chunk/:chunkIndex`, then complete again. A `parts` value that is not a list of `{ chunkIndex, eTag }` is refused `400 INVALID_REQUEST`; an absent `parts` is still an empty list. + + **What changes for you.** A client that completed an upload before sending all of its bytes — including a completion with no chunks at all, which used to commit an empty file — now receives the `409` above. Send every chunk first. + + **Progress.** A chunk index sent again replaces its slot, as it replaces its bytes in the backend: `uploadedChunks` counts distinct chunks and `uploadedSize` is the sum of each chunk's latest size. A session started before chunk sizes were recorded keeps its running byte total until it ends, and is completed only when that total reaches `totalSize`. +- dd986d8: fix(service-storage)!: downloading a file with no attachments scope and no field owner requires a signed-in caller + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + The storage download routes, both the one that answers a signed URL and the stable one that redirects to the bytes, now require a signed-in caller for a file that has neither an attachments scope nor a field owner: an upload no record has claimed. That is the class an avatar or an organization logo stored as a URL belongs to, and so is a picked file not yet saved to its record. ADR-0104 made the anonymous capability URL an opt-in, `acl: 'public_read'`; this was the one class still served anonymously by default. + + - **Refused now:** a caller with no session, with `401 AUTH_REQUIRED`, the answer the upload routes and the attachments gate already give an unauthenticated caller. No signed URL is minted for the refused caller. + - **Unchanged:** a signed-in caller is served exactly as before, including the signed URL's lifetime. A browser's `` and `` send the session cookie the sign-in set, so a signed-in page keeps rendering these files. A file marked `acl: 'public_read'` stays anonymous. Attachments-scope and field-owned files keep their parent-record verdicts. A deployment with no `auth` service, whose storage routes run without a session resolver, keeps these downloads open as before and says so once in its log. + + What changes for you. Before this release, anyone holding such a file's id could download it; now, sign in first. A file that must render before sign-in (on a sign-in page, in an email, on a public page) needs `acl: 'public_read'` on its `sys_file` row. +- ee8751d: feat(storage)!: the storage scope `public` is retired — no scope ever made a file publicly readable, and `acl: 'public_read'` stays the one opt-in for anonymous download (#22443) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on two published surfaces, shipped as `minor` under the launch-window convention for accept-set narrowings. + + `StorageScopeSchema` described `public` as "publicly accessible static assets", and the upload doors stored a caller's `scope: 'public'` on the new file. Neither ever made a file public. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope `public` and the default acl was stored private, needed a signed-in caller, and nobody was told (ADR-0049 enforce-or-remove). ADR-0104 makes `acl: 'public_read'` the one opt-in for anonymous download, so the scope is retired, not enforced: enforcing it would have let any uploader make a file anonymous at upload. + + ### What now refuses `public` + + - **The presigned upload door and the chunked upload door** (`@objectstack/service-storage`) answer an upload naming `scope: 'public'` with `400 INVALID_REQUEST`, before any file row, session row, upload URL or backend upload exists. The message names the remedy. Every other scope, and an omitted one, is taken exactly as before. + - **`StorageScopeSchema`** (`@objectstack/spec`): `public` left the enum. Writing it fails `tsc`, and parsing it, on its own or as `ObjectStorageConfig.scope`, fails with the prescription instead of zod's generic enum message. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | an upload with `scope: 'public'` | another scope, or no `scope` for the default `user`; then set `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in | + | `ObjectStorageConfig` with `scope: 'public'` | another scope, or no `scope` for the default `global` | + + **The one-line fix: stop naming scope `public`, and mark each file that must render before sign-in `acl: 'public_read'` on its stored file record.** The upload request carries no `acl`: every upload is stored `acl: 'private'`, so adding an `acl` to the upload request changes nothing. + + **Files already stored with scope `public`** download exactly as they did: a signed-in caller gets them, an anonymous one gets `401`, unless the file is `acl: 'public_read'`. Their stored file records are rewritten to scope `user` by the one-time operator sweep `@objectstack/service-storage` exports, which changes no access: the storage key and the bytes stay as they are. + + ### The retirement kit + + - **Schema.** `StorageScopeSchema` retires the member with `enumWithRetiredValues`. No D2 conversion: no metadata type carries this schema or the upload request, so `os migrate meta` has no authored source to rewrite. + - **D3 entry `storage-scope-public-retired`** carries the judgement no rewrite can make: whether a file that was uploaded as `public` must really be readable before sign-in. + - **Upload request contract.** The `scope` description on the presigned and chunked request schemas no longer offers `public` as an example, and says what the scope is and is not. + - **Liveness.** No ledger row: the liveness ledger walks metadata types, and no metadata type carries `StorageScope`. + + **Measured producers: none.** At origin/main da159f74e6, nothing in `packages/`, `examples/`, `apps/`, `skills/` or `content/docs/` uploads with scope `public` or declares an `ObjectStorageConfig` with it. The one hit was a `@objectstack/spec` request-schema test fixture, changed to `tenant` here. The same search finds 29 `scope: 'attachments'` writes, which is its control. At the `.objectui-sha` pin f0268ad784 and at objectui main 2063f7a, the upload adapter forwards a caller's scope and no caller names `public`: the console passes none, and the record attachments panel passes `attachments` (the control). Deployed callers and stored rows NOT MEASURED. +- 96e4be4: feat(service-storage)!: the `sys_file` scope option `public` is retired, and rows already stored with it are rewritten to `user` by a one-time operator sweep (#22443) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on the `sys_file` object's `scope` select, shipped as `minor` under the launch-window convention for accept-set narrowings. It completes the retirement registered as `storage-scope-public-retired`: `StorageScopeSchema` and both upload doors already refuse `public`, and now the stored vocabulary does too. + + ### What changes + + - **`sys_file.scope` no longer lists `public`.** Its options are `user`, `tenant`, `private`, `temp` and `attachments`. The engine refuses a write of `public` to the column as it refuses any undeclared option: `VALIDATION_FAILED`, `invalid_option` on `scope`. No access behaviour changes: the only scope value any code reads is `attachments`, and whether a file can be read before sign-in is decided by `acl: 'public_read'` alone. + - **Rows already stored with scope `public` are rewritten to `user`**, by a one-time sweep the operator runs (below), never at boot. `user` is the scope the upload doors and the field-reference copy path default to, and no reader tells it apart from `public`. One column moves: the storage key keeps its `public/` prefix, the acl and ownership columns are untouched, and no byte moves in the storage backend. + + ### FROM → TO + + | before | write instead | + | --- | --- | + | a `sys_file` row written with `scope: 'public'` | `scope: 'user'`, or no scope; and `acl: 'public_read'` on the row if the file must be readable before sign-in | + | rows already stored with `scope: 'public'` | nothing by hand: the sweep below rewrites them to `user` | + + **The one-line fix: stop writing scope `public` on `sys_file`, and run the sweep once on each deployment that stored it.** + + ### The operator step: run the sweep once after upgrading + + Until the sweep has run on a deployment that stored `public` rows, a record write that names a `public` file another field already owns is refused. The field-reference copy path copies the file into a new row with the source row's scope, the engine refuses `public` there, and the write fails with `ERR_FILE_REFERENCE_COPY`. The data REST doors answer that `500 INTERNAL_ERROR` ("Internal server error"); the server log carries the full sentence, which ends `Scope must be one of: user, tenant, private, temp, attachments`. Reads, downloads and updates that do not write `scope` are unaffected. + + The sweep ships in `@objectstack/service-storage` itself: `planSysFilePublicScopeBackfill`, `applySysFilePublicScopeBackfill`, `runSysFilePublicScopeBackfill` and `formatSysFilePublicScopeBackfillReport` are exported from the package, with their report types. It has the shape of the `sys_file` organization backfill: an operator step, never a boot hook. Import it from the package you upgraded to and run it server-side, from a context that holds the engine. It is a dry run first, and by default: + + ```ts + import { + planSysFilePublicScopeBackfill, + applySysFilePublicScopeBackfill, + formatSysFilePublicScopeBackfillReport, + } from '@objectstack/service-storage'; + + const plan = await planSysFilePublicScopeBackfill(engine); // counts, writes nothing + console.log(formatSysFilePublicScopeBackfillReport(plan)); + const applied = await applySysFilePublicScopeBackfill(engine, plan); // one scope write per row + console.log(formatSysFilePublicScopeBackfillReport(applied)); // keep it: it is the rollback list + ``` + + It counts before it writes (`scanned`), writes nothing where the count is zero, and is idempotent: every write moves its row out of `scope = 'public'`, so a second run scans and writes zero. A row whose write fails is reported, never retried, and is picked up by the next run. + + ### Rollback + + The inverse is `user` back to `public` on exactly the ids the applied report lists. This release cannot write it through the engine, which refuses `public` like any undeclared option. So the inverse goes with a code rollback: on the previous release, which still declares the option, write `scope = 'public'` back to those ids, through the engine there or as one raw driver statement against `sys_file` filtered to them. + + ### The step-18 entry says so too (`@objectstack/spec`) + + The D3 entry `storage-scope-public-retired`, which the protocol upgrade guide is built from, said that files already stored with scope `public` are not touched. That no longer holds for the `scope` column: after the sweep they read `user`. Its reason now names the `sys_file` option's retirement and the operator sweep above, and says that the rewrite changes no access: the storage key and the bytes stay as they are, and those files still download exactly as before. Its acceptance criteria add the sweep's end state: a dry run on each deployment scans zero `sys_file` records with scope `public`. +- ce3d0ad: fix(service-storage,plugin-audit)!: the `sys_attachment` and `sys_comment` parent gates judge a `controlled_by_parent` parent through its master, so a member can no longer attach files to, or delete other people's files or comments from, a child record they may not edit + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. It follows ADR-0055. + + **What stops being accepted.** + + - A `controlled_by_parent` record takes its access from its MASTER. Record sharing does not enforce on it, so the sharing service's edit verdict abstains on every such record, and both gates used to take that abstention as permission. A member holding the `sys_attachment` create or delete bit, or the `sys_comment` delete bit, could therefore attach files to, and delete other people's files and comments from, every master-detail child record of the organization. That included records whose own `PATCH` refuses the member, and records the member cannot read. + - Both gates now ask the sharing service's tri-state `checkEdit`. `allow` admits and `deny` refuses, as before. On an abstention they ask the security service's master-detail write check (`ISecurityService.checkControlledByParentWrite`), which is the answer a by-id update of the parent gets. Its `allow` and `not_applicable` admit. Its `deny` refuses, and so does an `unresolvable` outcome: a broken master-detail declaration, a record that does not exist, or an empty master reference. A datasource fault propagates with its `503`. + - The refusals keep today's envelopes. An attach or a re-point answers `403 ATTACHMENT_PARENT_ACCESS`. A delete of another user's file answers `403 ATTACHMENT_DELETE_DENIED`, and an update of it answers `403 RECORD_NOT_ACCESSIBLE`. A delete or update of another user's comment answers `403 RECORD_NOT_ACCESSIBLE`. A caller who cannot read the parent still gets the not-visible `403 PERMISSION_DENIED`. + + **What is unchanged.** An uploader still deletes their own file, and an author still deletes their own comment. Posting a comment still needs only read access to the record. A parent that is not `controlled_by_parent` is judged exactly as before, and a `public_read_write` parent still admits every member. A kernel whose security service does not serve the master-detail write check composes no master check, and there the gates admit on the abstention as before, which is also what the parent's own update gets. + + **What changes for you.** A member who was attaching or moderating on a child record through this gap now gets the refusal above. To give them that access, grant them edit access on the MASTER record: ownership of it, an `edit`-level record share, the `modifyAllRecords` bypass on the master object, or an app-authored `update` row-level security policy on the master that admits the row. Then their `PATCH` of the child and their attachment and moderation rights on it agree. The port types `AttachmentSharingLike` and `CommentSharingLike` now pick `checkEdit` rather than `canEdit`. The registered sharing service serves both, so a caller passing it is unaffected. `installAttachmentAccessHooks` and `installCommentAccessHooks` take an optional trailing resolver for the security service (new port types `AttachmentSecurityLike` and `CommentSecurityLike`). The two plugins pass it themselves. +- 1bb1da1: A reader refused `sys_file` read now sees the name, size and type of a file the record they are reading owns, whenever the download door would serve them its bytes + + Clause-②: yes + + The download door already judges a field-owned file by the record that owns it (ADR-0104 D3): a declared `fileAccessDelegate` decides where the owner object names one, otherwise the caller's read of that record. Record file-field hydration judged the same file by `sys_file` object read instead, which the default member grants do not include. So one reader could download a contract's signed PDF but saw the field as `{ id, metadataRefused: true }`: the bytes yes, the name no. + + - **From.** A reader with read on `contract` and no `sys_file` read gets `{ "signed_pdf": { "id": "f_7cQx2Lm90a", "metadataRefused": true } }` for every file id the record holds. + - **To.** For a file whose `ref_object` / `ref_id` name the record being read, that reader gets the hydrated `{ "id": "f_7cQx2Lm90a", "name": "signed.pdf", "size": 2048, "mimeType": "application/pdf", "url": "/api/v1/storage/files/f_7cQx2Lm90a" }`, exactly as a `sys_file` reader does, when the door's field-owned verdict allows it. A delegate-governed object (for example `sys_approval_action`, delegate `approvals`) gets its delegate's answer, not the raw record read. + - **Unchanged.** Every other id keeps `{ id, metadataRefused: true }`: an id copied in from another record, an attachment-only or unclaimed file, a `public_read` file the record does not own, and an owned file the delegate or the record read refuses. `sys_file`'s own read rule is unchanged, so a direct `sys_file` query by the same reader is still refused. A reader with `sys_file` read is served by their own read, as before. The download door's verdict for every request is unchanged. + + **One verdict, two callers.** `@objectstack/service-storage` moves the download door's field-owned arm into one module-private function (not exported) and hands it to the engine through a new seam, `ObjectQL.registerFieldOwnedFileReadAuthorizer` (type `FieldOwnedFileReadAuthorizer`, exported from `@objectstack/objectql`), the same handover `registerHeldFileResolver` already makes. The engine never re-derives it. An engine with nothing registered keeps the refused marker for every id. On a refused hydration the engine makes one more `sys_file` read under the caller's tenant (it reads only ids whose ownership pair names a record in the result) and asks the verdict once per read. + + **For consumers.** Nothing to change. A refused reader may now receive the full file object where it used to receive the marker, and code that already handles all three `expanded` read forms handles this. +- 08b38cb: fix(services)!: the datasource admin, share-link, webhook redeliver and storage families refuse a session the ADR-0069 authentication policy holds + + Clause-②: no (narrowing) — a session ADR-0069 holds out of protected resources is refused by families that answered it; core's existing exports only, no published schema or export moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate. REST and the runtime dispatcher already refuse such a session. Four families of routes mounted straight on the host server never asked, so they served it as an ordinary signed-in caller: + + - **`@objectstack/service-datasource`**: the datasource admin family (`registerDatasourceAdminRoutes`). Its door now asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) about the session it already reads, before the anonymous floor and the capability check. + - **`@objectstack/plugin-sharing`**: the share-link family (`registerShareLinkRoutes`). Every route, the two capability-token routes included, is now mounted through one door that refuses a context carrying `authGate`. `SharingServicePlugin`'s resolver now puts the session's posture on that context, as REST does. + - **`@objectstack/plugin-webhooks`**: the redeliver door (`WebhookOutboxPlugin`), on both of its faces: the route the plugin mounts, and the `webhooks` service's `handleRedeliver`. + - **`@objectstack/service-storage`**: the storage family, the two capability-token raw-transfer routes included. Every route is now mounted through one door. The posture is bound where every other storage gate is bound, so both the plugin's own mount and the host door `mountStorageRoutes` carry it. When a gate feature is active, the door reads the session and refuses a held one. When the gate is active but the session cannot be read, the door answers the declared `503` (`SERVICE_UNAVAILABLE`), as REST does. A direct call of `registerStorageRoutes` mounts the family exactly as before, because like the other storage gates this one is bound by the package's composition. No exported declaration changes. + + - **Refused now:** a held session gets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message`, in each family's declared error envelope, before the family does anything else. This is the answer REST gives the same session. + - **Unchanged:** a session the policy does not hold is served exactly as before, and an API-key caller is unaffected because it has no session to hold. A CORS preflight is answered as before. + + What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before these routes answer them, as on every other protected route. If you mount the storage routes through `mountStorageRoutes`, the gate is bound from the kernel's `auth` service with nothing to configure. If you mount `registerShareLinkRoutes` yourself with your own `contextFromRequest`, set `authGate` on the context it returns for a held session; the door refuses on that field. +- 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. +- 5e725b8: A server-side producer can store a file and attach it to a record: `IFileAttachmentService.attachFile`, registered by `StorageServicePlugin` as the kernel service `file-attachments` (`FILE_ATTACHMENT_SERVICE`), writes the bytes, one `sys_file` row and one `sys_attachment` row linking it to the parent, under the checks every other attachment of that parent meets + + Clause-②: yes (widening: `@objectstack/spec/contracts` gains `IFileAttachmentService`, `AttachFileInput`, `AttachedFile` and `FILE_ATTACHMENT_SERVICE`, and `StorageServicePlugin` registers and declares one more service) + + - **The call.** `attachFile({ parentObject, parentId, organizationId, filename, mimeType, data })` answers `{ fileId, attachmentId, size }`. `organizationId` is required: the organization the caller acts in, or `null` for none. It acts as the system, for a producer with no browser upload in front of it (inbound mail first). Reach it with `ctx.getService(FILE_ATTACHMENT_SERVICE)` once the kernel has bootstrapped. + - **What it runs.** Each check is the one the rest of the platform already runs, not a copy: + - The `max_upload_mb` storage setting, through the same verdict the REST upload doors ask. Over it, the call is refused `413 PAYLOAD_TOO_LARGE` before anything is stored. + - The parent's organization. A parent walled into another organization than `organizationId` is refused `404 RECORD_NOT_FOUND`, as a missing parent is. Both rows are written in the parent's organization, or in the caller's for a global parent. + - Every gate on the `sys_attachment` insert, among them the `enable.files` opt-in: a parent object without it is refused `403 FILES_DISABLED`. + - Before the kernel has bootstrapped, or with no data engine, the call is refused `503 SERVICE_UNAVAILABLE`. A malformed input is refused `400 VALIDATION_ERROR`. + - **No refusal leaves a committed `sys_file`.** The row is written `pending` before the bytes, and turns `committed` only in the same transaction as the `sys_attachment` insert. A refused call removes the bytes and the row before it throws. If the process dies mid-call, the pending-upload retention reclaims both. + - `@objectstack/service-storage`: the upload doors' size refusal moved from the route module into `storage-limits.ts`, beside the limit it enforces, so the doors and the verb read one status, one code and one sentence. The doors answer exactly as before. +- 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. +- 0b1179f: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + feat(service-storage): the `sys_attachment` read gate writes one `ReadableIds` leaf per parent object and does no pre-scan, so a broad read is judged over every row, not a 2,000-row window (ADR-0139 D4, D5) + + Clause-②: yes + + The gate serves an attachment only when the caller may read the record it is attached to. 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. + + The gate now writes one branch per object the metadata registry holds (except `sys_attachment` itself): `{ parent_object: P, parent_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: + + - A broad read of `sys_attachment` is served every attachment whose parent record the caller may read, with a true `total`, whatever the table size. An attachment whose parent the caller may not read, whose parent no longer exists, or that names no parent is still never served. A read scoped to one record (the attachments panel) 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_comment`, `sys_comment_reaction`, `sys_activity` and `sys_audit_log`); and a parent bound to another datasource than `sys_attachment`. The whole read also stays on the probe when the security service offers no `canReadObject`, or when none is registered. + - A read whose probe finds no candidate row is now narrowed to no rows rather than left unfiltered. It served nothing before either. + - On a driver that does not compile the leaf (`driver-mongodb`), a read of `sys_attachment` now fails with `INTERNAL_ERROR` / 500 naming the driver and the leaf, instead of serving a capped list. The gate does not turn that refusal into an empty answer. This is ADR-0139's ruled behaviour until a pipeline form is measured for that driver. + + For a host that installs the gate itself: `installAttachmentReadVisibility(engine, logger, getAdmission?)` takes an optional third argument that resolves the security service on every read. `StorageServicePlugin` passes it. A call without it keeps every read on the bounded probe and says so once. +- d8e11b5: fix(service-storage)!: the storage family gate and the route judge the same credential + + Clause-②: no (narrowing) — a session the ADR-0069 policy holds is refused on every storage route whatever else the request carries; no published schema, export or option moves. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate, and every route of the storage family is one. The family's gate and its routes now judge the same credential: the gate judges every credential a route of the family can serve. + + - **Refused now:** a session the policy holds gets `403` with the gate's own `code` (`PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED` or `MFA_REQUIRED`) and `message` on every storage route, whatever else the request carries, before the family does anything else. While a gate is active, a session that cannot be read is the family's declared `503` (`SERVICE_UNAVAILABLE`), whatever else the request carries. + - **Unchanged:** a session the policy does not hold is served exactly as before, apart from that outage answer; an API key whose owner holds an `account`-scope posture is refused with that posture, as before; and while no gate feature is active nothing changes. + + What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before any storage route answers them, whatever else their request carries. There is nothing to configure. + +### Patch Changes + +- a7a48b7: The storage store's by-id methods and the HTTP outbox's `redeliver` now pass the explicit system opt-in (`{ isSystem: true }`) on their data-engine calls. Until now they reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. + + Clause-②: no + + - **service-storage.** `StorageMetadataStore.getFile`, `updateFile`, `deleteFile`, `getSession`, `updateSession` and `deleteSession` take the opt-in inside the store. Access stays by id, and the reads stay unscoped by organization, as before. On update and delete the acting organization still reaches the driver beside the opt-in, so a row stamped for another organization is still out of reach of these doors. The doors keep the authorization they already ran. + - **service-storage, the update payload.** `updateFile` and `updateSession` now send the caller's patch alone, where they used to send the whole row read back merged with it. The engine's read-only strip, which does not run for a system write, used to take `organization_id` and the four audit columns out of that row; now the store never sends them. The stored row is the same as before, and a column another writer changed between the read and the write is no longer reverted by it. + - **service-messaging.** `SqlHttpOutbox.redeliver` takes the opt-in on both of its reads and on its reset write. The caller's `tenantId` stays on every call as the driver-level scope, so a delivery in another organization is still not found. The reset write states `bypassTenantAudit: false`, so a redelivery from a caller with no organization is still reported by the driver's tenant audit. + - None of the gates the security middleware runs before its hand-off applies to these calls. ⛔ No new export on either package entry, and no new elevation API. +- b88c356: The remaining platform producers in these four packages now pass the explicit system opt-in (`{ isSystem: true }`) on their data-engine calls. Until now they reached the engine with no principal and no opt-in, and the security middleware let that through only because of its principal-less hand-off. + + Clause-②: no + + - **service-messaging, the inbox read state.** `listInbox` (and its unread total), the receipt read behind it, and mark-read / mark-all-read take the opt-in inside the service. Their scope is unchanged: every read of a user's rows is keyed on the user id the door derived from the session, the receipt a mark-read inserts is stamped with it, and the receipt it updates is one a user-keyed read returned. + - **service-messaging, `owner_of:` audiences.** The record read takes the opt-in, the same posture as the email lookup beside it. It reads only `id` and the owner fields, and only the owner id leaves the resolver. An `owner_of:` audience on an object whose sharing model is `private` now resolves its owner; before, it resolved to nobody. + - **service-messaging, the rest of the fan-out and the outboxes.** The `role:` and `team:` membership reads, the email and SMS recipient reads, the notification template read, the dedup lookup in `emit()`, and both outboxes' enqueue, ack and list. + - **service-storage.** `StorageMetadataStore.createFile` and `createSession` insert under the opt-in. The organization still reaches the driver beside it, so the stored organization is unchanged, and the file's `owner_id` is still the uploading user. + - **service-settings.** The `sys_secret` store the plugin builds (insert, get, update), and the read that verifies a rotation before the old secret is reaped. A store `update` now writes the `ciphertext` it is given; without a context the engine's read-only strip dropped it. No caller in this repository uses `update`. + - **metadata-protocol.** `SysMetadataRepository.getByHash`, `list`, `history` and the history replay of `watch()`. + - None of the gates the middleware runs before its hand-off applies to these calls. ⛔ No new export on any package entry, and no new elevation API. +- 7ebbc01: fix(service-storage): finishing an upload after switching active organization answers `409 RESOURCE_CONFLICT` naming the change, not a `500` that reads as a data-engine outage + + Clause-②: no + + `POST /api/v1/storage/upload/complete` and `POST /api/v1/storage/upload/chunked/:uploadId/complete` write by id under the caller's active organization, and that write is scoped to it. When the uploader's active organization is no longer the one the upload was started in, the write cannot reach the upload's row. Both doors used to answer that with `500 INTERNAL` and a message diagnosing a data-engine outage ("Restore the data engine …"). + + - **Now:** before writing anything, each door answers `409 RESOURCE_CONFLICT` in the standard error envelope. The message says the upload was started in a different organization than the active one, and that switching back finishes it. The server log carries one warning naming the upload, the organization it was started in and the active one. + - **Unchanged:** which organization an upload belongs to (switching back finishes it there); a commit or completion from the starting organization; a real data-engine failure, which still answers `500` with its consequence and fix; the uploader-only rule, which runs first, so a caller who did not start the upload is still refused `403 PERMISSION_DENIED` and learns nothing about organizations; uploads stamped with no organization; routes with no session resolver; a store with no data engine wired; and the progress door. + + What changes for you: when either door answers `409 RESOURCE_CONFLICT`, switch the active organization back to the one the upload was started in and retry the same call. +- 3513ac7: fix(service-storage): sending a chunk, or polling an expired upload's progress, after switching active organization answers `409 RESOURCE_CONFLICT` naming the change, not a `500` that reads as a data-engine outage + + Clause-②: no + + `PUT /api/v1/storage/upload/chunked/:uploadId/chunk/:chunkIndex` records each chunk on the upload session, and `GET /api/v1/storage/upload/chunked/:uploadId/progress` stamps a session past its `expires_at` as `expired`. Both writes go by id under the caller's active organization, and they are scoped to it. When the uploader's active organization is no longer the one the upload was started in, the write cannot reach the session row. Both doors used to answer that with `500 INTERNAL` and a message diagnosing a data-engine outage ("Restore the data engine …"). + + - **Now:** each door answers `409 RESOURCE_CONFLICT` in the standard error envelope, with the same message the commit and chunked-completion doors give: the upload was started in a different organization than the active one, and switching back finishes it. The chunk door answers it before the chunk reaches the storage backend and before any write. The progress door answers it only when the expiry stamp is due, before stamping. The server log carries one warning naming the door, the upload, the organization it was started in and the active one. + - **Unchanged:** which organization an upload belongs to (switching back finishes it there); a chunk or a progress read from the starting organization; the progress of a live session, or of one already `expired`, which is a read and still answers `200` from any organization; a real data-engine failure, which still answers `500` with its consequence and fix; the resume-token check on the chunk door and the uploader-only rule on the progress door, which run first, so a caller who cannot act on the upload learns nothing about organizations; sessions stamped with no organization; routes with no session resolver; and a store with no data engine wired. + + What changes for you: when either door answers `409 RESOURCE_CONFLICT`, switch the active organization back to the one the upload was started in and retry the same call. +- 4f4c4ed: fix(auth,services): the remaining in-process session reads no longer renew a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Nine doors in these packages read the session in-process (`auth.api.getSession({ headers })`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, which then expired before the session: a dead cookie beside a live bearer, after which every cookie-only path saw a signed-out user. The doors: `POST /api/v1/auth/admin/oauth2/toggle-disabled`, every `/api/v1/auth/admin/*` mount behind the shared platform-admin gate, `POST /api/v1/auth/admin/unlock-user` and `POST /api/v1/auth/admin/has-permission` (`@objectstack/plugin-auth`); `POST /api/v1/webhooks/redeliver`; the storage upload and download doors (`/api/v1/storage/*`); the share-link management routes (`/api/v1/share-links`); the settings routes (`/api/settings`); and the datasource-admin routes (`/api/v1/datasources/*`). + + **The rule now** is the one the REST, dispatcher, current-user and cloud-connection doors already follow. Each of these reads goes through `inProcessSessionReadInput(headers)` from `@objectstack/types`: + + - **A request carrying a session cookie** (a browser, including a console that sends its cookie beside its bearer) reads with `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. + - **A bearer-only request** (`@objectstack/client` outside a browser, the `os` CLI) is unchanged: a read past `updateAge` still renews the session, and no cookie is set on a response to a request that sent none. + + **Upgrading.** Nothing to change. `@objectstack/plugin-webhooks` now depends on `@objectstack/types` directly; it already reached it through `@objectstack/core`. +- 54c3ce1: fix(service-storage): chunks sent in parallel to one chunked upload each record their part + + Clause-②: no + + `PUT /storage/upload/chunked/:uploadId/chunk/:chunkIndex` merges its chunk into the upload session's record of the chunks it holds (`parts`, `uploaded_chunks`, `uploaded_size` on `sys_upload_session`). It read that record, merged in memory and wrote the whole record back, so two chunk PUTs to one upload at the same time both answered `200` while the record kept only one of them: `GET …/progress` undercounted, and the completion was refused `409 RESOURCE_CONFLICT` naming the chunk the record had lost until the client sent it again. + + The record is now written with a compare-and-set: the write lands only while the row still holds the progress the chunk door read, and when another chunk's write landed first the door reads the record again and merges again. On a wired data engine this is the engine's own conditional update, evaluated in the same statement that writes, so it holds across server processes. Every chunk sent in parallel is recorded, and a parallel upload completes on its first completion. A sequential upload is unchanged. + + A chunk whose record write loses to another write on 16 attempts in a row is refused `409 RESOURCE_CONFLICT`, with `error.details` `{ chunkIndex, attempts }`: its bytes are stored, but the upload does not hold it. Send that chunk again. +- f782f17: fix(service-storage): an attach the attachment gate refuses no longer leaves the uploaded file stored forever — the caller's own never-attached file is tombstoned and reclaimed by the sweep + + Clause-②: no + + Attaching a file is two writes: the upload commits a `sys_file` (scope `attachments`), and then a `sys_attachment` insert attaches it to a record. When the attachment gate refused that insert with `403 ATTACHMENT_PARENT_ACCESS`, the file stayed `committed` with no attachment pointing at it. Nothing ever reclaimed it: files were tombstoned only when they lost their last attachment, and this one never had one. + + - **Now:** when the attachment gate refuses an attach, the file is tombstoned (`status: 'deleted'`, `deleted_at` set), the same way a file is tombstoned when its last attachment is removed. This covers every way the gate refuses: sharing denies edit on the parent, the parent's master record denies it (`controlled_by_parent`), or the parent cannot be read. From there the file follows the existing path. The lifecycle sweep reclaims the row and its bytes 30 days after `deleted_at`. At sweep time it checks again that nothing holds the file. + - **Only the caller's own unheld upload:** the file must be an `attachments`-scope, `committed` file. The refused caller must be its uploader (`sys_file.owner_id`). It must have no attachment and no field owner (`ref_*`). A refused attach that names someone else's file, a file another record still holds, or a field file changes nothing. + - **It survives a rollback:** the tombstone is written after the refusal, outside the refused write's transaction. If the attach ran inside a caller's own transaction, an `atomic` batch for example, rolling that transaction back does not undo the tombstone. + - **Unchanged:** the refusal itself (status, code, message), which does not depend on the file and says nothing about it; an admitted attach; and a retry. Retrying the same `file_id` within the 30 days is admitted or refused like any attach. An admitted retry attaches the file and brings it back to `committed`, as re-attaching a detached file always has. + - **Not covered:** an attach refused before the attachment gate runs still leaves the uploaded file `committed`. That is an attach with no `sys_attachment` create grant (`403 PERMISSION_DENIED`), or one to a parent with `enable.files` off (`403 FILES_DISABLED`). This is tracked in #22547. + + What changes for you: nothing to do. When the attachment gate refuses an attach, the uploaded file no longer uses storage indefinitely. +- 6a3fe25: feat(spec,service-storage,client)!: one upload-scope list — the upload requests' `scope` and the SDK's `storage.upload` close to the new `UploadScope` enum, the `sys_file` scope select is built from it, and the upload doors answer any other scope with `400` naming the allowed values instead of `500 INTERNAL` (#22470) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published request contract and on the SDK method that sends it, shipped as `minor` under the launch-window convention for accept-set narrowings, plus one new export. + + The presigned and chunked upload requests declared `scope` an open string, while the stored file record (`sys_file.scope`, a closed select) only ever took `user`, `tenant`, `private`, `temp` and `attachments`. Any other value reached the `sys_file` insert, the data engine refused it as an invalid option, and the upload door answered that caller error as `500 INTERNAL`, with a message telling the operator to restore the data engine. + + ### What changes + + - **`UploadScopeSchema` / `UploadScope`** (`@objectstack/spec`, new, exported from `@objectstack/spec/api`): the upload-scope vocabulary, declared once — `user`, `tenant`, `private`, `temp`, `attachments`. It is a different list from `StorageScopeSchema`, which classifies a storage configuration and is not read by any upload. + - **`GetPresignedUrlRequestSchema.scope` and `InitiateChunkedUploadRequestSchema.scope`** read it. The default stays `user`. A literal outside the list fails `tsc`, and a parse refuses it on the `scope` key. + - **`client.storage.upload(file, scope)`** (`@objectstack/client`): the `scope` parameter is typed `UploadScope` instead of `string`, default `user` unchanged, so a scope outside the list fails `tsc` at the SDK call. `client.storage.getPresignedUrl` and `client.storage.initChunkedUpload` take the request types above and narrow with them. + - **The `sys_file` scope select** (`@objectstack/service-storage`) takes its options from the enum, in its order, with the same labels. The stored values do not change. + - **The presigned upload door and the chunked upload door** answer a scope outside the list — any string, a case variant, `null`, a number — with `400 INVALID_REQUEST`, naming the allowed values, before a file record, a session record, an upload URL or a backend upload exists. `public` is refused by the same gate and keeps its remedy (`acl: 'public_read'` on the stored file record). An omitted scope is the default `user`, as before. A real data-engine fault still answers `500`. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | an upload naming a scope outside the list (a key prefix such as `avatars`, a folder, a record path) | one of `user`, `tenant`, `private`, `temp`, `attachments`, or no `scope` for the default `user` | + | `client.storage.upload(file, scope)` called with a `scope` typed `string` (`@objectstack/client`) | pass one of the five, or type the value `UploadScope` (`import type { UploadScope } from '@objectstack/spec/api'`) | + | a caller passing a scope typed `string` into either upload request | type it `UploadScope` | + + **The one-line fix: send one of the five upload scopes, or none.** `attachments` is for a file whose referrers are record attachment rows (it is what orphan tombstoning reads); `temp` for a scratch file; otherwise `user` or `tenant`. + + **No stored file record moves**: no upload naming another scope ever succeeded, so no stored record carries one. + + ### The kit + + - **D3 entry `upload-request-scope-closed`** carries the judgement no rewrite can make: which scope a file that was sent under a free name really belongs under. No D2 conversion: no metadata type carries the upload request, so `os migrate meta` has no authored source to rewrite. + - **Liveness.** No ledger row: the liveness ledger walks metadata types, and no metadata type carries the upload request. +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. +- 3d6392f: fix(service-storage): a presigned token the local adapter refuses answers `401 INVALID_TOKEN` on both raw byte routes, never a `500` + + Clause-②: no + + `PUT` and `GET /api/v1/storage/_local/raw/:token` are the local adapter's presigned byte routes. Each now answers every token it will not honour with `401 INVALID_TOKEN`, the status, code and message (`Invalid or expired token`) the error catalog already published for this code. The four cases are a malformed token, a forged one, one minted for the other verb, and an expired one. + + - **Before:** a malformed token, or one minted for the other verb, answered `500 INTERNAL`, which reads as a server fault. A forged or an expired token answered `403`, with a message naming which check failed. A genuine fault whose text happened to contain "expired" or "signature" was answered as a refused token. + - **Now:** every refusal gets the same status, code and message, so the answer never tells a forger whether the signature or the expiry failed. Any other failure behind a valid token is still `500 INTERNAL`, whatever its text says. + - **A 5xx withholds its message text.** Per the platform rule, a 5xx on the raw byte routes answers with the route's generic text and not the fault's own; the original error stays in the server log. + - **Token signatures are compared in constant time.** + - **Unchanged:** a valid token's upload or download, the `501 NOT_IMPLEMENTED` answer when no adapter can verify tokens, and every other storage route. + + What changes for you: a stale or tampered signed URL now answers `401` where it answered `403` or `500`. To recover, request a fresh URL (`GET /api/v1/storage/files/:fileId/url`, or a new presigned upload). The SDK's `storage.upload` and the console's upload adapter only check that the response is OK, so neither needs a change. +- 3c1a046: fix: a route's catch-all `500` answers its own generic text, never the caught error's message, and `@objectstack/types` exports the writer that does it, `sendServerFault` + + Clause-②: yes (widening) — `@objectstack/types` gains one export, `sendServerFault`; nothing is removed, renamed or narrowed, and no response field changes shape. + + A declared 5xx does not carry the text of the error it caught: that text can name server internals, such as a path, a host or a table. The REST layer already withheld it. The storage, settings and i18n route families and the share-link routes answer in the nested `{ success: false, error: { code, message } }` envelope and did not: their catch-all `500` sent the caught error's message to the caller. + + - **What a caller now reads.** The same `status` and `code` as before, with the route's own generic text as `error.message`: `Internal error` on the storage and i18n routes, the route's message on the settings routes (`Failed to list manifests`, `Failed to read namespace`, `Failed to write namespace`, `Action failed`), and the route's `Failed to …` message on the share-link routes. On the storage routes this includes the metadata store's outage sentence, which an operator now reads in the server log. + - **What the operator reads.** The original error, stack included, in one `[5xx]` server-fault line. The storage byte routes, which already withheld the text, logged two lines per fault and now log one. A line names the mounted route pattern when the route passes one, never the request URL. + - **Unchanged.** Every `4xx` answer. The settings `SETTINGS_CRYPTO_UNAVAILABLE` refusal, which keeps its prescription. The authorization-store outage `503`, which keeps its declared code and message. The storage `501 NOT_IMPLEMENTED` answers. On the share-link routes, any error that declares its own `status` keeps the envelope it declares. + - **`sendServerFault(res, status, code, message, thrown, request?)`** (new, `@objectstack/types`): writes `message` in the declared envelope, and hands `thrown` to the server-fault log with its stack, as the fault's one line. Use it for an arm that answers a caught fault. Keep `sendError` for a `4xx` and for a `5xx` whose text you wrote for the reader. `request` is the mounted route pattern, never the request URL. +- 8d3dd6f: The storage download door resolves its caller through `@objectstack/core`'s `resolveRequestExecutionContext`, the one request-to-`ExecutionContext` composition + + Clause-②: no + + - The download authorizer that `StorageServicePlugin` and `mountStorageRoutes` bind for parent-governed files resolves the caller by calling `resolveRequestExecutionContext` with this door's differences as named parameters: `anonymous: 'fail-closed'`, `authGate: 'resolved'`, `credentials: 'api-key-and-session'`, `sessionApi: 'optional'`, `session: 'request'`, its own way to reach the `tenancy` service, `localization: 'withhold'`, no request locale, `accessToken: 'carry'`, and no OAuth provenance or admission channel. The auth-service lookup, the header normalisation, the tenancy wiring, the verdict reads and the fail-closed `catch` stay at the door. The family's ADR-0069 gate reader, which runs before every storage route, is unchanged. + - The answers are the ones the door gave before: an anonymous caller, an unrecognised cookie, a failed session read with no gate active and a refused API key get `401 AUTH_REQUIRED`; a session or an API key owner the authentication policy holds gets `403` with its code and message from the family's gate; an unreadable permission store or tenancy service gets `503 SERVICE_UNAVAILABLE`; and an API key its own lifecycle refuses gets its `403` and code. A one-off probe of 47 fault wirings through the download route read the same answer on both sides of the change. + - The context the door hands to the owner-record read and to a file-access delegate is now the assembled `ExecutionContext` envelope rather than the resolver's raw output: it also states `isSystem: false` and `principalKind: 'human'`, and no longer carries the internal `credential` marker, which no reader of that context consumes. + - Nothing to do for a consumer: no export, route, configuration key or response shape changed. +- 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). +- abb6df0: Clause-②: no + + fix(cli): `os storage orphans`, the `os secret rewrap` dry run and the `os migrate summary-nulls` dry run say they write no row or table, and that opening a SQLite file can still switch its journal mode, instead of saying nothing was written + + **What was wrong.** Each of these runs 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, each run 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 "nothing was written". Nothing was lost. + + **What changed.** Each sentence below now claims no row or table. Where the run opened a SQLite file, the report's line is followed by the note the other read-only 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 line ends without it. + + - `os storage orphans` (`formatStrandedOrphanInventory`, `@objectstack/service-storage`). FROM: "This pass is READ-ONLY: nothing was written, tombstoned or deleted." TO: "This pass is READ-ONLY: no row or table was written, tombstoned or deleted." and the note. + - `os secret rewrap` (the dry run's note, `REWRAP_NOTES.dryRun`, also in `--json` `report.notes`). FROM: "Dry run: every attributed row was opened, re-sealed and verified in memory, and nothing was written. Re-run with --apply to write." TO: "… and no row or table was written. Re-run with --apply to write." The note already follows the dry run's closing line on a SQLite file, so this sentence carries none. + - `os migrate summary-nulls` (the dry run that found NULL roll-ups, `formatSummaryBackfillReport`, `@objectstack/objectql`). FROM: "Dry run — nothing was written. Re-run with --apply to backfill." TO: "Dry run — no row or table was written. Re-run with --apply to backfill." and the note. + + **Unchanged.** No exit code, flag, prompt, refusal or export signature changes, and no `--json` document gains a field; 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. +- af284b0: feat(spec): the remaining `getSession` readers read `AuthSessionApi`'s declared result, and none reads a top-level `activeOrganizationId` + + Clause-②: yes (widening) + + - **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains three optional `user` members**, each declared for a named reader: `isPlatformAdmin` (`boolean`, the ADR-0068 D2 rung plugin-auth's `customSession` derives) and `role` (`string | null`, better-auth's `admin` plugin scalar), both read by `judgePlatformAdmin` in `@objectstack/plugin-auth`; and `name` (`string`), read by the runtime dispatcher's AI-route `req.user` resolver as the display name. Every member is optional, so every implementer and caller typechecks unchanged. + - **The readers read through the declared type instead of `any` or a cast** (types only, runtime behaviour unchanged): `judgePlatformAdmin` and the four platform-admin mounts in `@objectstack/plugin-auth` (better-auth's own session type now has to fit the declaration at those reads), the REST server's two session reads (`@objectstack/rest`), the dispatcher's posture read, `resolveSessionData` and both `HttpDispatcher` reads (`@objectstack/runtime`), the cloud-connection and install-local session reads (`@objectstack/cloud-connection`), the webhook redeliver door (`@objectstack/plugin-webhooks`), and the storage session bridge (`@objectstack/service-storage`). + - **Fix — the top-level `activeOrganizationId` fallback is removed** from the storage upload stamp (`@objectstack/service-storage`) and from install-local's `resolveActiveOrgId` (`@objectstack/cloud-connection`). The active organization is read from `session.activeOrganizationId` alone, where the contract declares it. No in-process producer answers a top-level `activeOrganizationId` (better-auth's read is `{ user, session }`), so for the shipped auth plugin nothing changes. An auth service that answered only the flattened shape now gets no organization: the upload is not stamped, and install-local reports no active organization. To fix it, answer the active organization on `session.activeOrganizationId`, as `AuthSessionApi` declares it. +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [746637e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [3071d3e] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [3f0a307] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [77efc66] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf540a7] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/types@18.0.0 + - @objectstack/observability@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index 2f839565c62..41417e21777 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Storage Service for ObjectStack — implements IStorageService with local filesystem and S3 adapter skeleton", "type": "module", diff --git a/packages/skills/CHANGELOG.md b/packages/skills/CHANGELOG.md index 19721b09132..5de333b9d49 100644 --- a/packages/skills/CHANGELOG.md +++ b/packages/skills/CHANGELOG.md @@ -1,5 +1,121 @@ # @objectstack/skills +## 18.0.0 + +### Minor Changes + +- 165587e: New published package `@objectstack/skills`: the ObjectStack skills catalog (`skills/**` of the repository), shipped in the changeset `fixed` group so it always carries the version of the `@objectstack/*` packages it teaches. Its build copies the catalog into `dist/skills//…` byte for byte (the layout the skills CLI's `experimental_sync` reads from `node_modules`), and `files` lists only that tree. The repository's `skills/**` stays the one source of truth and the documented `next` channel; nothing a consumer writes changes. + + `@objectstack/spec`: `llms.txt`'s package-ecosystem count reads 69 with `@objectstack/skills` joining the scope. + +### Patch Changes + +- f4755a6: docs(skills): `objectstack-data` says which hook doors provide `ctx.title` — a `body` has it, a deprecated `handler` gets it at runtime without `HookContext` declaring it, and `ql.registerHook` and a bare `ObjectQL` `bindHooks` do not provide it (#24289) + + Clause-②: no + + `skills/objectstack-data/references/data-hooks.md` listed `ctx.title(field?)` in the sandbox `ctx` table with no qualifier, so it read as a member of every hook context. It is not: a sandboxed `body` has it (the recommended form); an inline or named `handler`, which is deprecated, is handed it at runtime when the runtime binds the stack's hooks, but `HookContext` does not declare it, so a handler annotated with that type fails to compile; a hook registered with `ql.registerHook(...)`, or bound through `ObjectQL.bindHooks` with no runtime, gets no `ctx.title` at all. The row now says so. Text only; nothing an author writes changes, and no hook context changes. +- e4a770d: docs(skills): the `objectstack-ai` skill names the `agent` type's environment-overlay switch by its protocol-18 spelling, `allowEnvironmentOverlay:false` (#22340 S3) + + Clause-②: no + + `skills/objectstack-ai/SKILL.md`'s "Platform agents — reference only" section said the `agent` type is closed with `allowOrgOverride:false`. That key was renamed to `allowEnvironmentOverlay` in protocol 18 (ADR-0131 D6 retired the per-organization overlay axis, so the key names the one scope it governs; the old key stays on the registry entry schema only as a tombstone that refuses at parse with the new name in its prescription), so the published line now reads `allowEnvironmentOverlay:false`. The value, the `allowRuntimeCreate:false` beside it and the ADR-0063 §2 citation are unchanged; nothing an author writes changes. The same rename is recorded in `AGENTS.md` rule 7 and as header amendment notes on ADR-0005 and ADR-0010, none of which publishes. +- 44cadd3: fix(skills): the automation skill teaches flow value slots as a CEL envelope or a literal and text slots as `{{ }}` holes; the single-brace `{…}` dialect leaves the catalog text + + Clause-②: no + + The `objectstack-automation` skill no longer lists `{$User.}`, `{NOW()}` / `{TODAY() ± N}` or any other single-brace `{…}` token as a spelling a flow value slot still reads, and the remedies it gives no longer pass through a refused spelling. What the skill now teaches is what the platform accepts: + + - A value slot — `assignments.*`, `fields.*`, a `subflow` / `map` `input.*`, a `script` `inputs.*`, a `loop` / `map` `collection`, a notify node's `recipients` / `templateData.*` / `sourceId`, an `http` node's `body`, each CRUD `filter` operand — takes a CEL value envelope `{ dialect: 'cel', source: '…' }` or a literal. A `{…}` token there is refused at `objectstack validate` (`expression-invalid`), `registerFlow` and the executor, naming its CEL spelling. + - A text slot — notify `title` / `message` / `actionUrl`, screen `title` / `description`, end `message`, `http` `url` and `headers` — renders `{{ }}` holes. + - The run user is `current_user.id`, guarded as `current_user != null ? current_user.id : null` in a flow that can run without a user; the date macros are `isoDate(today())`, `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, `isoDatetime(now())` and `isoDatetime(addDays(now(), N))`. + - A CRUD `filter` operand that is a whole-string query placeholder (`{current_user_id}`, `{today}`) passes through to the query engine untouched; an operand that evaluates to `null` refuses the node. + - A notify `recipients` value is one id, a list, or an envelope that evaluates to one, a list or `null` — a comma-separated string is one recipient. + + The `objectstack-query` skill's filters rule names the value-slot judge (`expression-invalid`) in place of the retired lint rule `flow-filter-token-unknown`, and the automation eval set's two expected outputs that spelled a retired token move with the text. +- 197fbe2: docs(skills): both READMEs the package ships install the catalog from `@objectstack/skills` at the installed `@objectstack/spec`'s version, and name the GitHub path only as the `next` channel (#22668) + + Clause-②: no + + `README.md` (the npm page) and `dist/skills/README.md` (the catalog's front page, copied from the repository's `skills/README.md`) are the two READMEs this package ships. Both now give the same two-step install the docs give: `npm install --save-dev "@objectstack/skills@$(node -p "require('@objectstack/spec/package.json').version")"`, then `npx skills experimental_sync --agent claude-code -y` — the catalog's version is then the installed spec's. + + Before: the npm page taught a bare `npm install --save-dev @objectstack/skills`, which on a project depending on `@objectstack/spec` 17.x installs `18.0.0-next.0`, the next major's catalog (the registry's `latest`); its example named `@objectstack/skills@17.7.0`, a version that was never published; and it did not say that the sync skips every skill a `skills add` installed, which is every project `create-objectstack` scaffolded through `18.0.0-next.0`. The catalog's front page still taught `npx skills add objectstack-ai/objectstack/skills …` as the install command for existing projects. + + Both pages now state which versions exist (published from `18.0.0-next.0` on; no 17.x, so step 1 answers `ETARGET` there), the update story (bump `@objectstack/skills` with the other `@objectstack/*` packages, then re-run the sync), and the GitHub path once, as the `next` channel: the unreleased `main`, for trying the next version's skills, not for a released project. Nothing under `dist/skills/*/` changes. +- 179f7bf: fix(skills): the `objectstack-data` field-types rule names all five formula `returnType` members, `'currency'` included, and the `currencyConfig` a currency formula carries (#22743) + + Clause-②: no + + `skills/objectstack-data/rules/field-types.md` taught a formula's `returnType` as the four-member set `'number' | 'text' | 'boolean' | 'date'`, and its formula example said `(no 'currency')`. `@objectstack/spec`'s `FieldSchema.returnType` is the five-member enum `'number' | 'text' | 'boolean' | 'date' | 'currency'`, and a formula whose `returnType` is `'currency'` carries its currency in its own `currencyConfig` — `CurrencyConfigSchema`, the same shape a `currency` field declares; absent, the mode is `dynamic`, the tenant default currency. The `formula` table row and the formula example now both state the five-member set and name `currencyConfig` as the carrier. The example's `CEL — record. prefixes required` comment, which restated the table cell above it, is removed to pay the skill's token ratchet. Nothing is said about how a currency formula renders. +- eed637c: docs(automation): every text that teaches a sender of an inbound `api` hook teaches the timestamped `x-objectstack-signature` form only — `t=,v1=`, the HMAC-SHA256 of `.` under the flow's secret, refused when `t` is more than 300 seconds from the server clock + + Clause-②: no + + The inbound hook door has accepted the timestamped form since `signHttpBodyAt` landed in `@objectstack/core`, and it is the only form that stops a captured post from verifying forever; the published teaching still showed the body-only `sha256=` form. The guidance converges first; the door's refusal of the body-only form is a later change of its own. + + - **`@objectstack/skills`.** The `objectstack-automation` skill's Inbound webhook section teaches `t=,v1=`: `v1` is the HMAC-SHA256 of `.` under the start node's `secret`, and the door refuses a `t` more than 300 seconds from its clock. The body-only form is no longer shown. + - **`@objectstack/lint`.** The hint of `flow-api-trigger-secret-missing` and its `os explain` entry describe the same form. The rule's id, severity, verdict sentence and judgement are unchanged. + - The showcase example's comment and the platform checklist's intake recipe sign the same way. + + **Nothing to migrate for an author.** A sender that already signs with `signHttpBodyAt` is unchanged. **The outbound contract stays as published:** the messaging outbox and the flow `http` node keep signing body-only (`signHttpBody`) for third-party receivers, and the inbound door's accept set is not changed by this release. +- e9e10fd: docs(skills): the `objectstack-data` indexing rule's partial-index example teaches the rule with an application-shaped index instead of restating the platform's overlay uniqueness key (#23039) + + Clause-②: no + + `skills/objectstack-data/rules/indexing.md`'s "Access methods and partial indexes" section illustrated a partial index with the platform's own overlay-uniqueness DDL and said that was what `ensureOverlayIndex` runs. The quoted key had four parts; the platform's runtime key has carried a fifth, `bundle_member`, since protocol 18, so the sentence stated something the platform no longer runs. The example is re-cut to an application-shaped partial index — one *active* subscription per account, the coarse `{ fields: ['account_id'] }` declared in metadata and the unique partial form built in a migration — so the rule is taught without restating a platform key that changes on its own schedule. The rule itself is unchanged: declare the coarse index (or none) in metadata, build the partial form as raw SQL in a runtime migration, fall back to a plain index on a dialect that rejects the predicate. Nothing an author writes 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. +- 713f231: docs(skills): the upgrade skill's preflight re-installs the catalog from `@objectstack/skills` when a 17.x project moves to 18, and both READMEs give the scaffolder's pinned `--copy` sync (#23276) + + Clause-②: no + + `objectstack-upgrade`'s §0 Preflight gains one step for the skills themselves. A 17.x project's skills were installed with `skills add` from the repository's `main`; their `skills-lock.json` entries read `sourceType: github`, and `experimental_sync` skips them (it prints `Skipped … installed with skills add` and exits 0), so a project that only bumps its packages keeps its 17.x-era skill text. The step removes the eleven `objectstack-*` skills the catalog carried during 17.x (a name not installed is skipped; `--all` when they are the project's only skills), installs `@objectstack/skills` at the installed `@objectstack/spec`'s version, and copies it with `npx skills@1.7.2 experimental_sync --agent claude-code --copy -y` — the command `create-objectstack` runs. The step is paid under the token ratchet by deleting text the same file restates elsewhere; the ceiling is unchanged. + + `README.md` (the npm page) and `dist/skills/README.md` (the catalog's front page, copied from the repository's `skills/README.md`) replace the unpinned linked sync (`npx skills experimental_sync --agent claude-code -y`) with that pinned `--copy` spelling and say why: without `--copy` the sync writes links into `node_modules`, which a clone of a project that commits its skills does not have. +- 68e64dd: fix(skills): the automation skill's two reference files write a CRUD `filter` operand as a CEL value envelope or the `{today}` query placeholder; the single-brace `{…}` filter spelling leaves them + + Clause-②: no + + `skills/objectstack-automation/references/examples-flows.md` and `references/state-machines-and-approvals.md` no longer teach `'{TODAY()}'`, `'{TODAY() + N}'` or `'{record.id}'` at an `update_record` filter operand — spellings the platform refuses (`expression-invalid`) at `objectstack validate`, `registerFlow` and the executor, so an author who copied the example got a refused node. The examples now write what the platform accepts: the query placeholder `'{today}'` for today's date, a CEL value envelope `{ dialect: 'cel', source: 'isoDate(daysFromNow(N))' }` for a one-day window at a date offset, and `{ dialect: 'cel', source: 'record.id' }` for the triggering record's id. The prose names the CEL value envelope where it named a template token (and no longer says a CEL envelope is compared as a literal at a filter operand), and the time-word cheat sheet names the CEL envelope and the `{{ }}` text hole where it named the retired `{…}` template. +- 46ca793: docs(skills): the `objectstack-ui` pages rule says what a page type, template and region name run (#23610) + + Clause-②: no + + `skills/objectstack-ui/rules/pages.md` taught `home` as the app's landing page, `utility` as a floating utility panel, templates `two-column` / `single-column` and regions `left_sidebar` / `right_sidebar`, and `kind: 'jsx'` as a live alias of `html` — none of which the console draws or the spec accepts. The rule now states the declared contract: `home` is the widest region page and not a landing (an app opens on its first navigation item; `isDefault` on it is refused), `utility` is a narrow inline region page with no floating or docked shell slot, `template` is one of `default`, `header-sidebar-main`, `three-column` or `dashboard` with the regions each one places (`sidebar` left, `aside` right; `full-width` retired), a region the template does not place or a second region for a filled slot is refused at its `name`, and `kind: 'jsx'` is refused with the prescription `html`. Skill text only; 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. +- b711056: docs(skills): the `objectstack-ui` navigation rule states the derived record surface per flow — viewing routes to a page, creating or editing opens a modal bounded on desktop and full-screen on mobile, a light record uses a drawer (#23753) + + Clause-②: no + + `skills/objectstack-ui/rules/navigation.md`'s Surface bullet said a field-heavy object opens create/edit/detail as a full page and that mobile always pages. The platform's derivation (`deriveRecordFlowSurface` in `@objectstack/spec`) routes only the `view` flow to a page; the create, edit and child task flows never route — a field-heavy record, or any record on mobile, opens them in a modal that is bounded on desktop and full-screen on mobile, and a light record on desktop opens every flow in a drawer. The bullet now states that mapping and names the override only where the spec declares it: `navigation.mode` on the list view (`navigation` is not an object key). Text only; nothing an author writes changes. +- 2fdfb5d: docs(skills): the `objectstack-data` datasources rule states that `default` is host-reserved — the host connects it from `OS_DATABASE_URL`, and a stack that declares a datasource named `default` is refused (#23768) + + Clause-②: no + + `skills/objectstack-data/rules/datasources.md`'s opening paragraph read "Most apps need only the `default` datasource; declare more to read/write a separate or external database." "Declare more" implied that `default` is declared too, and a stack whose `datasources` collection carries an entry named `default` is refused at `defineStack`, `os validate`, publish and boot: the host declares and connects `default` itself. The sentence is replaced by the rule the spec and runtime hold: `default` is host-reserved, the host connects it from `OS_DATABASE_URL`, a stack that declares a datasource named `default` is refused, and a datasource is declared under its own name only to read/write a separate or external database. The added words are paid for inside the same file by deleting two restatements of the field reference: "`external` settings are required when `schemaMode !== 'managed'`" (the schema refuses the omission itself) and "Set `autoConnect: true` to force a live connection" (the auto-connect list two lines above already names it). No behaviour changes; nothing an author writes changes. +- 49700e4: docs(skills): the `objectstack-api` skill stops teaching an app to mount routes on `http.server` — an app declares `apis:`, computes in a flow, and registers code as a stack function or a connector (#23787) + + Clause-②: no + + `skills/objectstack-api/SKILL.md`'s decision table sent an endpoint that needs code to an imperative `http.server` mount on `kernel:ready`, and its "Code routes" section taught that mount. Ruling C′ item 2 on #23128 retired the mount as an app-author surface: the `http.server` service is the platform plugins' own door-building primitive. The table now points code at a `type: 'flow'` endpoint whose `script` node runs a stack function (`defineStack({ functions })`) or whose `connector_action` node calls a declared connector, names the inbound `type: 'api'` flow hook for a webhook signed with ObjectStack's own `x-objectstack-signature`, and states the three cases that have no app surface today (a third-party callback verified by its own signature over the raw body, a streaming response, a protocol the platform does not speak) instead of inventing one. The "Code routes" section is deleted, and eval 3 expects that answer. Skill text only; nothing an author writes changes. +- f1c0b57: docs(skills): `objectstack-data` and `objectstack-formula` state that `previous` is `null` on insert — `previous == null` judges an insert, a column read off it there faults, and only a write to an existing row whose prior state is not in hand leaves it unbound (#23983) + + Clause-②: no + + `skills/objectstack-data/references/data-hooks.md` and `skills/objectstack-formula/SKILL.md` said `previous` is unbound on insert events and told authors to write insert-event conditions over `record` alone. Since `@objectstack/objectql` binds `previous` to `null` on insert in validation rules and declarative hook conditions (the binding a flow's start condition already had), that text was false: `previous == null || …` judges an insert and `previous != null && …` skips one; only a column read off `previous` on insert (`previous.status`) still has no value and still fails the write, and only a write to an existing row whose prior state is not in hand leaves `previous` unbound, failing closed. The bullet, the scope-table row, the abort note and the legacy-translation note now state that binding and the `null` guard; the `record`-alone advice stays correct and is no longer presented as the rule. Text only; nothing an author writes changes, and a hook's `ctx.previous` is unchanged. +- 53ef752: docs(skills): `objectstack-api` states that a public form's submit is authorized by the form's own `publicFormGrant`, and that the `guest_portal` name on the submit context admits nothing and needs no permission set (#24239) + + Clause-②: no + + `skills/objectstack-api/SKILL.md` described the anonymous form endpoints as running under a synthetic `{ permissions: ['guest_portal'], anonymous: true }` execution context and named nothing else, so an author reading it inferred that a `guest_portal` permission set authorizes the submit and declared one, or concluded that the form falls open without one. Neither is true: the submit is authorized by the form's own declaration (ADR-0056 Option A) — the route hands the engine a `publicFormGrant` scoped to the form's target object, which admits the create and the read-back of the row it made and nothing else, decided before any permission set resolves. The `guest_portal` name on the context admits nothing; where a deployment registers a set under it, that set only feeds the grant's result masker. The passage now says so, matching `content/docs/ui/public-data-collection.mdx` and the `registerFormEndpoints` docstring in `@objectstack/rest`. Text only; nothing an author writes changes, and the form routes' behaviour is unchanged. + ## 18.0.0-next.5 No changes in this release. diff --git a/packages/skills/package.json b/packages/skills/package.json index 9f0d1db645d..2e64272b8d8 100644 --- a/packages/skills/package.json +++ b/packages/skills/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/skills", - "version": "18.0.0-next.5", + "version": "18.0.0", "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 70c22e16a8c..fc561fc2668 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,6164 @@ # @objectstack/spec +## 18.0.0 + +### Major Changes + +- 317cddd: feat(spec)!: an element binds data through the node-level `dataSource` only — the flat `object` / `filter` / `sort` / `limit` of `element:record_picker`, `element:number` and `element:repeater`, and `object-grid.defaultFilters`, are retired (#11509) + + Clause-②: no (narrowing: the ten element-layer flat data-binding keys and `object-grid.defaultFilters` leave the accept set, and the component-props gate's `dataSource.object` waiver becomes a refusal) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). `@objectstack/lint` is `major` with it: its component-props rule refuses what it used to waive. + + **Why.** One page element carried two doors onto one query. Each of these flat keys was the same query as a key of the node-level `dataSource` binding (`ElementDataSourceSchema`), and the three renderers resolved them by three contradictory rules: the record picker let the binding win, `element:number` AND-combined the two filters, and the repeater read the flat keys alone and ignored the binding — while the component-props rule waived a missing flat `object` whenever `dataSource.object` was present, so a repeater bound only through `dataSource` passed `os validate` and drew "No records". The console moved all three elements onto the binding first (objectui#11880); from this release the binding is the one door. `object-grid.defaultFilters` was the legacy second spelling of `filter`, read only when `filter` lowered to nothing. + + **What changes.** + + - **`@objectstack/spec`.** `ElementRecordPickerPropsSchema` `object` / `filter` / `sort` / `limit`, `ElementNumberPropsSchema` `object` / `filter`, `ElementRepeaterPropsSchema` `object` / `filter` / `sort` / `limit` and `ObjectGridPropsSchema` `defaultFilters` are `retiredKey()` tombstones: each is refused at its key with the prescription, and its input type is `never`. The repeater's other spellings of its query keys (`objectName`, `where`, `orderBy`, `top`, …) now point at the binding. The `object-*` blocks keep their own keys, and the relationship-scoped blocks are unchanged. + - **`@objectstack/lint`.** `validate-component-props` (`component-props-invalid`, warning) reports an `element:record_picker`, `element:number` or `element:repeater` node with no `dataSource.object` at that path, instead of waiving the flat `object` for any type whose binding names one. A flat key is reported by its tombstone. + - **Conversions (stored rows, artifacts, `os migrate meta --from 17`).** `element-flat-data-binding-to-data-source` moves a flat key the binding lacks onto it, deletes one the binding already set where the binding won, and appends `element:number`'s flat filter to the binding's (they always AND-combined). It leaves for the author, as a TODO: a record-picker key beside a `dataSource.view` that sets no such key of its own, a repeater key the binding sets to a different value or beside a `view`, and an `element:number` filter pair that is not two rule arrays. `object-grid-default-filters-removed` moves `defaultFilters` onto an empty `filter` (absent, `null`, `[]` or `{}`) and deletes it beside a `filter` that has rules. Both run before `page-component-filter-record-to-rule-array`, which then converts a moved record-form filter at its new door. Both are retired from the load path: an author is refused at the parse. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ type: 'element:record_picker', properties: { object: 'deal', limit: 20, labelField: 'name' } }` | `{ type: 'element:record_picker', dataSource: { object: 'deal', limit: 20 }, properties: { labelField: 'name' } }` | + | `{ type: 'element:number', properties: { object: 'deal', aggregate: 'count', filter: [...] } }` | `{ type: 'element:number', dataSource: { object: 'deal', filter: [...] }, properties: { aggregate: 'count' } }` | + | `{ type: 'element:repeater', properties: { object: 'deal_note', sort: [...], titleField: 'subject' } }` | `{ type: 'element:repeater', dataSource: { object: 'deal_note', sort: [...] }, properties: { titleField: 'subject' } }` | + | `object-grid` `properties: { defaultFilters: [...] }` (no `filter`) | `properties: { filter: [...] }` | + | `object-grid` `properties: { filter: [...], defaultFilters: [...] }` | `properties: { filter: [...] }` — the fallback was never read beside a `filter` with rules | + + **The one-line fix: move each key from `properties` to the node's `dataSource` (a sibling of `type`), unchanged; rename `defaultFilters` to `filter` where `filter` is empty, and delete it where it is not.** + + **For a consumer that pins both repositories:** its objectui pin moves past objectui#11880 no later than its objectstack pin moves past this release — the converted shape is one only that objectui reads. + + **Who is affected, measured.** This repository authors none of the eleven keys: its one element-layer author (the showcase record picker) already binds through `dataSource`, and a tree-scoped absence pin (`packages/spec/src/ui/element-flat-binding-retirement.test.ts`) keeps it that way. Other repositories and deployed metadata were not measured here. + + ### The retirement kit + + - `RETIRED_KEYS_BY_MAJOR[18]`: `ui/ElementRecordPickerProps:object|filter|sort|limit`, `ui/ElementNumberProps:object|filter`, `ui/ElementRepeaterProps:object|filter|sort|limit`, `ui/ObjectGridProps:defaultFilters`. D3 entries `element-flat-data-binding-retired` and `object-grid-default-filters-retired`, each with a step-18 rationale fragment. They absorb the three protocol-18 narrowings of the same keys to the rule array (`element-number-filter-rule-array`, `element-record-picker-filter-rule-array`, `object-grid-default-filters-rule-array`), whose keys are gone in the same major; `page-component-filter-record-to-rule-array` drops the retired doors from its reach. + - Generated: `authorable-surface/ui.json` (11 rows `[RETIRED]`) and the `ui/component` reference page. Hand-edited ledger: `dropped-refinements.baseline.json` loses the three rows whose only dropped refinement was a retired `filter`. + - Pins: the tombstones, the binding, both conversions and the tree-scoped absence walk in `element-flat-binding-retirement.test.ts`; the missing-binding refusal and the repeater trap in `validate-component-props.test.ts`; the docs gate's twin in `check-yaml-examples.ts --self-test`. +- f66fdc7: The maps a flow node hands to a CALLEE are value slots now: a `subflow` node's `input`, a `map` node's `input` (evaluated once per item) and a `script` node's `inputs`. Each value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`, evaluated in the calling flow's scope and handed over as the value it computes, or a literal written as it is. A `{…}` template token there is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling, as in every other value slot. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. These three maps are computed values: each one becomes the callee's input. Until now each executor handed its map to the single-brace interpolator, so a `{token}` resolved there, and a CEL envelope written there reached the callee as the object `{ dialect: 'cel', source: '…' }` it spells, with the run reporting success. + + **What changes for a value that may be absent.** Where a whole token resolved to nothing, the template handed the callee nothing. A child flow then seeded its input variable from the variable's `defaultValue`. Under CEL an absent variable or key fails the run, and the guarded form `has(vars.x) ? vars.x : null` hands `null`. A `null` the caller supplies is a value: it wins over the child's `defaultValue`. To keep the default for an absent value, write it in the guard, `has(vars.x) ? vars.x : 'standard'`. Leave the key out where the value is never meant to be supplied. A `script` function is handed `null` where the template handed `undefined`. The refusal at those positions says this. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `input: { ownerId: '{record.owner}' }` on a `subflow` | `input: { ownerId: { dialect: 'cel', source: 'record.owner' } }` | an absent `owner` fails the run; guarded, it hands `null`, which wins over the child's `defaultValue` | + | `input: { ownerId: '{x}' }` where the child's default should apply when `x` is absent | `input: { ownerId: { dialect: 'cel', source: "has(vars.x) ? vars.x : 'standard'" } }` | the default is written in the guard, because a supplied `null` is not absent | + | `input: { row: '{item}' }` on a `map` | `input: { row: { dialect: 'cel', source: 'item' } }` | evaluated once per item, with the item variable bound; a list or a record is handed over with its type | + | `inputs: { lines: '{lines}' }` on a `script` | `inputs: { lines: { dialect: 'cel', source: 'lines' } }` | the function receives the list; guarded, an absent value is `null` where it was `undefined` | + | `input: { message: 'Task "{record.title}" is done.' }` | `input: { message: { dialect: 'cel', source: "'Task \"' + record.title + '\" is done.'" } }` | one CEL concatenation; wrap a hole that may be null in `coalesce(…, '')` | + + **The one-line fix: write each value of a `subflow` / `map` `input` and a `script`'s `inputs` as a CEL value envelope, and write a child's default into the guard where it should apply.** + + **Who is affected, measured.** In this repository: 5 values in 3 nodes of the showcase example (`showcase_task_completed`'s `script`, and the `subflow` nodes of `showcase_task_done_notify_owner` and `showcase_project_closure`), 3 values in the todo example's `task_completion` `script`, 2 docs pages, the service-automation README, 2 dogfood fixtures and the test fixtures; all are migrated in this change. hotcrm's sites at these positions were not measured. + + **Still accepted, unchanged.** A CEL value envelope and every literal. + + ### The kit + + - **The contract.** `SubflowConfigSchema.input`, `ScriptConfigSchema.inputs` and `MapConfigSchema.input` take `FlowValueSlotSchema` per value (`@objectstack/spec/automation`), so the executor's contract parse refuses a token as a guard. Each published JSON Schema declares the dropped refinement (`dropped-refinements.baseline.json`). + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains `subflow.input.*`, `map.input.*` and `script.inputs.*` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries `MapConfigSchema`. `registerFlow`, `objectstack validate` and `flow-bare-dollar-reference` / `flow-double-brace-interpolation`'s value-slot hints follow the ledger, so all of them cover the three maps. + - **The executor.** One per-key resolver, shared with the CRUD `fields` map, evaluates an envelope through `AutomationEngine.evaluateValueEnvelope` in the parent's scope and run context. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 9fdb127: A `loop`'s (with a `body`) and a `map`'s `collection` is a value slot now: the list the node iterates is a CEL value envelope, `{ dialect: 'cel', source: 'tasks' }`, that the executor evaluates in the run's scope, or the items written as an inline array, iterated as written. A string there is refused at `objectstack validate`, at `registerFlow` and by the executor: a `{…}` template token, naming its CEL spelling, and a variable's bare name, naming that variable in an envelope. The envelope is newly accepted there; the contract refused it before this change. + + Clause-②: yes (narrowing: a string is refused at loop.collection and map.collection, a {…} token and a variable's bare name alike; widening: both accept a CEL value envelope they refused, their published JSON Schema and inferred types gain the envelope arm, and FlowCollectionSlotSchema is a new export) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (both slots accept a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. The list a `loop` or `map` iterates is a computed value. Until now the executors ran a string `collection` through the single-brace interpolator. The contract also declared a variable's bare name there (`collection: 'tasks'`), but the structured `loop` and `map` never read one: the interpolator handed the name back unchanged, the value was no list, and every run failed at the node ("collection 'tasks' did not resolve to an array"), although `registerFlow` and `objectstack validate` accepted the flow. That arm retires with the token, and the refusal names the envelope that iterates. + + **What changes where the list may be absent.** The envelope must evaluate to a list, and anything else fails the node, `null` included, as a token that resolved to nothing did. One difference remains on an absent variable: the template failed the node with a returned failure (the step's `NODE_FAILURE`), and CEL fails it with an evaluation fault naming the variable (`EXECUTION_ERROR`). A `fault` edge routes both. To iterate nothing when the list may be absent, guard it to answer `[]`: `has(vars.tasks) ? vars.tasks : []`. + + **What does not change.** An inline array is iterated as written, and its elements are data: a `{…}` inside one was never interpolated, and is not judged now. A `loop` with no `body` is the legacy flat-graph form: it still reads `collection` as a bare variable name and sets `$loopItems` and `$loopIndex`, and no door judges it. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `collection: '{tasks}'` | `collection: { dialect: 'cel', source: 'tasks' }` | an absent `tasks` fails the node with an evaluation fault instead of a returned failure | + | `collection: '{record.items}'` | `collection: { dialect: 'cel', source: 'record.items' }` | as above | + | `collection: '{tasks}'` where an absent list should iterate nothing | `collection: { dialect: 'cel', source: 'has(vars.tasks) ? vars.tasks : []' }` | the node iterates nothing where the template failed it | + | `collection: 'tasks'` (a bare name, on a `loop` with a `body` or a `map`) | `collection: { dialect: 'cel', source: 'tasks' }` | the node iterates; it failed every run before, so decide whether it should | + | `collection: ['a', 'b']` | unchanged | an inline array is the list | + | `collection: 'tasks'` on a `loop` with no `body` | unchanged | the legacy flat-graph loop is untouched | + + **The one-line fix: write a `loop`'s or a `map`'s `collection` as a CEL value envelope naming the list, `{ dialect: 'cel', source: 'tasks' }`, or as an inline array.** + + **Who is affected, measured.** In this repository: the showcase example (two `loop` collections and one `map` collection), the todo example (two `loop` collections), the flows guide, the service-automation README, and test fixtures; all are migrated in this change. hotcrm writes a `{…}` collection on eleven structured loops in nine flow files (and one test fixture) and no bare name; it is on spec 17.7.0, so nothing there moves until it upgrades. + + **Newly accepted.** A CEL value envelope at `loop.collection` and `map.collection`: the published JSON Schema for both gains the envelope arm (a list, or the envelope object), and so do the inferred `LoopConfig['collection']` and `MapConfig['collection']`, which lose `string`. `FlowCollectionSlotSchema` (`@objectstack/spec/automation`) is the contract both take. + + **Still accepted, unchanged.** An inline array at both, and on a body-less `loop` any `collection` its legacy form reads. + + ### The kit + + - **The contract.** `LoopConfigSchema.collection` and `MapConfigSchema.collection` take `FlowCollectionSlotSchema`: a list or a CEL value envelope, so the executor's contract parse refuses a string as a guard, in the words above. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS`' `loop.collection` and `map.collection` rows take the `value` role, and `loop.collection` is read only on a `loop` with a `body` (`onlyWithKey`). The `loop` and `map` descriptors declare the slot with `xExpression: 'value'`. The single-brace `flow-template` role, which recorded these two slots and no other, is gone from `FlowNodeExpressionRole`, so a descriptor marker `'template'` fails the reconciliation ratchet as unknown. + - **The executors.** The `loop` and `map` nodes evaluate an envelope through `AutomationEngine.evaluateValueEnvelope`, as every value slot does, and iterate an inline array as written. A value that is not a list fails the node, naming what it evaluated to and its source. + - **The lint.** `validate-flow-template-paths` reads a loop iterator's object off an envelope `collection` that names a variable, as it read the token before. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 615c696: Each operand of a `get_record`, `update_record` or `delete_record` node's `filter` is a value slot now. A field's value, an operator's comparand and each element of a list comparand take a CEL value envelope, `{ id: { dialect: 'cel', source: 'record.id' } }`, which the executor evaluates in the run's scope before the query, or a literal handed to the query as written. A query placeholder — `{current_user_id}`, `{current_org_id}`, `{today}`, `{30_days_ago}` and the rest of the date macros — is such a literal: the query engine resolves it, and it passes untouched, as before. Every other single-brace `{…}` token at an operand is refused with its envelope: at `objectstack validate`, at `registerFlow`, at the metadata save door and by the executor's parse. An operand envelope that evaluates to `null` refuses the node, as a token that resolved to nothing did. + + Clause-②: yes (narrowing: a flow {…} token at a get_record, update_record or delete_record filter operand is refused, a whole-string query placeholder excepted, an object naming a string dialect at an operand must be a valid CEL value envelope, an operand envelope that evaluates to null refuses the node, and @objectstack/lint no longer exports validateFlowFilterTokens, FLOW_FILTER_TOKEN_UNKNOWN or FlowFilterTokenFinding and retires the flow-filter-token-unknown rule id; widening: a filter operand gains the CEL value envelope arm, the exported FlowNodeExpressionPath type gains the optional filterOperands field, ValueSlotTemplateOptions gains filterOperand, FLOW_NODE_EXPRESSION_PATHS gains the three filter rows, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains get_record and delete_record, the published GetRecordConfig, UpdateRecordConfig and DeleteRecordConfig JSON Schemas mark filter as a value slot, and flowFilterOperands, FlowFilterOperand, classifyFlowFilterOperandString and FlowFilterOperandStringKind are new exports) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL — its contract table names "filter values" — and Decision 3 deletes the single brace. A CRUD filter was the last value position that read the single-brace dialect: the executor interpolated the whole filter, resolving a flow token against the run's variables and handing a token no variable resolved on to the query engine when it was a query placeholder. Two `{…}` vocabularies met in one string. The query placeholders are the query-filter surface, which ADR-0032 puts out of its scope, so they stay; the flow token goes. + + **One classifier.** `classifyFlowFilterOperandString` (`@objectstack/spec/automation`) draws the line: a string that is one whole wrapped token the query engine resolves on the server (`isKnownFilterToken`) is a placeholder and passes; any other string carrying a `{…}` token is refused; the rest is a literal. The node contracts, `registerFlow`, `objectstack validate` and the executor's parse all read it, through the value-slot judge. + + **What an operand is.** The operands are the positions a value sits at, read the way the filter grammar reads a `FilterCondition` (`flowFilterOperands`): a field's value, each entry of an operator map (`$gt`, `$in`, `$between`, …), each element of a list comparand, inside `$and`, `$or` and `$not` and inside a nested-relation condition. An envelope there is one operand, and may evaluate to a list (`{ id: { $in: { dialect: 'cel', source: 'record.ids' } } }`). The filter's keys, operators and combinators are its structure, never evaluated. The top-level `filter` is never an operand. + + **What changes where a value may be absent.** The template erased a condition whose token resolved to nothing, and the node was refused. Under CEL an absent variable or key fails the run before any query, naming it, and an envelope that evaluates to `null` — a guarded form, a field that holds no value — refuses the node with the guard's message: a condition whose comparand is `null` or nothing does not narrow a query, it widens it. So route around the node with a `decision` where the value may be absent, rather than guarding it to `null`. To match an empty field, write the literal `null` (or `{ $null: true }`), never a computed one. + + **A flow variable named like a placeholder no longer shadows it.** Under the template a flow variable named `today` or `current_user_id` won over the placeholder of the same name. No flow token is read at an operand now, so the placeholder is always the query engine's; read the variable with an envelope, `{ dialect: 'cel', source: 'today' }`. No flow, variable or output variable in this repository is named like a placeholder. + + **A related field named `dialect`.** An object at a field's value that names a string `dialect` is read as an envelope. A nested-relation condition on a related object's field named `dialect` is written through an operator map, `{ rel: { dialect: { $eq: 'x' } } }`. No object in this repository declares such a field. + + **The lint rule `flow-filter-token-unknown` retires.** It reported a `{FUNC()}` token neither the flow template dialect nor the query placeholders define at a flow filter. Every token it reported is refused now by the value-slot judge at the same position and severity, with the envelope that replaces it, so the rule, its `os explain` entry and its exports (`validateFlowFilterTokens`, `FLOW_FILTER_TOKEN_UNKNOWN`, `FlowFilterTokenFinding`) are removed rather than left reporting the same defect a second time with the retired dialect's remedy. A consumer moves to the value-slot judge's findings: rule id `flow-filter-token-unknown` → `expression-invalid` (`EXPRESSION_INVALID`); `validateFlowFilterTokens(stack)` → `validateStackExpressions(stack)`, which reports each refused operand located at its node and `config.filter.`; `FlowFilterTokenFinding` → `ExprIssue`; `FLOW_FILTER_TOKEN_UNKNOWN` → `EXPRESSION_INVALID`. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `filter: { id: '{record.id}' }` | `filter: { id: { dialect: 'cel', source: 'record.id' } }` | an absent `record.id` fails the run before the query instead of refusing the node | + | `filter: { amount: { $gt: '{record.cap}' } }` | `filter: { amount: { $gt: { dialect: 'cel', source: 'record.cap' } } }` | a `null` cap refuses the node, as before | + | `filter: { id: { $in: ['{a}', '{b}'] } }` | `filter: { id: { $in: [{ dialect: 'cel', source: 'a' }, { dialect: 'cel', source: 'b' }] } }`, or one envelope evaluating to the list | — | + | `filter: { owner: '{$User.Id}' }` | `filter: { owner: { dialect: 'cel', source: 'current_user.id' } }` | in a run with no user `current_user` is `null` and the read fails: skip the node with a `decision` on `current_user != null` | + | `filter: { due: { $lt: '{TODAY() - 3}' } }` | `filter: { due: { $lt: { dialect: 'cel', source: 'isoDate(daysAgo(3))' } } }` | none for a whole number of days | + | `filter: { owner: '{current_user_id}' }`, `{ due: { $lt: '{today}' } }` | unchanged | a query placeholder stays the query engine's | + | `filter: { owner: '{today}' }` meaning a flow variable named `today` | `filter: { owner: { dialect: 'cel', source: 'today' } }` | the placeholder no longer shadows the variable | + + **The one-line fix: write each filter operand the flow computes as a CEL value envelope, and keep the query placeholders as they are.** + + **Who is affected, measured.** In this repository, at the base: seven flow tokens at a filter operand in the shipped examples (`app-crm` 2, `app-showcase` 3, `app-todo` 2 — `app-todo`'s two query placeholders stay), five in the docs (`getting-started/common-patterns`, `kernel/runtime-services/examples` 2, the `service-automation` README 2), the flows guide's and the spec's own remedy texts (a `$User.Email` read named `filter: { id: '{uid}' }`; a `recordId` refusal named `filter: { id: '{record.id}' }`) and the `FlowSchema` example; all are migrated in this change. hotcrm was not measured. + + **Still accepted, unchanged.** Every literal operand, every query placeholder, a filter's keys, operators and combinators, an empty filter, and a filter on `create_record`, which has none. The `filters` alias still converts to `filter` at load. + + ### The kit + + - **The contract.** `GetRecordConfigSchema`, `UpdateRecordConfigSchema` and `DeleteRecordConfigSchema` judge each filter operand: an envelope must be a valid CEL value envelope, and a `{…}` token in a literal is refused by the value-slot judge in its filter mode, naming the operand's envelope and what `null` does there. Each `filter`'s `describe()` and TSDoc is restated, and so are the `recordId` guidance and the run-user remedy, which named a single-brace filter value. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains a `filter` row per node, with the new `filterOperands` flag: both walks emit each operand at its own path (`filter.amount.$gt`, `filter.id.$in[0]`), never the filter itself. `GetRecordConfigSchema` and `DeleteRecordConfigSchema` join `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`. + - **The executor.** The CRUD executors parse their config as authored, evaluate each operand envelope through `AutomationEngine.evaluateValueEnvelope` before the query, hand every other operand on as written, and refuse the node — as a guard a `fault` edge cannot route — when an operand evaluates to `null`. The single-brace `interpolateFilter` is deleted. + - **The lint.** `objectstack validate` refuses a token at each operand position and judges an operand envelope as CEL through the shared judges; the wrong-syntax hints at a filter operand name its envelope; `flow-filter-token-unknown` retires. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 968acc8: An `http` node reads each config key by what it is now. Its `url` and every `headers` value are text slots, like a notify `title`: they render `{{ }}` holes, `'https://api.example.com/v1/orders/{{ record.id }}'`. Its `body` is a value slot: a CEL value envelope, `{ dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }`, that the executor evaluates in the run's scope to the payload, or a literal sent as written. Its `method`, `durable`, `timeoutMs` and `signingSecret` are literals, read as written. A single-brace `{…}` token at any of them is refused, naming its `{{ }}` spelling at a text slot, its CEL spelling in the body, and at a literal the remedy to write the value: at `objectstack validate`, at `registerFlow` and by the executor, except at `signingSecret`, which the build doors leave to the run because the flow credential store may hold the secret it signs with, and which the executor's parse refuses there. + + Clause-②: yes (narrowing: a {…} token is refused at http url, each headers value, any string of a literal body, method, durable, timeoutMs and signingSecret, and as a whole headers map, and a body value that names a dialect must be a valid CEL value envelope; widening: the exported FlowNodeTextSlot type gains the optional eachValue field, LEDGER_DECLARED_NODE_CONFIG_SCHEMAS gains http, and the published HttpConfig JSON Schema marks body as a value slot) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a url and a header are text. Until now the executor handed its whole config to the single-brace interpolator and parsed the result, so every key read the retired dialect, a whole token in a typed key (`timeoutMs: '{t}'`) resolved to its value's type, and a CEL envelope written as the body was sent to the endpoint as the object it spells. + + **The request is rendered once.** The executor parses the config as authored — after the flow credential store's `signingSecret`, unchanged — then renders the url and the headers and evaluates the body, before either mode. The durable outbox enqueues exactly the request the inline call would send, and nothing renders at delivery. + + **What changes where a value may be absent.** A url that renders to no text refuses the node, as an empty one did. A header that renders to no text is not sent, where the template sent it empty. In the body an absent variable or key fails the run under CEL, naming it, where the template wrote `null` for a whole token and nothing inside text; guard a value that may be unset, `has(record.owner) ? record.owner : null`. CEL holds a map's values to one type, so in a CEL map literal a literal or a text beside a field read is wrapped in `dyn(…)`. + + **The literals.** `method`, `durable`, `timeoutMs` and `signingSecret` passed through the interpolator too, but no shipped flow computed one: the census found no `{…}` token at any of them in this repository's examples, docs or dogfood fixtures, only in the tests that pinned the interpolation itself. So no value slot is built there. They are read as written, and a `{…}` token in one is refused with the literal's remedy; an envelope there is refused too, since nothing evaluates it. A token written as the whole `headers` map, which the template resolved to a map, is refused the same way. A signing secret that arrived through a template (`signingSecret: '{signing_key}'`) is refused: write the secret itself, which a flow saved through the metadata API keeps in the write-only flow credential store, or `''` to send unsigned on purpose. + + **What does not change.** Every literal is sent as written. The method still defaults to `GET` inline and `POST` durable. An authored `signingSecret: ''` still sends unsigned, and a secret the credential store holds still takes the literal's place. A url or header with no token, and a body with no token, behave as before. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `url: 'https://api.example.com/v1/orders/{record.id}'` | `url: 'https://api.example.com/v1/orders/{{ record.id }}'` | a `Date` in the url renders as its ISO text, not JSON-quoted | + | `headers: { 'X-Ref': 'r-{record.id}' }` | `headers: { 'X-Ref': 'r-{{ record.id }}' }` | a header that renders to no text is not sent | + | `body: { id: '{record.id}', status: 'done' }` | `body: { dialect: 'cel', source: "{'id': record.id, 'status': dyn('done')}" }` | an absent `id` fails the run instead of sending `null` | + | `body: { text: 'Done: {record.title}' }` | `body: { dialect: 'cel', source: "{'text': 'Done: ' + record.title}" }` | an absent `title` fails the run instead of sending `'Done: '` | + | `body: '{payload}'` | `body: { dialect: 'cel', source: 'payload' }` | an absent `payload` fails the run instead of sending no body | + | `timeoutMs: '{timeout}'`, `durable: '{flag}'`, `method: '{verb}'` | the value itself: `timeoutMs: 5000`, `durable: true`, `method: 'POST'` | the key is a literal now; nothing computes it | + | `headers: '{headers}'` | the map itself: `headers: { 'X-Ref': '{{ record.id }}' }` | a map is not computed into `headers` | + | `signingSecret: '{signing_key}'` | the secret itself, or `''` to send unsigned | a secret read from a flow variable is refused at the run | + + **The one-line fix: double the braces in `url` and the `headers` values, write `body` as one CEL value envelope that builds the payload, and write `method`, `durable`, `timeoutMs` and `signingSecret` as the values themselves.** + + **Who is affected, measured.** In this repository, at the base: the showcase example (three bodies holding four tokens) and the flows guide (one body); both are migrated in this change. No url, header, method, `durable`, `timeoutMs` or `signingSecret` in the examples, docs or dogfood fixtures carries a token. hotcrm was not measured at these keys. + + **Newly refused in the body.** An object naming a `dialect` was data before and was sent as written; it is an expression now, evaluated per run, and a malformed one is refused. An envelope nested inside a literal body is still data, sent as written. + + **Still accepted, unchanged.** Every literal at these keys, and the published shapes of `url` (a string), `headers` (a map of strings), `method`, `durable`, `timeoutMs` and `signingSecret`. + + ### The kit + + - **The contract.** `HttpConfigSchema`: `url` and each `headers` value refuse a single-brace token through the text-slot judge; `body` takes `FlowValueSlotSchema`; `method`, `durable`, `timeoutMs` and `signingSecret` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. The token refusals at `method`, `durable`, `timeoutMs` and a whole `headers` map are marked read-as-written, so the build doors' value arm reports them at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains the `http` body row. `FLOW_NODE_TEXT_SLOTS` gains `http.url` and `http.headers`, whose new `eachValue` flag reads each value of the map at its own path (`headers.X-Ref`). `HttpConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the body's marker to the reconciliation ratchet. The build doors' carve-out for a config parsed after interpolation is gone with the interpolation. + - **The executor.** The `http` node parses its config as authored, renders the url and headers through the text renderer, evaluates the body through the shared value-slot resolver, and reads the literals as written; it no longer imports the single-brace interpolator. + - **The lint.** The `flow-double-brace-interpolation` hint names the http url and headers among the text slots, and at an http literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract. + - **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused`, `flow-text-slot-unbound-dollar-root-refused` and `flow-builtin-node-config-values-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 111ffff: A `notify` node's computed values are value slots now: `recipients`, `sourceId` and the values of `templateData` take a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, that the executor evaluates in the run's scope, or a literal written as it is. `recipients` holds one recipient or a list of them, each entry a string or an envelope, and an entry that evaluates to a list adds each of its recipients. The node's link, `actionUrl`, is a text slot like `title`: it renders `{{ }}` holes, `'/crm_case/{{ record.id }}'`. Its `sourceObject`, `actorId` and `payload` are literals, read as written: no flow computed one, so nothing is evaluated there. A single-brace `{…}` token at any of these positions is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling at a value slot, its `{{ }}` spelling at `actionUrl`, and, at a literal, the remedy to write the value. + + Clause-②: yes (narrowing: a {…} token is refused at notify recipients (the value or a list entry), templateData values, sourceId, actionUrl, sourceObject, actorId and any string of a payload value, a templateData value that names a dialect must be a valid CEL value envelope, and a CEL value envelope written as a payload value is refused; widening: recipients and sourceId accept a CEL value envelope they refused, a recipients list accepts envelope entries, and their published JSON Schema and inferred types gain the envelope arm) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (four positions accept a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace; a link with holes is text. Until now the executor ran each of these positions through the single-brace interpolator, and one of its answers reached nobody: a whole token holding a list, written as an entry of a `recipients` list — `recipients: ['{owners}']` with `owners = ['u1', 'u2']` — was handed to the messaging service as ONE recipient, `'u1,u2'`, and the run reported success. A token holding a record there became the recipient `'[object Object]'`, and so did one in a link or a `sourceId`. + + **What changes where a value may be absent.** A recipient envelope that evaluates to `null` or a blank string adds no recipient, as a token that resolved to nothing did, and a node left with none fails, naming the expressions that evaluated to nothing. An absent variable or key fails the run under CEL, naming it, where the template dropped the recipient; guard a field that may be unset, `has(record.assignee) ? record.assignee : null`. A relation hop on a scalar id (`record.account.owner` without the start node's `expand`) fails the evaluation itself, naming the key it could not read, where the template resolved it to nothing. `sourceId` must evaluate to a string; `null` leaves the key unset, and a half-specified click-through pair is still dropped. A recipient, or a `sourceId`, that evaluates to anything else — a map, a number — fails the node, naming what it evaluated to. + + **The literals.** `sourceObject`, `actorId` and the values of `payload` passed through the single-brace interpolator too, but no flow computed one: the census found no `{…}` token at any of them, in this repository or in hotcrm. So no value slot is built there. They are read as written, and a `{…}` token in one — in any string of a `payload` value, at any depth — is refused with the literal's remedy: a single-brace token is not resolved here; write the value. An envelope written there is refused too, since nothing evaluates it (a deeper one inside a `payload` value is data, as it always was). + + **What does not change.** A literal at every value slot is written as it is: a recipient string (`'team:ops'`, a user id, an email address) is one recipient, as before, and is not split. `template`, `topic`, `channels` and `severity` are read raw, as before. An explicit `actionUrl` still wins over the link synthesized from `sourceObject` / `sourceId`, and a link that renders no text yields to it, as an empty one did. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `recipients: '{record.owner}'` | `recipients: { dialect: 'cel', source: 'record.owner' }` | an absent `owner` fails the run instead of leaving the node without a recipient | + | `recipients: ['{record.assignee}']` where it may be unset | `recipients: [{ dialect: 'cel', source: 'has(record.assignee) ? record.assignee : null' }]` | none: the `null` is dropped, as the unresolved token was | + | `recipients: ['{owners}']` (a list variable) | `recipients: [{ dialect: 'cel', source: 'owners' }]` | each of its recipients is notified, where the template sent one joined string | + | `recipients: ['{$User.Id}']` | `recipients: [{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }]` | none: a run with no user adds no recipient | + | `sourceId: '{record.id}'` | `sourceId: { dialect: 'cel', source: 'record.id' }` | an absent id fails the run instead of dropping the link | + | `sourceObject: '{record.object_name}'`, `actorId: '{record.owner}'`, `payload: { who: '{record.owner}' }` | the value itself: `sourceObject: 'crm_case'` | the key is a literal now; nothing computes it | + | `templateData: { name: '{record.name}' }` | `templateData: { name: { dialect: 'cel', source: 'record.name' } }` | the template gets `null` (a guard's answer) where the key was absent | + | `actionUrl: '/crm_case/{record.id}'` | `actionUrl: '/crm_case/{{ record.id }}'` | a `Date` in the link renders as its ISO text, not JSON-quoted | + + **The one-line fix: write each `notify` value position as a CEL value envelope, `{ dialect: 'cel', source: 'record.owner' }`, double the braces in `actionUrl`, and write `sourceObject`, `actorId` and `payload` as the values themselves.** + + **Who is affected, measured.** In this repository, at the base: the showcase example (16 recipient strings, 7 `sourceId`s and 4 links), the todo example (2 recipients and 2 `sourceId`s), the flows guide, the email-templates and common-patterns guides, two dogfood fixtures and test fixtures; all are migrated in this change, and a recipient field that may be unset is guarded. hotcrm at `1d7148bf2d` writes 19 notify nodes in 15 flow files, each with one `{…}` recipient (2 of them `{$User.Id}`), 35 `{…}` `templateData` values and a link with holes; it is on spec 17.7.0, so nothing there moves until it upgrades. + + **Newly accepted.** A CEL value envelope at `recipients` (the value, or each entry of a list) and `sourceId`: their published JSON Schema gains the envelope arm, and so do the inferred `NotifyConfig` types. At a `templateData` key an object naming a `dialect` was data before; it is an expression now, evaluated per run, and a malformed one is refused. `sourceObject`, `actorId` and `payload` keep their published shapes (a string, a string, a map of any values), with no envelope arm. + + **Newly refused at the literals.** A `{…}` token in `sourceObject`, in `actorId` or in any string of a `payload` value, and a CEL value envelope written as a `payload` value, which was handed on as the object it spells. + + **Still accepted, unchanged.** Every literal at these positions. + + ### The kit + + - **The contract.** `NotifyConfigSchema`: `recipients` takes a string, a list of strings and envelopes, or one envelope; `templateData` takes `FlowValueSlotSchema` per key; `sourceId` takes a string or an envelope; `actionUrl` refuses a single-brace token through the text-slot judge; `sourceObject`, `actorId` and `payload` keep their literal types, and the contract refuses a token or an envelope there with the literal's remedy. That refusal is marked read-as-written, so the build doors' value arm reports it at `FlowSchema.parse`, `objectstack validate` and `registerFlow` as the executor's parse does. Each `describe()` is restated. + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains three `notify` value rows. `recipients` carries `eachElement`: a list is read entry by entry, at its index (`recipients[1]`). `FLOW_NODE_TEXT_SLOTS` gains `notify.actionUrl`. `NotifyConfigSchema` joins `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS`, which carries the markers to the reconciliation ratchet. + - **The executor.** The `notify` node evaluates each envelope through the shared value-slot resolver, renders `actionUrl` through the text renderer and reads the literals as written; it no longer imports the single-brace interpolator. + - **The lint.** The `flow-double-brace-interpolation` hint names `actionUrl` among the text slots, and at a notify literal both wrong-syntax hints say that neither brace dialect resolves there and name the value itself; `objectstack validate` refuses a token at each position through the value-slot and text-slot judges and the contract. + - **ADR-0087.** The step-18 D3 entries `flow-value-slot-template-dialect-refused`, `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` are amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- 12b9daf: A `screen` node's three positions that hand a value to the client are value slots now: an object form's `defaults` (each field's prefill), a field's `defaultValue`, and an object form's `recordId` (the record an `edit` form opens). Each value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`, that the executor evaluates in the run's scope before the screen goes on the wire, so the client receives the value it computes, never the envelope; or a literal, served as written. A `{…}` template token there is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL spelling, as in every other value slot. `recordId` gains the envelope arm, which it refused before this change, and its literal stays a string. + + Clause-②: yes (narrowing: a {…} token is refused at screen.defaults.*, a field's defaultValue and screen.recordId; widening: screen.recordId accepts a CEL value envelope it refused, and its published JSON Schema and inferred type gain the envelope arm) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, beside one widening (`recordId` accepts a CEL value envelope), shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 2 makes a computed value whole-field CEL, and Decision 3 deletes the single brace. A screen's prefill and its record id are computed values handed to the client. Until now the executor ran each through the single-brace interpolator: a `{token}` resolved there, an envelope written in `defaults` or a `defaultValue` went to the client as the object `{ dialect: 'cel', source: '…' }` it spells, and `recordId` was the text of whatever the token found, so a token that found a record served the id `[object Object]`. + + **What changes where a value may be absent.** Where a whole token resolved to nothing, the template served nothing there. Under CEL an absent variable or key fails the run at the screen, with its source, and the guarded form `has(vars.x) ? vars.x : null` serves `null`: + + - In an object form's `defaults`, the form keeps a `null` prefill as a value and opens the field blank, over the object field's own `defaultValue`, which it applied when the prefill left the field out. To keep that default for an absent value, write it in the guard: `has(vars.x) ? vars.x : 'prospecting'`. + - A field's `defaultValue` seeds the field with `null`. + - `recordId` must evaluate to the record's id, a non-blank string. Anything else, `null` included, fails the run at the screen. The template served such a screen with no record id, and its `edit` form opened with no record and could not save. Where the record may be absent, route around the screen with a `decision` on the value instead of guarding the id. + + The refusal at each position says what it serves. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `defaults: { account: '{account_id}' }` | `defaults: { account: { dialect: 'cel', source: 'account_id' } }` | an absent `account_id` fails the run; guarded, it prefills `null` and the field opens blank over the object field's `defaultValue` | + | `defaults: { stage: '{x}' }` where the object field's default should apply when `x` is absent | `defaults: { stage: { dialect: 'cel', source: "has(vars.x) ? vars.x : 'prospecting'" } }` | the default is written in the guard, because a `null` prefill is a value | + | `defaults: { parent: '{rec}' }` | `defaults: { parent: { dialect: 'cel', source: 'rec' } }` | the record is served as a map, a list as a list | + | `fields: [{ name: 'email', defaultValue: '{lead.email}' }]` | `fields: [{ name: 'email', defaultValue: { dialect: 'cel', source: 'lead.email' } }]` | guarded, it prefills `null` | + | `recordId: '{record.id}'` | `recordId: { dialect: 'cel', source: 'record.id' }` | the envelope must evaluate to a non-blank string id: `null`, a map or a list fails the run at the screen | + | `recordId: 'acc_1'` | unchanged | a string literal is the id | + + **The one-line fix: write each value of a screen's `defaults`, each field's `defaultValue` and an object form's `recordId` as a CEL value envelope; write the object field's default into the guard where it should apply, and route around a screen whose record may be absent.** + + **Who is affected, measured.** In this repository: the CRM example's lead conversion (`crm_convert_lead_wizard`: 3 `defaults` values in its two object-form steps), the object-form example in the flows guide, and test fixtures; all are migrated in this change. hotcrm's sites at these positions were not measured. + + **Newly accepted.** A CEL value envelope at `recordId`, which `z.string()` refused before this change: the published JSON Schema for `recordId` gains the envelope arm (a string, or the envelope object), and so does the inferred `ScreenConfig['recordId']`. + + **Still accepted, unchanged.** A CEL value envelope at `defaults` and at a field's `defaultValue`, and every literal at all three, a string `recordId` included. + + ### The kit + + - **The contract.** `ScreenConfigSchema.defaults` and a field's `defaultValue` take `FlowValueSlotSchema`, and `recordId` takes a string or a CEL value envelope (`@objectstack/spec/automation`), so the executor's contract parse refuses a token as a guard. Each published JSON Schema declares the dropped refinement (`dropped-refinements.baseline.json`). + - **The ledger.** `FLOW_NODE_EXPRESSION_PATHS` gains `screen.defaults.*`, `screen.fields[].defaultValue` and `screen.recordId` (role `value`), and `LEDGER_DECLARED_NODE_CONFIG_SCHEMAS` carries `ScreenConfigSchema`. `registerFlow`, `objectstack validate` and `flow-bare-dollar-reference` / `flow-double-brace-interpolation`'s value-slot hints follow the ledger, so all of them cover the three positions. The build doors keep refusing a `recordId` literal that is not a string, as before. + - **The executor.** The `screen` node resolves each position through the value-slot resolver the CRUD `fields` map and a callee's input share: an envelope is evaluated by `AutomationEngine.evaluateValueEnvelope` in the run's scope, before the screen is served. A screen that passes through without pausing goes on no wire, and evaluates nothing. + - **ADR-0087.** The step-18 D3 entry `flow-value-slot-template-dialect-refused` is amended. No key is removed, so there is no tombstone, and there is no D2 conversion. +- d85615d: A flow VALUE slot now refuses the date macros as well — `{TODAY()}`, `{NOW()}`, with an optional `± N` day offset — so it reads no single-brace `{…}` token at all. Each is refused at `objectstack validate`, at `registerFlow` and by the executor, naming its CEL string form: `isoDate(today())`, `isoDate(daysFromNow(N))` / `isoDate(daysAgo(N))`, `isoDatetime(now())`, `isoDatetime(addDays(now(), N))`. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The template dialect is retired from the value slots: one dialect per slot, with a remedy for every spelling. The date macros were the last spelling kept, because CEL had no string form for a timestamp: `today()` and `now()` reach the data engine as a `Date` object, not the ISO text the macros wrote. The CEL stdlib now has one — `isoDate(t)` (`YYYY-MM-DD`) and `isoDatetime(t)` (`YYYY-MM-DDTHH:mm:ss.sssZ`), on the UTC calendar the macros rendered on — so the macros can be refused with a remedy that writes the same text. + + **What is refused.** In every value slot — `create_record` / `update_record` `fields`, the `assignment` node's `assignments` map, and both legacy `assignment` shapes — a string carrying a date macro, alone or beside other text and tokens. A string that mixed a date macro with another token used to be kept whole, the run-user token included (`'Due {TODAY()} by {$User.Id}'`); it is refused now, with a remedy for every token. + + **Where the remedy parts from the template.** For no offset or a whole number of days the CEL form writes the template's text byte for byte, at every instant (measured live through both engines over DST transitions, month and year ends and a leap day). The refusal names the three places it does not: + + - a fractional offset: the template added it to the day of the month and truncated the sum, `addDays` truncates the offset itself — going back, a day apart on most days of a month — and `daysFromNow(1.5)` / `daysAgo(1.5)` are refused at build; + - a variable offset (`{TODAY() + days}`): the template looked the offset up as one variable name and added 0 days, without a word, when it found none or the value was not a number; CEL reads the path, and fails the run on an absent variable or a value that is not a number; + - an offset that is neither a number nor a variable name (`{TODAY() + 3d}`): the template added 0 days. + + **Where an envelope is literal data, the remedy names none there.** The refusal used to prescribe an envelope at whatever position it refused a token. Inside an object or list value, and in the legacy `assignment` shapes, the executor reads an envelope as data: the prescribed metadata registered and stored the envelope object. The refusal there now names what evaluates — the whole value built as one CEL map or list literal (mixed values each wrapped in `dyn(…)`), or the assignment moved into the `assignments` map. This holds for every token, not only the date macros. An envelope-shaped object in those positions is still data, unchanged. + + **The `has()` guard a refusal names now holds for an absent variable.** For a path that may be absent, the refusal named a guard on its last key, `has(source.id) ? source.id : null`. `has()` evaluates everything but its last selection, so where the variable itself was never bound — an `isInput` variable the caller left out, with no default — that guard failed the run (`Unknown variable: source`). The guard now tests each step off `vars`, which holds only the variables the run has bound: `has(vars.source) && has(vars.source.id) ? vars.source.id : null` answers `null` there, and where a key on the way is absent or `null`. `flow-bare-dollar-reference`'s value-slot hint carries the same guard. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{TODAY()}'` | `{ dialect: 'cel', source: 'isoDate(today())' }` | nothing | + | `'{TODAY() + 7}'`, `'{TODAY() - 3}'` | `{ dialect: 'cel', source: 'isoDate(daysFromNow(7))' }`, `{ dialect: 'cel', source: 'isoDate(daysAgo(3))' }` | nothing for a whole number of days; a fraction truncates differently, and `daysFromNow` / `daysAgo` refuse one at build | + | `'{NOW()}'` | `{ dialect: 'cel', source: 'isoDatetime(now())' }` | nothing | + | `'{NOW() + 2}'`, `'{NOW() - 1}'` | `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), 2))' }`, `{ dialect: 'cel', source: 'isoDatetime(addDays(now(), -1))' }` | nothing — `addDays` keeps the time of day, where `daysFromNow` would land on midnight | + | `'{TODAY() + days}'` | `{ dialect: 'cel', source: 'isoDate(addDays(today(), days))' }` | an absent `days`, or one that is not a number, fails the run where the template silently added 0 | + | `'Due {TODAY()} by {$User.Id}'` | `{ dialect: 'cel', source: "'Due ' + isoDate(today()) + ' by ' + current_user.id" }` | guard the run user where a flow can run without one | + | `payload: { due: '{TODAY()}' }` (a string inside an object value) | `payload: { dialect: 'cel', source: "{'due': isoDate(today())}" }` | an envelope written at `payload.due` would be stored as an object | + | the guard an earlier refusal named, `has(source.id) ? source.id : null` | `has(vars.source) && has(vars.source.id) ? vars.source.id : null` | `null` where `source` was never bound, where the last-key guard failed the run | + | a text slot's `assignments: { due: '{TODAY() + 7}' }`, then `'Due {{ due }}'` | `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' } }`, then `'Due {{ due }}'` | nothing for a whole number of days | + + **The one-line fix: write a date macro as `isoDate(…)` / `isoDatetime(…)` in a CEL envelope, with a whole number of days.** + + **Who is affected, measured.** This repository's examples carry no date macro in a value slot; its own test fixtures and docs that did are migrated in this change. The census taken for this retirement's first change measured 15 date-macro value-slot sites in hotcrm at `c529de2` (8 `{NOW()}`, 3 `{TODAY()}`, 3 `{TODAY() + N}`, 1 `{TODAY() + var}`); they migrate there. Deployed metadata and other repositories were not measured here. + + **Still accepted, unchanged.** A CEL value envelope and every literal. + + ### The kit + + - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`) keep no token; the text-slot judge's date-macro remedy (`textSlotTemplateRefusal`) names the CEL envelope. `FlowValueSlotSchema` / `AssignmentValueSchema` say so in their descriptions. + - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the date macros and name their forms) and `flow-text-slot-single-brace-refused` (its date remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. + - **`@objectstack/lint`.** `flow-double-brace-interpolation`'s hint in a value slot names the CEL envelope for the holes the author meant, where it named `{record.title}`, which the slot refuses; `flow-bare-dollar-reference`'s value-slot hint asks the judge at the string's own position, so a nested or legacy-shape string gets the spelling that evaluates there, and carries the judge's step-by-step `has()` guard. + - **`@objectstack/service-automation`'s README.** Its *Expressions* section names the date macros' CEL forms. +- 6a06e3b: A flow VALUE slot now refuses the run-user template token `{$User.}` as well, and every CEL expression in a flow sees **`current_user`**, the run's user. `{$User.Id}` in a `create_record` / `update_record` `fields` value or an `assignment` value is refused at `objectstack validate`, at `registerFlow` and by the executor, naming `current_user.id` and, for a flow that can run without a user, its guard; every other `$User` path is refused saying it never resolved. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The template dialect is retired from the value slots, one dialect per slot, with a remedy for every spelling. The run user was the one path spelling kept, because the flow CEL scope bound no user: `current_user.id` passed `objectstack validate` and `registerFlow`, then failed the run with `Unknown variable: current_user`. The scope now binds it, so the token can be refused with a remedy that evaluates. + + **What `current_user` is.** In every flow CEL expression — a start-node `condition`, an edge `condition`, a `decision` condition, a screen field's `visibleWhen`, an `assignment` or `fields` value envelope — `current_user` is the run's user, built through `createEvalUser` from what the run context holds: `id` (the run's `userId`), `positions`, `organizationId` (the run's `tenantId`) and the derived `isPlatformAdmin`. No run carries an email or a name, so neither is bound. A run with no user — a schedule, a record change made by a system write — sees `null`, never a stand-in user: `current_user.id` then fails the run loudly, and `current_user != null` can guard it. A `runAs: 'system'` run sees the user that triggered it, as `{$User.Id}` did. A flow variable named `current_user` is shadowed by the binding, as one named `vars` is by the variables namespace; both are read as `vars["current_user"]` / `vars["vars"]`, and the refusal of a `{vars.…}` or `{current_user.…}` path token prints that spelling. + + **The expression remedy.** A refused `{…}` arithmetic token's CEL spelling now reads every variable path in it the way a lone path token's does — `{int * 2}` is `vars["int"] * 2`, `{items.0 * 2}` is `items[0] * 2` — instead of rewriting only its divisors, which printed envelopes that did not evaluate. + + **Still accepted, unchanged.** A CEL value envelope and every literal. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{$User.Id}'` | `{ dialect: 'cel', source: 'current_user.id' }` | nothing when the run has a user | + | `'{$User.Id}'`, in a flow that can run without a user | `{ dialect: 'cel', source: 'current_user != null ? current_user.id : null' }` — or skip the node with `current_user != null` as a start condition or a `decision` | the guarded form writes `null` where the template wrote nothing, so on `update_record` it clears a stored value the template left alone | + | `'{$User.Email}'`, `'{$User.Name}'`, any other `$User` path | a `get_record` on `sys_user` with `filter: { id: { dialect: 'cel', source: 'current_user.id' } }` and `outputVariable: 'me'`, then `{ dialect: 'cel', source: 'me.email' }` | these never resolved in any shipped run: they wrote nothing | + | `'Owner: {$User.Id}'` | `{ dialect: 'cel', source: "'Owner: ' + current_user.id" }` | in a user-less run, write the hole as `(current_user != null ? current_user.id : '')` | + | a text slot's `assignments: { by: '{$User.Id}' }`, then `'By {{ by }}'` | `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `'By {{ by }}'` | nothing when the run has a user; where the flow can run without one, compute `current_user != null ? current_user.id : null`, which renders nothing where the template did | + + **The one-line fix: write `current_user.id` where you wrote `{$User.Id}`, and guard it where the flow can run without a user.** + + **Who is affected, measured.** This repository's two authored value-slot sites are migrated in this change: the `examples/app-todo` quick-add screen flow (a screen flow always has a user, so the bare read) and the `os explain flow` catalog example, an `update_record` under a record-after-create trigger, which a system write fires with no user; it now gates on `current_user != null` in its start condition, so the stored value is left alone there as before. `examples/app-showcase`'s `{$User.Id}`, a `notify` `recipients` entry, is migrated to `current_user` with the `notify` value slots on this line. Other repositories and deployed metadata were not measured here. + + ### The kit + + - **The binding.** `AutomationEngine.celScope` (`@objectstack/service-automation`); `evaluateCondition` and `evaluateValueEnvelope` take the run context as an optional last argument, which every engine site and the `assignment`, `decision`, `create_record` and `update_record` executors pass. Without one, `current_user` is `null`. + - **The refusal.** `valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals` (`@objectstack/spec/automation`); the text-slot judge's run-user remedy (`textSlotTemplateRefusal`) now computes the id with the CEL envelope. + - **The ledger.** The step-18 D3 entries `flow-value-slot-template-dialect-refused` (amended to refuse the run-user paths), `flow-text-slot-single-brace-refused` and `flow-text-slot-unbound-dollar-root-refused` (their run-user remedy). No key is removed, so there is no tombstone, and there is no D2 conversion. + - **`@objectstack/cli`.** `os explain flow`'s example writes `current_user.id` and gates on `current_user != null`. + - **`@objectstack/lint`.** `flow-bare-dollar-reference`'s hint for a bare `$name.path` in a value slot names the CEL envelope the value-slot refusal writes for it — `current_user.id` and its guard for `$User.Id`, the variable the reference names for any other — where it named `{source.id}` and `{$User.Id}`, both refused there. A text slot's hint keeps the `{{ }}` hole, and the hint at a node's literal, or at any other position, names the value itself. + - **`@objectstack/service-automation`'s README.** Its *Expressions* section states the value-slot envelope and `current_user`. +- 0e9e7b7: A flow VALUE slot no longer reads the single-brace `{…}` template dialect: a `create_record` / `update_record` `fields` value or an `assignment` value that carries a `{…}` token is refused at `objectstack validate`, at `registerFlow` and by the executor, with the CEL spelling of each token. A computed value is a CEL value envelope, `{ dialect: 'cel', source: '…' }`; a string is the literal text it spells. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** Since #19938 the value slots also evaluate a CEL value envelope, so a flow had two dialects for one job — two function sets, two meanings of `/`, and a template that wrote nothing where CEL refuses. The maintainer's ruling D on the flow expression dialects put the template's retirement from these slots on the v18 train, with a remedy for every spelling and an automatic conversion only where one is lossless (ADR-0087 D2). + + **What is refused.** In every value slot — `create_record.fields.*`, `update_record.fields.*`, the `assignment` node's `assignments` map, and the two legacy `assignment` shapes the executor still reads (the `assignments: [{ variable, value }]` array and the bare `{ : }` config) — a string, or a string at any depth of an array or object value, that carries a `{…}` token the interpolator would resolve. One judge says it everywhere (`flowNodeValueTemplateRefusals` / `valueSlotTemplateRefusals` in `@objectstack/spec/automation`): `FlowValueSlotSchema`, `AssignmentValueSchema`, `CreateRecordConfigSchema`, `UpdateRecordConfigSchema` and `AssignmentConfigSchema` refuse it at the value's path; `objectstack validate` reports it as `expression-invalid` at `error`, located at the node and `config.`, which also refuses it at the metadata save door; `registerFlow` refuses the flow (a stored flow carrying one is skipped at boot with a warn naming it); and the `create_record` / `update_record` / `assignment` executors refuse the node before writing anything. Each refusal leads with `VALUE_SLOT_TEMPLATE_REFUSAL` and then names the CEL spelling of the string's tokens. + + **Not converted.** No D2 conversion rewrites any spelling: every token spelling authored in flows answers differently under CEL for some input (measured through both engines over the same variables, 13 of 25 probes the same, 12 different), so a rewrite would change what some flow writes. Where a value may be absent, whether the field should take nothing, `null` or a default was decided silently by the template; it is now the author's decision. + + **What is still accepted, unchanged.** A CEL value envelope and every literal (a token-free string, numbers, booleans, `null`, arrays, objects). A text slot (`notify` `title` / `message`, a screen `description`, …) reads `{{ }}` holes and refuses a single-brace token. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `'{record.owner}'`, `'{x}'` | `{ dialect: 'cel', source: 'record.owner' }` | CEL refuses an absent variable or key where the template wrote nothing — guard one that may be absent a step at a time from `vars`: `has(vars.record) && has(vars.record.owner) ? vars.record.owner : null`, `has(vars.x) ? vars.x : null` (writes `null`) | + | `'{items.0}'` | `{ dialect: 'cel', source: 'items[0]' }` | an empty list fails the run | + | `'{$error.message}'` | `{ dialect: 'cel', source: 'vars["$error"].message' }` | a `$`-named variable is read through `vars` | + | `'{round(x * 100) / 100}'` | `{ dialect: 'cel', source: 'round(x * 100) / 100.0' }` | CEL divides two integers as integers: keep a decimal operand on every division, or `123.46` becomes `123` | + | `'Renewal — {contract.number}'` | `{ dialect: 'cel', source: "'Renewal — ' + contract.number" }` | wrap a non-string hole in `string(…)`, one that may be null in `coalesce(…, '')` | + | braces meant literally, `'{"a": 1}'` | `{ dialect: 'cel', source: "'{\"a\": 1}'" }` | a CEL string literal | + + **The one-line fix: write the CEL spelling the refusal names, and guard a value that may be absent.** + + **Who is affected, measured.** A TypeScript-AST census of every `create_record` / `update_record` `fields` value and `assignment` value (all three shapes, same-file spreads included): this repository at `959c209d5` carried 21 authored sites (`examples/**` and `packages/verify/src`) — 20 refused by this change (9 bare references, 10 dotted paths, 1 `$error` path, all migrated in this change) and 1 `{$User.Id}` (`examples/app-todo`), which the run-user refusal on this line refuses and migrates; hotcrm at `c529de2` carries 91 (2 of them through a same-file spread) — 71 refused by this change (31 bare references, 36 dotted paths, 4 text with holes) and 20 that the date-macro and run-user refusals on this line refuse (15 date macros, 5 `{$User.Id}`). Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `automation/flow-value-slot-template.ts` (`VALUE_SLOT_TEMPLATE_REFUSAL`, `valueSlotTemplateRefusals`, `flowNodeValueTemplateRefusals`), composed into the value-slot contracts in `automation/builtin-node-config.zod.ts`; one new dropped-refinement site (`automation/AssignmentConfig` `out.catchall`, the bare legacy shape's values). + - **The doors.** `AutomationEngine.registerFlow` and the `create_record` / `update_record` / `assignment` executors (`@objectstack/service-automation`); `validateStackExpressions` (`@objectstack/lint`), whose `warning` hint pointing a template expression at the envelope this refusal replaces. + - **The ledger.** The D3 semantic entry `flow-value-slot-template-dialect-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: no authored spelling maps losslessly. +- a87d8be: The v18 line opens (ADR-0131; #22050). Every release from here is an `18.0.0-next.N` prerelease on the npm dist-tag `next`, until 18.0 GA. + + Clause-②: no + + - **What this marks.** Changesets is in pre mode with the tag `next`. Every `@objectstack/*` package versions together, so this one `major` takes all of them to 18, and the first version cut is `18.0.0-next.0`. `PROTOCOL_VERSION` follows `@objectstack/spec`'s major at version time, so it reads `18.0.0` from that prerelease on. The marker itself changes no code, key, export or stored shape. + - **What the line carries.** ADR-0131 (organization ownership is total: no NULL `organization_id`) lands on this line in stages. Each stage that breaks something says so in its own changeset, with its own migration. + - **Who receives it.** A plain install still resolves the dist-tag `latest`, which stays on 17.x until 18.0 GA. A prerelease is installed only on request: `@objectstack/spec@next`, or an exact `18.0.0-next.N`. + - **What ends it.** 18.0 GA exits pre mode and publishes to `latest`. + + +- 2b61f2d: A flow TEXT slot — a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message` — reads ADR-0032 §3's `{{ }}` template holes, rendered by the formula template engine over the flow's variables. A single-brace `{…}` token in one is refused at `objectstack validate`, at `registerFlow` and by the node's contract, with the `{{ }}` spelling of each token. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 3 fixes one template delimiter: "One delimiter, `{{ }}`; single `{ }` deleted." The notify pair was already typed as `template` slots, while the executor read the single brace, so a flow carried two template dialects and an author who met both mixed them. + + **What changes.** + + - **The renderer.** The text slots render through one renderer inside `@objectstack/service-automation` (package-internal; the package's public exports do not change): `{{ path }}` and `{{ path | formatter[:arg] }}` holes, every other character literal. A hole reads a flow variable by name (`{{ record.name }}`, a node output `{{ lookup.result }}`, an index `{{ rows.0.subject }}` or `{{ rows[0].subject }}`, and a `$`-named one: `{{ $error.message }}`). `null` and an absent path render nothing, an object or array renders as JSON, a `Date` as its ISO text; the ADR-0032 formatters make the rest explicit (`{{ amount | currency }}`, `{{ due | date:long }}`). A hole holds no logic. + - **The refusal.** One judge in `@objectstack/spec/automation` (`textSlotTemplateRefusal`, `flowNodeTextSlotSources`, `FLOW_NODE_TEXT_SLOTS`, `TEXT_SLOT_TEMPLATE_REFUSAL`): `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` refuse a single-brace token at the slot's key; `registerFlow` and `objectstack validate` (`expression-invalid`, `error`) refuse it with the same words and also compile every slot's holes, so `{{ a + b }}` or an unknown formatter is refused before a run. A stored flow carrying one is skipped at boot with a warn naming it. Past the doors, a hole that does not compile fails the node with a guard refusal, which a `fault` edge does not route. + - **`@objectstack/formula`.** A template hole's path may contain `$` (`{{ $error.message }}`): a widening of the hole grammar, so a host scope's `$`-named variable has a template spelling. An unbound one renders nothing, like any unknown path. + - **`flow-double-brace-interpolation`** no longer flags `{{ }}` on the text slots, and its hint names them as the only `{{ }}` positions; a bare `$ref.x` outside the holes of a text slot is flagged with the hole spelling. + - **Unchanged:** the value slots are CEL's. + + **Not converted.** No ADR-0087 D2 conversion rewrites `{x}` to `{{ x }}`. The two renderers were run over the same variables: a path renders the same text for a string, a number, a boolean, `null`, an absent key or variable, an ISO date string, an object or an array, but a `Date` value rendered JSON-quoted under the 17.x interpolator (`"2026-10-08T09:30:00.000Z"`, quotes included) and as its ISO text now, and a screen or `end` text that was one token holding an object, an array or a `Date` rendered `String(value)` (`[object Object]`). So the rewrite is the author's to check; the D3 record is the semantic entry `flow-text-slot-single-brace-refused`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `title: 'Deal won: {record.name}'` | `title: 'Deal won: {{ record.name }}'` | + | `message: 'Failed: {$error.message}'` | `message: 'Failed: {{ $error.message }}'` | + | `message: 'Total {amount * 2}'`, `'{round(x)}'` | compute it first — `assignments: { v: { dialect: 'cel', source: 'amount * 2' } }` — then `'Total {{ v }}'` (a number format is a formatter: `{{ v \| number:2 }}`) | + | `message: 'Due {TODAY() + 7}'`, `'By {$User.Id}'` | compute it first with an `assignment` node's CEL value envelope — `assignments: { due: { dialect: 'cel', source: 'isoDate(daysFromNow(7))' }, by: { dialect: 'cel', source: 'current_user.id' } }` — then `'Due {{ due }}'`, `'By {{ by }}'` | + + **The one-line fix: double the braces of every path token in a text slot (`{x}` → `{{ x }}`), and compute anything else into a variable first.** + + **Who is affected, measured.** This repository's in-tree text-slot sites — `examples/app-showcase` (24 strings, 30 tokens) and `examples/app-todo` (5 strings, 7 tokens), all of them paths — are rewritten in this change, with the docs pages that taught the single brace (`content/docs/automation/flows.mdx`, `content/docs/getting-started/common-patterns.mdx`). Other repositories and deployed metadata were not measured here. +- 4e9fe9f: feat(spec)!: `PROTOCOL_VERSION` is `18.0.0`; the runtime handshake accepts `engines.protocol: '^18'` and refuses `'^17'` + + Clause-②: yes (narrowing: the handshake refuses a manifest pinned to protocol 17 (`'^17'`, `'^17.x.y'`, `'17.x'`), while the exported `PROTOCOL_VERSION` moves to `'18.0.0'` and a `'^18'` manifest is admitted) + + + + **BREAKING**, graded `major`: Changesets is in pre mode (`next`) for the v18 line, so this ships in an `18.0.0-next.N` prerelease. The protocol major moves in this reviewed change, not in the version pass (ruling record 6049734955, Q1 → B). Until that line's first version pass, `PROTOCOL_VERSION` is one major ahead of the package version; `check-changeset-no-major.mjs` admits that only in pre mode with a pending `major` for `@objectstack/spec` (ruling record 6056808625). + + - **`PROTOCOL_VERSION` reads `'18.0.0'`** and `PROTOCOL_MAJOR` reads `18`. The ADR-0087 D1 handshake on every load and install door (the app load seam, `POST /api/v1/packages`, `POST /api/v1/marketplace/install-local`) now admits a package whose `engines.protocol` admits major 18, and refuses one pinned to 17 (`'^17'`, `'^17.x.y'`, `'17.x'`) with `OS_PROTOCOL_INCOMPATIBLE` (422), naming `objectstack migrate meta --from 17`. + - **What you change.** Run `objectstack migrate meta --from 17` to list the edits for existing sources; `--write` applies the ones it can prove, including the declared `engines.protocol` range, rewritten in place from `'^17'` to `'^18'`, and you apply the rest by hand. + - **`spec-changes.json` and the protocol upgrade guide** now carry the protocol 17 → 18 record, generated from the registries: 64 mechanical conversions and 329 semantic entries. The aggregate record spans 16 → 18. + - **`os init`** stamps `'^18'` in the manifests it writes, and `os lint`'s `protocol/missing-engines-range` fix names `'^18'`. The `create-objectstack` template keeps `'^17'` until the version pass stamps it together with its `@objectstack/*` dependency ranges. + - **`@objectstack/mcp`**: the README's `defineStack` examples declare `'^18'`. +- 326a90a: feat(spec)!: a `page` gains an optional `print` declaration and a linted printable block subset; the list-export `'pdf'` prescription points at the print page; the zero-reader document schemas retire whole (#22158) + + Clause-②: yes (narrowing) + + + + **BREAKING** — the document family's exports leave `@objectstack/spec/data` (an export removal on a published entry), graded `major` on `@objectstack/spec`: Changesets is in pre mode on `main` (tag `next`), where the launch-window `major` guard stands aside for the line's breaking changes, so the level says what the change is. The v18 opening marker already takes the fixed group to `18.0.0-next.N`, so this grade moves no version on its own. `@objectstack/lint` is `minor`: its new rule refuses blocks only inside a page that declares `print`, which no page could carry before this release, and it adds exports. The `print` declaration itself is additive. + + Card ① of the ruling on PDF and print documents (letter B′): **a document is a page with a print declaration; there is no separate template type.** + + ### The print page (additive) + + `PageSchema` gains an optional, closed `print` block. Its presence makes the page a print page — a document (an invoice, a delivery order, a letter, a report) authored in the page's own blocks: + + ```ts + definePage({ + name: 'invoice_print', + label: 'Invoice', + type: 'record', + object: 'invoice', + regions: [ + { name: 'header', components: [{ type: 'element:image', properties: { src: '/logo.png', alt: 'ACME' } }] }, + { name: 'main', components: [ + { type: 'record:details', properties: { fields: ['customer', 'invoice_date'] } }, + { type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'description' }, { name: 'amount' }] } }, + ] }, + { name: 'footer', components: [{ type: 'element:text', properties: { content: 'Payment due within 30 days.' } }] }, + ], + print: { paperSize: 'A4', orientation: 'portrait', margins: { top: 15, bottom: 15 }, repeatHeader: true, repeatFooter: true, pageNumbers: true }, + }); + ``` + + - **Keys** (`PagePrintSchema`): `paperSize` (`A4` | `A5` | `Letter` | `Legal`), `orientation` (`portrait` | `landscape`), `margins` (`{ top, right, bottom, left }` in millimetres), `repeatHeader` / `repeatFooter` (repeat the page's own `header` / `footer` region on every sheet), `pageNumbers`, and the page-break hints `repeatTableHeaders` and `avoidBreakInside`. Each maps to print CSS (`@page`, `thead { display: table-header-group }`, `break-inside`). + - **Not yet rendered.** Nothing applies these keys until the console's print rendering lands; the liveness ledger carries `print` as `planned` with an author warning, so `os validate` tells an author who writes it that the layout is validated but not yet applied. + - **Refused at the parse** (`checkPagePrintComposition`, exported for `.shape` mirrors): `print` on a `slotted` page, on an `html` / `jsx` / `react` page, on a `list` or `utility` page (a print page is a `record`, `home` or `app` page), on a `full` page with no `regions`, and `repeatHeader` / `repeatFooter` on a page with no region of that name. + - **The printable block subset** (`PRINTABLE_PAGE_COMPONENT_TYPES`, with the reason for every other vocabulary type in `PRINT_REFUSED_PAGE_COMPONENT_TYPES`): `page:section`, `page:card`, `page:footer`, `record:details`, `record:highlights`, `record:line_items`, `element:text`, `element:image`, `element:divider`, `element:definition-list`, `element:repeater`, `element:number` and `object-metric`. Inside a print page any other block — one that pages or windows its rows (`object-grid`, `record:related_list`), one that lays itself out to the screen (`page:sidebar`, `object-kanban`, `object-calendar`), or one with nothing printable (controls, inputs, shell chrome, `page:tabs`) — is refused by `@objectstack/lint`'s new gating rule `print-page-block-unprintable` (`validatePrintPageBlocks`), on `os validate`, `os build`, `os lint` and the page save door. + + ### The list-export prescription + + `'pdf'` stays refused in `view.exportOptions` formats. The refusal no longer says "PDF export itself was declined as NOT PLANNED" — no longer true — and instead names the view's `allowPrinting` for printing a list and a page that declares `print` for a document. + + ### FROM → TO (the retirement) + + `DocumentTemplateSchema`, `DocumentSchema`, `ESignatureConfigSchema` and the orphaned `DocumentVersionSchema` (`data/document.zod.ts`) are removed from `@objectstack/spec/data`, with their type aliases (`DocumentTemplate`, `DocumentTemplateParsed`, `Document`, `DocumentParsed`, `ESignatureConfig`, `ESignatureConfigParsed`, `DocumentVersion`, `DocumentVersionParsed`). No code in this repository, objectui or hotcrm read them. + + | before | what to write instead | + | --- | --- | + | `DocumentTemplateSchema` (a docx template with placeholders) | a `page` that declares `print`, its body drawn from the printable block subset | + | `DocumentSchema` / `DocumentVersionSchema` | nothing — no document store ever kept them; a document record is ordinary object data, its files `sys_file` attachments | + | `ESignatureConfigSchema` | nothing — no e-signature integration exists | + + **The one-line fix: delete the import; author a printable document as a page with a `print` block.** Every such import is TS2305 after upgrade. `data/DocumentSchemaValidation` (the NoSQL driver's block) is unaffected. `os migrate meta --from 17` lists the delegated step (D3 `document-schemas-retired`); there is no mechanical edit, since no stack collection ever carried these shapes. + + ### The retirement kit + + - `RETIRED_DEFS_BY_MAJOR[18]`: `data/DocumentTemplate`, `data/Document`, `data/ESignatureConfig`, `data/DocumentVersion`; D3 semantic entry `document-schemas-retired`, with a step-18 rationale fragment. The `ESignatureConfig` deadline-key entries in `RETIRED_KEYS_BY_MAJOR[18]` stay as history. + - No tombstone and no D2 conversion: none of the schemas is a stack collection member or a metadata type, so a conversion would have no seam that runs. + - Generated: `json-schema.manifest/data.json` −4 defs, `authorable-surface/data.json` −30 rows, `authorable-defaults/data.json` −2, the api-surface / declaration-map / export-origins shards, the `data/document` reference page removed. + - Pin: `src/data/document-schemas-retirement.test.ts`, a tree-scoped absence walk over the declared radius. +- e23eccb: feat(spec,metadata-protocol)!: the metadata type registry's `allowOrgOverride` is renamed to `allowEnvironmentOverlay`, on the registry and on `GET /meta/types` + + Clause-②: yes (narrowing) + + + + **BREAKING**: a renamed authorable key and a renamed response field, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0131 D6 retired the per-organization overlay axis: every metadata write and read is environment-wide. The registry flag kept the name that said "per-org" while it decided one environment-scope question at every door — whether an environment may overlay an item of the type that a managed package ships: the metadata protocol's save door, the managed-content seal on write and on the served read, the repository's put gate, and Studio's write gate through `GET /meta/types`. It is renamed to say that. One key, not two, and not the old name with a new meaning; no window in which both spellings are accepted or served. + + ## FROM → TO + + | where | you wrote / read | write / read instead | + |:--|:--|:--| + | a `DEFAULT_METADATA_TYPE_REGISTRY` entry (`MetadataTypeRegistryEntry`, `@objectstack/spec/kernel`) | `allowOrgOverride: true` | `allowEnvironmentOverlay: true` | + | a reader of a registry entry | `entry.allowOrgOverride` | `entry.allowEnvironmentOverlay` | + | each `entries[]` item of `GET /api/v1/meta/types` (`GetMetaTypesResponse`) | `allowOrgOverride` | `allowEnvironmentOverlay` | + + **The one-line fix: rename the key, and every read of it; the value is unchanged.** + + Every value is unchanged: `true` on `view`, `dashboard`, `report`, `translation` and `email_template`, `false` on every other built-in type. On the wire the value is still the registry's grant OR the `OS_METADATA_WRITABLE` hatch naming the type (`overrideSource: 'env'`), and the hatch still opens only items no managed package ships, never a managed one. `overrideSource` keeps its `registry` / `env` values; its describe now names the new field. + + ⚠️ **Read the old key nowhere.** Writing `allowOrgOverride` fails to compile (its input type is `never`) and fails to parse with the rename prescription (issue code `invalid_type` at path `allowOrgOverride`). A READER does not fail: `if (!entry.allowOrgOverride)`, a filter on it or a partial-match assertion still compiles against the tombstone and reads `undefined`, which treats every type as closed to environment overlays. A client that declares its own type for the `/meta/types` response, instead of importing `GetMetaTypesResponse`, sees no compile error at all and reads every type as locked. Search for the old name, do not wait for `tsc`. + + **`supportsOverlay` keeps its name; its describes now say what it gates.** It decides one thing: a stored overlay row of an item a managed package ships may be removed through the ordinary delete door without the type's environment-overlay permission: the removal removes no managed definition, and changes what is served for `permission` alone. It opens no save and it decides no read. The registry's describe, the `GET /meta/types` describe and `MetadataTypeInfoResponse`'s describe used to promise that "the loader can merge overlays", in the retired per-organization wording. + + **Who is affected, measured.** No manifest, stack, plugin configuration or stored row can carry a registry entry (`contributes.kinds` is closed, `config.additionalTypes` is a tombstone, the registry is a TypeScript literal), so no authored metadata changes. The readers are code: every reader in this repository moved in this change, and a reader of the `/meta/types` field outside it moves with it — the Studio console reads the field and moves in its own repository's paired change, which the console pin carries. + + ### The kit + + - **The tombstone.** `MetadataTypeRegistryEntry.allowOrgOverride` is a `retiredKey()` naming `allowEnvironmentOverlay` (`kernel/metadata-plugin.zod.ts`); the 28 built-in rows spell the new key. + - **The ledger.** The retired-keys entry `kernel/MetadataTypeRegistryEntry:allowOrgOverride` and the D3 semantic entry `metadata-type-registry-allow-org-override-renamed` (protocol 18), which records the registry key and the wire field together. No D2 conversion: there is no authored or stored source a conversion could rewrite. + - **The wire.** `getMetaTypes` writes the hatch elevation onto `allowEnvironmentOverlay` on both of its arms — a registry type and a runtime-registered type with no registry row — and no entry carries `allowOrgOverride`. + - **The refusal sentences** that named the flag name the new key: `… takes no environment overlay (allowEnvironmentOverlay=false) …`, `… has neither allowEnvironmentOverlay nor allowRuntimeCreate …`, `… allowRuntimeCreate=false and allowEnvironmentOverlay=false …`. Codes and statuses are unchanged. +- 20e7d52: A `type: 'chart'` list view must bind a dataset: a view whose effective chart binding names no `dataset` is refused at every list-view door, at `chart` (no binding at all) or at `options.chart.dataset` / `options.chart.values` (an incomplete legacy bag), with the binding to declare. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and on the view write door, graded `major` on `@objectstack/spec`: Changesets is in pre mode on `main` (tag `next`), where the launch-window `major` guard stands aside for the line's breaking changes. + + **Why.** A chart list view plots only the ADR-0021 `dataset` its binding names. The renderer reads that binding as the top-level `chart` block, else the legacy `options.chart` bag, the block replacing the bag whole. The `chart` block already required `dataset` and `values`, but a view with no block at all, or with only the bag, never met that schema: the view write door (`PUT /api/v1/meta/view/:name`) saved `type: 'chart'` with no `chart` block, and an `options.chart` bag holding only `chartType`, and `defineStack` / `os validate` accepted the block-less view. Such a view renders a dead screen: the renderer either guessed a binding nobody wrote or, since objectui retired that guess, refuses on screen. + + **What is refused.** A list view whose `type` is `chart` and that: + + - declares no `chart` block and no `options.chart` bag: one `custom` issue at `chart`, whose message begins *This list view is `type: 'chart'` but declares no `chart` block, so it binds no dataset and there is nothing to plot.*; + - declares no `chart` block and an `options.chart` bag with no `dataset` or no `values` (the bag is legal on the flattened overlay only): one `custom` issue per missing key, at `options.chart.dataset` / `options.chart.values`. + + It is a check on the list-view schema itself, so it reaches every door that parses a list view: `defineView`, `defineStack`, `os validate` / `os build`, a view item's `config`, and the metadata write door, which answers `422 INVALID_METADATA`. **New export:** `checkListViewChartBinding`, published from `@objectstack/spec/ui`, is this refinement check itself, a `(view, ctx) => void` function; objectui's `ListViewSchema` mirror, which is built from `ListViewSchema.shape` and so drops the schema's object-level checks, attaches it with `.superRefine(checkListViewChartBinding)`. + + **What stays accepted, byte for byte.** A chart view whose `chart` block names a `dataset` and at least one measure in `values`; a chart overlay whose `options.chart` bag carries both and no `chart` block replaces it; an incomplete bag under a complete `chart` block, which replaces it whole; a flattened overlay patch that names no `type`; and every view of another type, including a grid that only offers a chart in `appearance.allowedVisualizations`. + + **What to do.** Bind the chart: declare a top-level `chart` block naming the dataset to plot and at least one of its measures, for example `chart: { dataset: 'lead_metrics', values: ['amount_sum'] }`, with `dimensions` (the X / group axis) optional and `chartType` defaulting to `bar`. A view that carries its binding in the legacy `options.chart` bag either completes the bag or, preferred, moves it to the top-level `chart` block. A view that is not meant to be a chart takes another `type`. No conversion can do this for you: only the author knows which dataset a chart plots. + + **Stored views.** A stored `view` row is neither rewritten nor refused on read: it is served as stored, carries the same issue in its read-side `_diagnostics`, and is refused on its next save. + + **Who is affected, measured.** No chart list view without a binding exists in this repository: the two chart list views in `examples/app-showcase` and the chart list views in the `@objectstack/lint` fixtures all bind a dataset and a measure. Deployed metadata was not measured. + + ### The kit + + - **The refusal.** `checkListViewChartBinding` in `ui/view.zod.ts`, attached beside the calendar binding check at the three list-view doors: `ListViewSchema`, `ObjectListViewSchema` and the flattened list overlay member of the view write door. The `chart` slot's description now says a chart view must bind one, and the generated reference page carries it. + - **The ledger.** The D3 semantic entry `view-chart-binding-dataset-required` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion. +- 3e72f93: A flow node's `outputVariable` (`get_record`, `create_record`, `map`, `script`, `subflow`) refuses a variable name that starts with `$`, and a `try_catch` node's `errorVariable` refuses every `$` name except the engine's own `$error`, its default. The refusal names the remedy: the same name without the `$`, read as `{{ name }}`. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The `$` names are the flow engine's own variables: it binds `$record`, `$runId`, `$flowName`, `$flowLabel` and `$error`, a flat-graph `loop` binds `$loopItems` and `$loopIndex`, and a resume signal may not write any `$` name. A flow text slot already refuses a `{{ }}` hole whose root is a `$` name the engine does not bind, and tells the author to drop the `$`. But the binding keys took any string, so a flow could bind `errorVariable: '$caught'` and then be refused `'Failed: {{ $caught.message }}'`. The two doors of one contract gave two answers. Now both give the text slot's answer. + + **What is refused.** + + - `outputVariable` on `GetRecordConfigSchema`, `CreateRecordConfigSchema`, `MapConfigSchema`, `ScriptConfigSchema` and `SubflowConfigSchema`: a name that starts with `$`. That includes the engine's own names, since a binding over `$record` would overwrite the trigger record for the rest of the run. + - `errorVariable` on `TryCatchConfigSchema`: a name that starts with `$`, other than `$error`. + - Each key states the rule as a JSON Schema `pattern`, so the published `json-schema/**` refuses what the parse refuses. The parse issue is an `invalid_format` (regex) issue at the key, and its message names the remedy. + - `FlowSchema.parse`, `registerFlow` and `objectstack validate` refuse the flow at `nodes.N.config.outputVariable` / `nodes.N.config.errorVariable`, inside a region body too (`nodes.N.config.try.nodes.M.config.outputVariable`). A stored flow carrying one is skipped at boot with a warn naming it, and each executor's own contract parse refuses the node at run time. + + **Unchanged.** `errorVariable` absent (it defaults to `$error`) or an explicit `errorVariable: '$error'`; any name that does not start with `$`, a `$` later in the name (`a$b`) included; an empty string, which every executor reads as no binding; and every hole the text-slot judge already admits. Non-string values keep the type refusal they had. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `errorVariable: '$caught'` with `message: 'Failed: {{ $caught.message }}'` | `errorVariable: 'caught'` with `'Failed: {{ caught.message }}'`, or drop `errorVariable` and write `{{ $error.message }}` | + | `outputVariable: '$lead'` with `title: '{{ $lead.name }}'` | `outputVariable: 'lead'` with `title: '{{ lead.name }}'` | + + **The one-line fix: name the variable without the `$`, and rename every read of it with it.** + + No D2 conversion rewrites the name: the bare name may already be bound in the flow, and the reads of the old name sit in every dialect a flow string speaks (a text-slot hole, a CEL expression), so the rename is the author's. + + **Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), types all six keys as plain strings and accepts any `$` name. This repository was measured with `git grep` over `packages`, `examples`, `skills`, `apps`, `content` and `docs`: the only `$`-named `errorVariable` / `outputVariable` other than `$error` were three test fixtures, all renamed here (`errorVariable: '$err'` in two `packages/spec` automation tests, `errorVariable: '$caught'` in a `service-automation` test). Every authored `errorVariable` in examples and docs is `$error`, which stays legal, and no example or doc binds a `$`-named `outputVariable`. The pinned `objectui` checkout uses `errorVariable: '$error'` only. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The rule.** `flowBoundVariableNameSchema` in `automation/flow-bound-variable-name.ts`, package-internal (no new public export), composed into the six keys. It reads the `$` prefix the engine's closed list implies, not a copy of the list. + - **The ledger.** The D3 semantic entry `flow-binding-variable-dollar-name-refused` (protocol 18), with no D2 conversion. +- 5fb1746: A flow CEL expression that reads a root the flow does not bind is refused at `objectstack validate`. The run fails such an expression with `Unknown variable: X` (X being the root) on every evaluation, and the build door used to pass it. The reachable case is the run user: `user.id == "u1"`, `ctx.user.id == "u1"` and `os.user.id == "u1"` — the spellings formulas, row-level security and the client accept — are refused, naming `current_user`, the one spelling flow CEL binds. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface (`objectstack validate`'s `expression-invalid` rule). + + **Why.** Flow CEL evaluates in one scope per run: the flow's variables spread to top level, the trigger record's fields flattened beside them, `record` bound to the record the run was handed (where an entrance hands it one), and `previous`, `vars` and `current_user` bound by the engine. Formulas, row-level security and the client also bind the run's user as `user`, `ctx.user` and `os.user`; flow CEL does not, because a top-level `user` would collide with a variable or a lookup field of that name. So an author who wrote the alias by habit shipped a flow that faulted at its first evaluation, and nothing warned. + + **What is refused.** At every flow CEL site the build door reads — a node's `config.condition` (the start node's trigger gate included), an edge `condition`, a `decision` branch `expression`, a screen field `visibleWhen`, and the CEL value envelopes of an `assignment` and of a `create_record` / `update_record` `fields` map — a root that is none of: + + - the engine's roots: `previous`, `vars`, `current_user`; + - `record`, where an entrance the stack declares hands the flow a record — a record trigger (a `record-*` `triggerType`), a time-relative sweep, the inbound hook, a `type: 'flow'` action, a `map` node with an `itemObject`, or a `subflow` / `map` parent handed one — or where the flow binds a variable named `record`; + - a name the flow binds: a declared variable, an `outputVariable`, an `iteratorVariable` (a `loop`'s default `item` included), an `indexVariable`, an `errorVariable`, an `assignment` target, a node id, a screen field's `name`; + - a field (or a registry-injected column) of any object the stack declares — the run flattens whichever record its entrance hands it (an object-less flow action hands only the row `id`). + + The user spellings are told to write `current_user`, with the guard for a flow that can run without a user; any other root is named with what could have bound it, and the nearest in-scope name. The error is `expression-invalid`, `error`. Where the flattened-scope near-miss advisory ("`X` is not a field of … If `X` is a flow variable this is safe to ignore") named the same root, the refusal replaces it. + + **Not judged — the door stands down where the run can bind a name it cannot see.** + + - **A node:** a `script` or `connector_action` node (each hands its code the live variable map), a `wait`, `subflow` or `map` node (a resume folds the caller's `variables` bag in under their plain names), a screen with no field list, or a node type outside the builtin set. + - **An entrance the stack declares that hands the flow a record whose keys are not in hand:** + - a record trigger (`config.objectName`) or a time-relative sweep (`config.timeRelative.object`) on an object the stack does not declare; + - an `api` trigger, whose inbound hook hands the request body in as the record; + - a `type: 'flow'` action launching the flow on an undeclared object; + - a `map` node feeding the flow whose `itemObject` is undeclared or absent; + - a `subflow` or `map` parent that is itself such a flow. + - **The runtime publish gate:** a flow write there is not judged for unbound roots, by design (rulings `5791822697` item 3 and `6104584601`). Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged. + + A `$` name has no CEL spelling (`$runId == "x"` does not parse), so the `$` roots stay the text-slot rule's. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `condition: 'user.id == "u1"'` | `condition: 'current_user.id == "u1"'` (`current_user != null && current_user.id == "u1"` where the flow can run without a user) | + | `condition: 'ctx.user.id == record.owner'` | `condition: 'current_user.id == record.owner'` | + | `assignments: { who: { dialect: 'cel', source: 'os.user.id' } }` | `assignments: { who: { dialect: 'cel', source: 'current_user.id' } }` | + | `condition: 'deals.size() > 0'` with nothing binding `deals` | declare it (`variables: [{ name: 'deals', type: 'list' }]`) or bind it with a node (`outputVariable: 'deals'`) | + + **The one-line fix: write `current_user` for the run user, and bind or correct every other root the refusal names.** + + **Who is affected, measured.** The last published spec line, 17.x, judged a flow expression's syntax only, so it accepts every refused source. Measured with the judge over every flow CEL site: the example apps (`app-crm`, `app-todo`, `app-multi-package`, `app-showcase`: 35 flows, 45 sites), `packages/platform-objects` (no flows) and `objectstack-ai/hotcrm` at `f0afcbda07` (32 flows, 65 sites) — 0 refusals; every root those flows read is an engine root, `record` on a flow an entrance hands a record, a binding of the flow's own or a field of its trigger object. One flow of the 67 is left unjudged by an entrance (`showcase_inbound_task_webhook`, an `api` trigger). Deployed metadata and other repositories were not measured. + + ### The kit + + - **The judge.** `flow-cel-root-scope.ts` in `@objectstack/lint`, called by `validateStackExpressions` at each flow CEL site. One bound set per flow, read once like the shadowing pass's variable set, and one entrance index per stack (`flowCelEntrances`). + - **The ledger.** The D3 semantic entry `flow-cel-unbound-root-refused` (protocol 18). There is no D2 conversion: none of these roots ever evaluated in a flow, so there is no behaviour to carry over. +- 9f5eca5: Every remaining place a flow binds a variable by name refuses a name that starts with `$`: a `loop` or `map` node's `iteratorVariable` and `indexVariable`, an object-form `screen` node's `idVariable`, a `screen` field's `name`, a declared flow variable's `name`, and an `assignment` node's targets. The refusal names the remedy: the same name without the `$`, read as `{{ name }}`. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The `$` names are the flow engine's own variables: it binds `$record`, `$runId`, `$flowName`, `$flowLabel` and `$error`, a flat-graph `loop` binds `$loopItems` and `$loopIndex`, and a resume signal may not write any `$` name. A flow text slot refuses a `{{ }}` hole over a `$` name the engine does not bind, and `outputVariable` / `errorVariable` already refuse one. Every other binding took any string, and on the run a `$` binding was worse than unreadable (measured with the engine on this release's `main`): + + - `iteratorVariable: '$record'` on a `loop` or `map` left `$record` holding the last item for the rest of the run, and `indexVariable: '$runId'` left `$runId` holding an index; + - `assignments: { $record: … }` overwrote the trigger record; + - a declared variable named `$record` was overwritten by the engine at run start, so its `defaultValue` never reached the run; + - a `screen` whose `idVariable` or field `name` was a `$` name paused, and then could never be submitted: the resume carrying the value was refused with `INVALID_SIGNAL`; + - a leading blank hid the `$` from a first-character rule, while the `screen` and `script` executors trim a name before they bind it: under that rule `idVariable: ' $id'` registered, the paused screen named `$id`, and its resume was refused the same way; a `script`'s `outputVariable: ' $record'` passes the protocol-18 rule too and is trimmed to `$record` by its executor (read from the executor, not run). + + **What is refused.** + + - `iteratorVariable` and `indexVariable` on `LoopConfigSchema` and `MapConfigSchema`, `idVariable` on `ScreenConfigSchema`, `name` on `ScreenFieldConfigSchema` and on `FlowVariableSchema`: a name that starts with `$`. Each key states the rule as a JSON Schema `pattern`, so the published `json-schema/**` refuses what the parse refuses. The parse issue is an `invalid_format` (regex) issue at the key, and its message names the remedy. + - Every binding key — `outputVariable` and `errorVariable` included — now judges the first NON-BLANK character, so `' $x'` is refused like `'$x'`. The pattern's `\s` is exactly the set `String.prototype.trim` removes. + - An `assignment` node's targets, in each shape its executor binds: a key of the `assignments` map (`AssignmentConfigSchema` states it as `propertyNames.pattern`), a top-level key of the bare legacy config, and the `variable` (or `name`, `key`) of a legacy `assignments: [{ variable, value }]` item. + - `FlowSchema.parse`, `registerFlow` and `objectstack validate` refuse the flow where the name was written — `nodes.N.config.iteratorVariable`, `nodes.N.config.fields.M.name`, `nodes.N.config.assignments.NAME`, `variables.N.name` — inside a region body too. A stored flow carrying one is skipped at boot with a warn naming it, and the `loop`, `map` and `screen` executors' own contract parse refuses the node at run time. + + **Unchanged.** Any name whose first non-blank character is not `$`, a `$` later in the name (`a$b`) included; the defaults (`iteratorVariable` is still `item`); an empty string where the key took one; a body-less legacy `loop`, whose `iteratorVariable` nothing reads; and every hole the text-slot judge already admits. Non-string values keep the type refusal they had. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `iteratorVariable: '$row'` with `title: '{{ $row.name }}'` | `iteratorVariable: 'row'` with `title: '{{ row.name }}'` | + | `idVariable: '$account'` | `idVariable: 'account_id'`, read as `{{ account_id }}` | + | `variables: [{ name: '$total', type: 'number' }]` | `variables: [{ name: 'total', type: 'number' }]`, read as `{{ total }}` | + | `assignments: { $total: … }` | `assignments: { total: … }` | + + **The one-line fix: name the variable without the `$`, and rename every read of it with it.** + + No D2 conversion rewrites the name: the bare name may already be bound in the flow, and the reads of the old name sit in every dialect a flow string speaks (a text-slot hole, a CEL expression), so the rename is the author's. + + **Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), types every one of these keys as a plain string. Measured on `main` at this change's base: the 35 flows `examples/app-crm`, `examples/app-todo` and `examples/app-showcase` ship all parse clean under the new rule, and a TypeScript-AST scan of `examples` (234 files), `packages/platform-objects` (167, which ships no flow), `packages/qa/dogfood` (280), the rest of `packages` and the `hotcrm` app found no `$`-led binding on any of these positions. The pinned `objectui` checkout binds none either. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The rule.** `flowBoundVariableNameSchema` in `automation/flow-bound-variable-name.ts`, package-internal (no new public export), now composed into every binding position. A `$` binding is refused with one sentence whichever door judges it. + - **The ledger.** The D3 semantic entry `flow-binding-name-dollar-refused` (protocol 18), with no D2 conversion. + +### Minor Changes + +- 29d0ed3: fix(spec)!: `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs` no longer lists `sys_organization.actions.change_slug` (#24318) + + Clause-②: no (narrowing) (a platform object stops declaring an action whose door exists only in cloud; owes a `CONTRACT_REVIEW_TIER` review on the narrowing face before the queue, and the `repo:cloud#2` seat's pointer before landing) + + `gatedInputs` is the census of platform-object inputs gated on a public auth feature, which the platform objects' completeness guard holds equal to their declarations in both directions. `sys_organization` no longer declares `change_slug` (its door is the cloud orchestrator's, and the composition that serves it declares the action), so its row leaves the census. + + **BREAKING — what moves for consumers.** One string leaves a published constant, shipped as `minor` under the launch-window convention for breaking changes. + + - FROM: `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs` listed seven `sys_organization` actions, `'sys_organization.actions.change_slug'` among them. + - TO: it lists six: `create_organization`, `update_organization`, `rename_organization`, `delete_organization`, `set_active_organization` and `leave_organization`. + - Fix: none for an action declared outside the platform objects. The census is not a gate on other packages: a top-level action bound to `sys_organization` that declares `requiresFeature: 'multiOrgEnabled'` is lowered to the same `features.multiOrgEnabled != false` predicate and served with it, measured on a booted composition. + + **Unchanged.** The `multiOrgEnabled` key, its surface (`crud`) and its semantics (`default-on`); every other feature's row. The constant is declared `as const`, so its literal type loses the one tuple member too. + + +- 1b40780: feat(automation)!: an approval decision resumes a flow as the person who decided + + Clause-②: yes (narrowing). The narrowing: under `runAs: 'user'`, a post-decision node that the submitter's rights admitted and the decider's do not is now refused (ruling A item 3). The widening: three published interfaces each gain one optional member, `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`) and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`). No schema shape moves: `flow.runAs` keeps its two values and only its describe changes. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A resumed flow run kept its trigger user for its whole life. In an approval flow that meant the nodes after the manager's decision wrote as the rep who submitted: the record's Last Modified By (`updated_by`) and the audit log named someone who did not act, and under `runAs: 'user'` the writes ran under the rep's grants. + + `flow.runAs: 'user'` now means the human whose act is running the current segment: the trigger user until a human decision, the decider after it. `runAs: 'system'` flows keep system privileges; after the decision their attribution user is the decider. + + - **Now, `runAs: 'user'`:** after an approval decision (approve or reject), the nodes that follow run with the decider's identity and permissions together. Their positions, permission sets and organization peers are resolved for them through the same resolver that seeds the trigger user's run, never copied from the trigger user. `updated_by` and `sys_audit_log` name the decider for those writes. The nodes before the decision keep the submitter. A second decision moves the segment to its own decider, a later pause keeps the identity the segment had, and the run's history still names the user who triggered it. + - **Now, `runAs: 'system'`:** after an approval decision, the flow keeps system privileges and its nodes run as `{ isSystem: true, userId: DECIDER }`, the shape the approvals verdict mirror already writes with. No permission is resolved and elevation is unchanged, so a read-only column still lands. Their writes, `sys_audit_log`, and `ctx.user` in the hooks those writes fire name the decider; the nodes before the decision keep the submitter. + - **Refused now:** under `runAs: 'user'`, a node after the decision that the decider may not perform (an object or field they may not edit, a record outside their reach) is refused, the run stops at that node as a repairable strand, and the decision door answers `RESUME_FAILED` (`repairable: true`) naming the run, the node and the refusal, as for any other failed post-decision node. Before, the same node ran with the submitter's rights. A repair replay (`continueRestoredRun`) continues as the decider too. + - **Unchanged:** a flow with no human decision node; an SLA auto-decision, which names no human, so the run keeps the identity it had (on both postures); a subflow parent's own nodes; and the send-back, resubmit and recall doors. + + `@objectstack/plugin-approvals` hands the decider to the engine on the service-stamped resume signal: the person the verdict mirror already writes as. The engine honours a decider only on a signal that carries the owning service's `RESUME_AUTHORITY_SERVICE` marker, so a resume built from an HTTP body cannot name one. The published types gain one optional member each: `ResumeSignal.resumedBy` (`@objectstack/spec/contracts`), and the `resumedBy` field on `ApprovalResumeSurface`'s resume signal and on `StrandedContinuationSignal` (`@objectstack/plugin-approvals`). + + What changes for you: if a `runAs: 'user'` flow's nodes after an approval decision must write something the approver may not, such as a read-only field or a field the approver's permission set marks non-editable, the flow needs `runAs: 'system'`; its writes then keep system privileges and are attributed to the approver. A hook that reads `ctx.user` on a write a `runAs: 'system'` approval flow makes after the decision now sees the decider instead of the submitter. A run that was suspended before this release and is decided after it continues as the decider. +- 8016a9d: **Shipped in 18.0.0-next.5**, whose CHANGELOG missed this entry; listed here one prerelease late. + + A navigation entry whose `visible` reads `data` must declare `dataSource`: an entry with no `dataSource` whose `visible` predicate reads the top-level `data` root is refused at parse, at the entry's own `visible`, with the fix. + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing on a published authoring surface: `NavigationItemSchema`), shipped as `minor` under the launch-window convention for breaking changes (Changesets is in pre mode on `main`, tag `next`). + + **Why.** A navigation entry may declare one API `dataSource`, whose selected value its `visible` reads as `data`. That is the only thing that puts a `data` root in a navigation predicate's scope: the console shell's ambient roots are `current_user`, `user`, `ctx.user`, `os.user` and `features`, and a group's `dataSource` is not published to its children. An entry that reads `data` without declaring a source has nothing to read, so until now it parsed, passed `os validate` and shipped as an entry the console never shows, with the author told nothing. + + **What is refused.** A navigation entry of any type (a `group` included) with no `dataSource` whose `visible`, a CEL predicate, reads the root `data`: `data.total == 1`, `!data.has_more`, `has(data.total)`, `.data.total == 1`, or `${data.total == 1}`. The answer is one `custom` issue at the entry's `visible` (for example `navigation.0.children.2.visible`), whose message begins *This navigation entry's `visible` reads `data`, but the entry declares no `dataSource`, so there is no `data` to read*, and names the fix. It is a check on `NavigationItemSchema` itself, so it reaches every door that parses a navigation entry: `app.navigation`, `app.areas[].navigation`, every `children` array at any depth, and a navigation contribution's `items`, through `defineApp`, `defineStack`, `os validate` / `os build` and the metadata write door. + + **What stays accepted, byte for byte.** An entry that declares `dataSource` and reads `data`; an entry whose `visible` reads only `current_user`, `features` or other ambient roots; a `data` that is a member name (`current_user.data.total`), text inside a string literal or a `//` comment, or a comprehension macro's own iteration variable (`current_user.positions.exists(data, data == 'org_admin')`); an entry with no `visible`; and a separator. + + **FROM → TO.** + + - FROM `{ id: 'nav_environments', type: 'object', objectName: 'sys_environment', visible: 'data.total == 1' }` + - TO `{ id: 'nav_environments', type: 'object', objectName: 'sys_environment', dataSource: { provider: 'api', read: { url: '/api/v1/cloud/home' }, path: 'data.environments' }, visible: 'data.total == 1' }`, or drop the `data.*` read and gate on `current_user` / `features`. + + One-line fix: declare `dataSource` on the entry whose `visible` reads `data`, or drop the `data.*` read. A child of a group that declares a `dataSource` declares its own. No conversion can do this for you: only the author knows which endpoint the entry was meant to read. + + **Who is affected, measured.** No navigation entry in this repository reads `data` without a `dataSource`: an AST census of every tracked `.ts` / `.tsx` / `.mts` file found 11 navigation entries with a `visible`, of which 2 read `data` (both spec tests, both declaring a `dataSource`) and 3 read `current_user`. Deployed metadata was not measured. + + ### The kit + + - **The refusal.** A module-private CEL root reader and the refinement that uses it, mounted once on `NavigationItemSchema` in `ui/app.zod.ts`. The `visible` description now says the read is refused without a `dataSource`, and the generated reference page carries it. + - **The ledger.** The D3 semantic entry `nav-item-visible-data-needs-data-source` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion. +- b12e16a: feat(service-settings)!: the settings audit trail's global rung moves to the new deployment-level `sys_platform_setting_audit` + + Clause-②: yes (narrowing) + + + + **BREAKING** for a reader of the settings audit trail, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D7; ruling record 6257510973, Q4 A). This is the expand half of the change: it adds the new write shape, and the 18.0 rows stay where they are and readable. No row an 18.0 install wrote is moved, deleted or re-stamped. + + - **Before.** A write of a global-scope settings key appended its history row to `sys_setting_audit`, stamped with the Default Organization under `single`. Any principal the settings read door let read the namespace could list it there. + - **Now.** That row goes to the new `sys_platform_setting_audit`. It has the same row shape, `scope` is always `global`, and there is no organization column (`systemFields: { tenant: false }`). Reading it through the generic data API requires the `manage_platform_settings` capability, as `sys_platform_setting` does, and each namespace's `readPermission` applies there too. Tenant and user rows stay in `sys_setting_audit`, stamped as before. + - **18.0 rows.** Global-rung rows an 18.0 install wrote stay in `sys_setting_audit`, stamped, until the upgrade ceremony moves them. The global rung's history is both tables: the sibling, plus `sys_setting_audit` rows at `scope = global`. + + **The remedy.** If you list or filter the global rung's settings history (`sys_setting_audit` with `scope = global`), read `sys_platform_setting_audit` as well, with a principal that holds `manage_platform_settings`. Select the 18.0 rows by `scope`, never by a missing organization: they carry the Default Organization. Nothing changes for a reader of tenant or user rows. +- fbafb85: feat(plugin-email,plugin-auth): identity mail — verification, password reset, magic link, the email-change notice, an account invitation — is recorded in `sys_identity_email`, a deployment-level sibling of `sys_email` with no organization column + + Clause-②: yes (widening) + + ADR-0131 D7, ruling of record 6257510973 (letter Q2 A). Expand-first: this release adds the new log and its write path, and keeps reading every row and job an 18.0 install left behind. It moves and deletes nothing. + + - **The new object.** `sys_identity_email` (`@objectstack/platform-objects`, registered by `EmailServicePlugin`) has `sys_email`'s fields, so the row shape is the same, and `systemFields: { tenant: false }`, so it has no organization column. It is append-only, readable over `get` / `list`, and gated by `manage_platform_settings` (like `sys_job`), because its rows carry password-reset and sign-in links. + - **The contract.** `SendEmailInput` and `SendTemplateInput` (`@objectstack/spec/contracts`) gain `identityMail?: boolean`. With `true`, the mail service writes the row to `sys_identity_email`. Without it, the row goes to `sys_email`, as before. `identityMail: true` together with an `organizationId` is refused as `VALIDATION_FAILED` and recorded as a rejected row in `sys_identity_email`. The organization is not written. + - **One pipeline, both logs.** The queue worker, the outbox drain hook, the boot outbox sweep, attachment reclamation and the internal-header readback read a row from the log it lives in. A delivery or reclaim job for a `sys_identity_email` row carries `object: 'sys_identity_email'` beside `rowId`. A job with no `object` reads `sys_email`, which covers every job an 18.0 process queued. A job naming anything else is refused at `error` and touches no row. `EmailPersistence.insert` / `update` / `readHeadersJson` take the log as an optional last argument. A custom implementation that ignores it keeps every row in its one store. `@objectstack/plugin-email` exports `TENANT_EMAIL_OBJECT`, `IDENTITY_EMAIL_OBJECT`, `EMAIL_LOG_OBJECTS`, `isEmailLogObject` and `EmailLogObject`. + - **plugin-auth.** The five identity sends say `identityMail: true` and name no organization, under every posture. Before, they carried the Default Organization under `single` and nothing under a wall. The organization invitation (`auth.invitation`) is tenant mail. It still carries its organization and still lands in `sys_email`. + - **What a reader of `sys_email` sees.** + - **Before.** Identity mail was a `sys_email` row: stamped with the Default Organization under `single`, organization-less under a wall. + - **Now.** New identity mail is a `sys_identity_email` row. Rows already in `sys_email` stay there and are still delivered, swept and reclaimed. + - **Remedy.** A report, export or query that looks for verification or reset mail reads both logs. For example: `ql.find('sys_identity_email', { where: { related_object: 'sys_user', related_id: userId } })` beside the same query on `sys_email`. + - **Inventory.** `os migrate organization-ownership`'s inventory lists `sys_identity_email` as deployment-level (column-drop fate, nothing to attribute). Moving the identity rows already in `sys_email` is an 18.x upgrade-ceremony step, not part of this release. +- 35450f0: feat(spec/ui): `navItemVisibleDataNeedsDataSource` is exported from `@objectstack/spec/ui`, so a mirror of `NavigationItemSchema` attaches the spec's own rule by identifier + + Clause-②: yes (widening) + + - **What is added.** One function export, `navItemVisibleDataNeedsDataSource(item, ctx)`: the refinement `NavigationItemSchema` already runs, which refuses a navigation entry with no `dataSource` whose `visible` reads `data`. It is exported the way `objectNavTargetExclusivity`, `checkPageRegionPlacement` and `checkPageMainRegionWidth` are, so a hand-written mirror of the navigation item can chain the same function in its own `superRefine` instead of restating the rule. + - **What does not change.** No schema accepts or refuses anything new. The issue is the same: one `custom` issue at `visible`, with the same message. `NavigationItemSchema` still attaches the same function, once, by name. + - **What the function reads.** Both spellings of `visible`, so it answers the same on an entry as authored and as parsed: + - the authored bare string (`'data.total == 1'`), normalised by `EvaluatedExpressionInputSchema`, the slot's own schema, exactly as the spec's parse normalises it; + - the parsed `{ dialect: 'cel', source }` envelope. + + A boolean or absent `visible` judges nothing. So a mirror whose own `visible` stays the authored string chains the function as it is and refuses what the spec refuses. +- 1e8b628: feat(spec,metadata-protocol)!: the `/meta` door no longer authors sharing rules — `sharing_rule` is a code-only metadata type; Setup lists declared sharing rules + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D6, regime C). + + A sharing rule has two sources: a package declares it (the stack's `sharingRules`, switched off through the activation ledger), or an organization authors its own (`sys_sharing_rule`). A runtime metadata item was a third, and it bypassed the same-name guard and the organization wall. The narrowings, FROM → TO: + + - `PUT /api/v1/meta/sharing_rule/:name`, with a name nothing declares, in publish or `?mode=draft` — FROM `200`, with an environment-wide item stored and served by `GET /api/v1/sharing/rules`, TO `403 NOT_CREATABLE`, nothing stored. With a declared rule's name — TO `403 NOT_OVERRIDABLE`. + - `GET /api/v1/meta/types` — FROM `sharing_rule` advertised `allowRuntimeCreate: true`, TO `allowRuntimeCreate: false` and `allowEnvironmentOverlay: false`. + + **The fix:** author an organization rule, or clone the declared one. `POST /api/v1/sharing/rules` (Setup > Sharing Rules) creates an organization rule. To customize a declared rule, copy it to an organization rule under a new name, then switch the declared one off with `POST /api/v1/security/_activation/sharing_rule/:name`, body `{ "enabled": false }`. A package declares its rules in the stack's `sharingRules`, as before. An item stored through the old door is still served until you remove it with `DELETE /api/v1/meta/sharing_rule/:name`. On an environment kernel, removing it needs `OS_METADATA_WRITABLE=sharing_rule`. That hatch is an explicit operator opt-in, not an authoring route: as for any code-only type, it reopens every write on the type, including creates of new names, and a sharing rule grants access. Unset it after the removal. + + Also in this change: + + - **`@objectstack/spec`.** `MetadataTypeSchema` (so the `MetadataType` union) and `DEFAULT_METADATA_TYPE_REGISTRY` gain `sharing_rule`. Its schema binding (`SharingRuleSchema`) moves from the non-kind collections into the kind map. `META_URL_TO_SINGULAR` gains the `sharing_rules` spelling, so `/api/v1/meta/sharing_rules` now folds to `sharing_rule`; before, that plural was not a recognised spelling. + - **`@objectstack/metadata-protocol`.** The refusal names the two sanctioned paths, not a source glob. `sharing_rule` gains its ADR-0126 Regime C row (clone, and the ledger switch). + - **`@objectstack/plugin-sharing`.** Setup's Access Control group gains **Declared Sharing Rules** (`nav_declared_sharing_rules`). It opens `/apps/setup/metadata/sharing_rule?scope=environment` beside **Sharing Rules**, which lists the organization's own rules. + - **`@objectstack/platform-objects`.** That entry's label, in `en`, `zh-CN`, `ja-JP` and `es-ES`. +- 14ccb1d: fix(service-automation): a `runAs: 'user'` flow now carries its user's email, so a row policy on `current_user.email` admits the run on the rows it admits for the same user's own request + + Clause-②: yes (widening) + + A `runAs: 'user'` flow's data nodes ran without the acting user's email, which the request path resolves for every user. The row-level security compiler reads `current_user.email` from the execution context's `email` and nothing else, so every owner policy keyed on it (such as `assignee == current_user.email`) dropped out inside the run. A record-change flow triggered by a member whose only access to a row is such a policy was refused on the row the member had just written, and could read and update with their own request. + + Run setup now seeds the email through the resolver the request path uses (`resolveUserAuthzGrants` from `@objectstack/core`, the user's stored `sys_user` email), and every data node passes it on: + + - It is seeded for every trigger shape that names a user and carries no email: the record-change shape (`userId` and `tenantId` only) and the REST and action doors' shape (positions and permission sets already resolved). Only the missing facts are taken from the resolver; forwarded positions and permission sets stay exactly as they were. + - A context that already carries an email keeps it verbatim, as a request's session email wins over the stored one. A `subflow` or `map` child and a suspended run keep the email their run was seeded with. + - After an approval decision, the nodes that run as the decider carry the decider's own email, resolved for them. The submitter's email is removed with the rest of the submitter's grants, on both `runAs` postures. + - A user with no stored email, or a resolver failure, leaves the run without one: policies that read it drop out, and the run sees less, never more. The `[runAs]` warning on a resolver failure now names the email beside the peer list. + - A run suspended before this release resumes without the email until it ends. + + A `runAs: 'system'` flow is unchanged. The flow CEL scope's `current_user` is unchanged too: it still binds no email. + + Published types: `AutomationContext` (`@objectstack/spec/contracts`) gains an optional `email` member, and `RunIdentityContext` / `RunDataContext` and `FlowUserGrants` (`@objectstack/service-automation`) each gain an optional `email` member. +- fec87e7: feat(spec)!: a package manifest's `permissions` no longer takes a flat list of permission strings — the structured `{ services, hooks, network, fs }` block is the only form (#13458) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not yet in on `main`). + + `ManifestPermissionsSchema` was a union of a flat `string[]` and the structured ADR-0025 §3.2 block. Nothing ever acted on the list: the loader registers the consented `grantedPermissions` set from the environment artifact with the permission enforcer, never the manifest's request, and the only code that met a list on a manifest was two reports saying it had been skipped. The marketplace install disclosure reads only the four lists, so a list was shown to an installer as "Requests no special permissions." (ADR-0049 enforce-or-remove). `ManifestPermissionsSchema` is now the structured block itself. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `permissions: ['system.user.read', 'system.data.write']` | `permissions: { services: [...], hooks: [...], network: [...], fs: [...] }`, naming the platform services the plugin resolves, the lifecycle hooks it registers, the network hosts it reaches and the filesystem paths it touches. | + | `permissions: []` | delete `permissions`: absence is the spelling for "requests nothing". | + | a list on an app manifest that ships no code | delete `permissions`. An app's record access is its permission SETS, in the stack's own top-level `permissions` collection. | + + **The one-line fix: replace every manifest `permissions` list with the structured block, or delete it.** A permission string has no mechanical mapping onto the four lists, so the translation is done by hand. `os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand. + + **What an author now sees.** Writing a list fails `tsc` (the key's type is the block), and `os validate`, `os build`, `os plugin build` and `defineStack` refuse it at `manifest.permissions` with the block's own answer: `Expected the plugin permission block { services?, hooks?, network?, fs? }, received a flat list.`, followed by the prescription. Every structured block that parsed before still parses, unchanged. + + ### The retirement kit + + - **Schema.** `ManifestPermissionsSchema` is `PluginPermissionsSchema`, by identity, and both export names stay. The block answers a list with its prescription on its own error map, the bare-array pattern `ListViewExportOptionsSchema` uses. The block also carries `EnvironmentArtifactSchema.grantedPermissions` values, so the answer is worded true there too, where a list was never legal. + - **D2 conversion `manifest-permissions-string-list-removed`** (step 18, retired from the load path): a lossless delete of an all-string list from the stack's `manifest` and every `packages[].manifest`. The notice carries the dropped strings. A built artifact replays it at the artifact door, so an artifact built while the list was legal still boots. It never touches the structured block, an array of objects, or the top-level ADR-0090 permission-set collection. + - **D3 entry `manifest-permissions-string-list-retired`** carries the judgement the delete cannot make: what each dropped string meant in services, hooks, hosts and paths. + - **Liveness.** `manifest.permissions` stays `live` on corrected evidence. Its consumer is the marketplace install disclosure, and it refuses nothing at load. The four keys are now drilled. + - **The two skip reports reworded.** `AppPlugin`'s security registrar and `@objectstack/plugin-security`'s audience-binding reconciler both report a manifest-stage `permissions` they cannot read as permission sets. They now name the flat list as the retired legacy form. They behave as before. + + **Measured producers: none outside tests.** On origin/main e67ba80049, no manifest in `examples/`, `apps/`, `packages/`, `skills/` or `content/docs/` writes a list. The exceptions are five `@objectstack/spec` `manifest.test.ts` fixtures, re-triaged here, and the skip-report tests of `@objectstack/runtime` and `@objectstack/plugin-security`, which hand a list to an unparsed bundle on purpose and still pass. The same instrument finds those five fixtures, which is its control. At the objectui pin `a58626c88dc8`, nothing reads `manifest.permissions` (control: 91 `manifest.(id|name|version)` reads), and the install disclosure reads the structured block alone. Deployed and cloud-held manifests NOT MEASURED. +- bfc15d2: feat(spec,core)!: a position's permission sets are declared in `permissionSets` on its definition, and the authorization resolver reads positions, their sets and the set bodies from the security catalog (ADR-0131 D3/D4) + + Clause-②: yes + + + + **BREAKING** (the resolver stops reading the position → permission-set junction), shipped as `minor` under the launch-window convention for breaking changes. + + **FROM → TO.** A position's permission sets were `sys_position_permission_set` rows. They are now declared in `permissionSets` on the position's definition; junction rows no longer grant. + + ```ts + definePosition({ name: 'sales_rep', label: 'Sales Representative', permissionSets: ['crm_sales_user'] }); + ``` + + The one-line fix for an app that bound sets to its positions with a boot-time binder: name the sets in `permissionSets` on each position. + + **`@objectstack/spec`.** `PositionSchema` accepts `permissionSets`: a list of permission-set names (lowercase snake_case). Before, the key was refused with a pointer to the junction. Each name is resolved in the environment catalog when a holder's grants are resolved. A name the catalog does not hold confers nothing. + + **`@objectstack/core`.** `resolveUserAuthzGrants`, and every surface built on it (`resolveAuthzContext`, `hasPlatformAdminStanding`, the permission explainer, `runAs: 'user'` automation), reads the security catalog the security plugin binds to its engine (`bindSecurityCatalogReader`, `securityCatalogReaderOf`): + + - A held position grants the sets its definition's `permissionSets` names. The junction rows and the read of set rows by the junction's ids are gone. + - A held set, granted directly or through a position, has the body (`systemPermissions`, `tabPermissions`) of its catalog definition. A set that only a `sys_permission_set` row carries, with no definition, confers nothing — including a row inserted directly (a script or a harness, outside the product's doors). + - **Deactivation moves to the activation ledger** (ADR-0049, as ADR-0131 D3 and ADR-0126 §4 place it). A position or a permission set is switched off by a `sys_metadata_activation` row of type `position` or `permission` whose `active` is false, deployment-wide. The `sys_position` and `sys_permission_set` rows are no longer read at all, their `active` column included. A name with no ledger row is in effect. A composition that does not register the ledger object (no `PlatformObjectsPlugin`) switches nothing off and issues no ledger read. + - The exported `ADMIN_STANDING_SURFACE` follows what the resolver reads: `sys_permission_set`, `sys_position` and `sys_position_permission_set` leave it, and `sys_metadata_activation` enters in the role `derives`, with the columns `metadata_type`, `name` and `active`. The `kind` union of `ADMIN_STANDING_NON_TABLE_INPUTS` gains `security-catalog`, for the catalog's `admin_full_access` definition. + - `convertDeactivatedCatalogRows` turns the rows' `active: false` flags into ledger rows. A name some organizations switched off and others kept on is reported `conflicting`, because the ledger is deployment-wide. The upgrade ceremony applies it beside `convertPositionBindingRows`. ⛔ It never runs at boot. + - Platform standing still requires an organization-less `admin_full_access` user grant, now resolved by name in the catalog. + - An engine no security plugin started on has no catalog bound, and resolves no set and no position-bound set. That is what an engine with no permission tables provisioned resolved before. + - `convertPositionBindingRows` converts the junction rows into each position's `permissionSets`. It answers per name `declared`, `converted` or `conflicting`, and it lists dangling bindings. The upgrade ceremony applies it. ⛔ It never runs at boot. + + **`@objectstack/plugin-security`.** The plugin binds the catalog read (engine registry and metadata service) to its engine at `start()`. At `kernel:ready` the built-in `everyone` anchor declares the deployment's baseline in its `permissionSets`: the app's `isDefault` set and the platform's `member_default`. It is judged by the same high-privilege check against the stack's declared capabilities that the boot binding used. Under a wall the envelope now carries `member_default` beside the app's set, as it did under `single`. `member_default` carries no system or tab permission, and enforcement already applied it to every human as the additive baseline. A Setup edit of a position row under `single` keeps the `permissionSets` the stored definition names (the row has no column for it). + + **`@objectstack/plugin-auth`.** The last-administrator guard follows what the resolver reads. Its standing-key map judges `sys_metadata_activation` (`metadata_type`, `name`, `active`) in place of `sys_permission_set`. A ledger write that switches `admin_full_access` off is refused only when it would leave no administrator who can sign in, and switching it back on is never refused. The guard no longer judges `sys_permission_set` writes, because the resolver no longer reads that row. + + **`@objectstack/platform-objects`.** The position metadata form gains a `permissionSets` row, translated in zh-CN, ja-JP and es-ES. + + **`@objectstack/plugin-security`.** The name-fold warning's remedy now names the position definition's `permissionSets`, not a junction row. + + **What stops granting at upgrade, and what to do.** + + - **A deactivation made in Setup.** The Deactivate action on a position or a permission set writes the row's `active` column, which no longer switches anything off. ⚠️ Until the upgrade ceremony converts the rows (`convertDeactivatedCatalogRows`), a position or set deactivated that way **grants again**. No enable/disable door accepts the `permission` or `position` type yet, so a new deactivation has no in-product path. To revoke now, remove the assignments or the binding. + + - **A binding made in Setup.** Binding a permission set on a position's page writes a `sys_position_permission_set` row, and so does confirming an audience-binding suggestion. That row no longer grants. Until Setup writes the definition (objectui C9), declare the binding in the position's `permissionSets` through the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator) or in the app's code. + - **Under a wall (`group`, `isolated`): a position or permission set an organization authored in Setup.** It is a data row with no definition in the environment catalog (ADR-0131 D3), so it stops granting (ruling #15196 Q3 = A). To keep it, a platform administrator re-creates it as environment metadata through the metadata door, under the same name, with its `permissionSets` or its set body. No command promotes a row in place today. + - **Existing junction rows** are converted into definitions by the ADR-0131 upgrade ceremony (`os migrate organization-ownership --apply`) under `single` only, where its `authored-conversion` step applies `convertPositionBindingRows`. Under a wall (`group`, `isolated`) the ceremony converts no binding (ADR-0131 D3): its `rewrite` step refuses each binding whose position's definition does not name the bound sets, and the ceremony stops there until the operator acts and re-runs it. A position no catalog declares is declared with those sets in `permissionSets` through one door only: the deployment's code, or the metadata door (`PUT /api/v1/meta/position/:name`, platform administrator). A declared position the metadata door can edit takes the missing sets through that door. A definition a package ships is sealed (ADR-0131 D6), and no door on the database adds sets to it. A name that organizations bound differently is reported `conflicting` and is never merged. +- 60bcfe7: feat(plugin-security,rest,spec)!: the permission-set "Discard Overlay" action, its REST route and its contract member are retired; no door creates the overlay it discarded, and a database holding one is refused at cold boot (ADR-0131 cutover, ADR-0048) + + Clause-②: no (narrowing) + + + + **BREAKING** — a narrowing of the public surface: one HTTP route, one Setup action and one optional contract member are removed. Shipped as `minor` under the launch-window convention for breaking changes. + + The action deleted the environment-wide `sys_metadata` row that shadowed a package-declared permission set, then re-projected the set's `sys_permission_set` row. Neither half has anything left to do: + + - No door creates such an overlay. The metadata door refuses a save over a packaged permission set on every topology, whatever `OS_METADATA_WRITABLE` says. The data door refuses an edit of one (clone it instead). The data API cannot write `sys_metadata`. A package that declares a name the environment holds is refused at install. + - An overlay stored before those refusals makes the cold boot refuse the deployment with `422 NAMESPACE_CONFLICT`, so a server that could answer the route never holds one. + - The authorization resolver reads the security catalog, not `sys_permission_set` rows, so the re-projected row granted nothing. + + ### What was removed + + | removed | what to use instead | + | --- | --- | + | `POST /api/v1/security/permission-sets/:id/discard-overlay` | `os migrate security-catalog-overlays` with the flags and environment the deployment boots with lists every overlay row with no server running; `--apply` deletes them (a history tombstone per row). | + | the **Discard Overlay** action on `sys_permission_set` in Setup | the same offline step. To keep a customization of a packaged set, clone it under a new name (the **Clone** action) and assign the clone. | + | `ISecurityService.discardPermissionSetOverlay` and `PermissionSetOverlayDiscardResult` (`@objectstack/spec/contracts`) | nothing: delete the call and the import. `tsc` names each one. | + | `discardPermissionSetOverlay`, `PermissionSetNotFoundError`, `PermissionSetOverlayStateError` and the `PermissionSetOverlayDiscardDeps` / `PermissionSetOverlayDiscardResult` types (`@objectstack/plugin-security`) | nothing: delete the import. | + + **The one-line fix:** stop calling the route or the member, and clear any overlay offline with `os migrate security-catalog-overlays --apply` before the first boot on this release. + + `ERROR_CODE_LEDGER['@objectstack/plugin-security']` no longer lists `INVALID_STATE` or `NOT_FOUND`: the removed action was their only producer in this package. Both codes stay registered under `@objectstack/rest`, and the union of codes is unchanged. + + The permission-set drift diagnostic no longer names the action in its `overlay_shadow` detail, and the boot reading of shadowed packaged sets names the offline step instead. +- 66949b7: feat(plugin-security)!: the four security-catalog row objects are retired — `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` (ADR-0131 D3) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. + + Positions, permission sets and capabilities live in the security catalog — the environment registry, fed by package declarations and by environment metadata saved through `/api/v1/meta` (ADR-0131 D3). The four row objects that mirrored them are no longer registered, so their data and REST routes and their Setup object pages are gone. No table is dropped and no row is deleted. + + FROM → TO: + + - `GET|POST|PATCH|DELETE /api/v1/data/sys_permission_set` → `GET|PUT|DELETE /api/v1/meta/permission/:name`; to copy a set, `POST /api/v1/security/permission-sets/:name/clone` with `{ name, label }`. + - `… /api/v1/data/sys_position` → `/api/v1/meta/position/:name`. A position's permission sets are its definition's `permissionSets`. + - `… /api/v1/data/sys_position_permission_set` → the position definition's `permissionSets` (`PUT /api/v1/meta/position/:name`). + - `… /api/v1/data/sys_capability` → read-only `GET /api/v1/meta/capability`; a package declares a capability with `defineCapability` (`stack.capabilities`). + - Setup → Access Control → Positions / Capabilities / Permission Sets now open Setup's catalog lists (`/apps/setup/metadata/?scope=environment`) instead of the object pages; the `sys_position` record page (`SysPositionDetailPage`) is removed. + - An object permission on one of the four names in a permission set grants nothing and can be deleted. + + What else changes: + + - `sys_user_permission_set.permission_set_id` is a plain text column now, not a lookup: same column, same stored ids, same indexes. It is not written and grants nothing; a grant names its set by `permission_set`. + - A permission set saved through the metadata door is written to no row: the `permission` mutation projector, the `sys_permission_set` and `sys_position` data-door write-throughs and the row-only position backfill are no longer registered. + - On a fresh `single`-posture database the first human is promoted to platform administrator without reading any catalog row. An unscoped grant written before `sys_user_permission_set.permission_set` existed (it names no set) still withholds the promotion until the organization-ownership migration names it. + - Uninstalling a package still revokes every grant naming a permission set the package declared, by the names the uninstalling door captures before the withdrawal (`declared`, `captureUninstallDeclared`), and now by those names alone: the cleanup reads and removes no `sys_permission_set` or `sys_position_permission_set` row, so the `sets` and `positionBindings` counts on its outcome are always 0. A grant written before `sys_user_permission_set.permission_set` existed names no set and is not reached; the organization-ownership migration names it. + - On a `group` or `isolated` deployment the data-door half of the walled catalog refusal goes with the objects: a data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability` answers `404 OBJECT_NOT_FOUND` for every caller instead of `403 PERMISSION_DENIED` below the platform operator. The refusal at the `/meta` write doors, the permission-set clone door and the `/packages` write doors is unchanged. + - `capability-reference-unknown` no longer counts a `sys_capability` seed row as a declared capability — no runtime reader consults one. Declare the capability with `defineCapability`. + - On an existing database `os migrate plan`'s unmanaged-table sweep now lists the `sys_position`, `sys_permission_set`, `sys_position_permission_set` and `sys_capability` tables: they keep their rows and no object declares them any more. The sweep only reports; nothing is dropped, and the tables stay for the organization-ownership migration. + + Removed exports, no replacement: + + - `@objectstack/plugin-security`: the object definitions `SysPosition`, `SysPermissionSet`, `SysPositionPermissionSet`, `SysCapability` (from `./objects`); `securityObjects` lists the remaining three. + - `@objectstack/platform-objects/pages`: `SysPositionDetailPage`. + - `@objectstack/spec`: `SystemObjectName.POSITION`, `SystemObjectName.PERMISSION_SET`; the four names leave `PLATFORM_OBJECTS_BY_PACKAGE['plugin-security']` and `PLATFORM_PROVIDED_OBJECT_NAMES`. + - `@objectstack/core`: `isRowActive` and `ActivatableRow` — a catalog row's `active` column decides nothing; deactivation is the activation ledger's (`sys_metadata_activation`). + - `@objectstack/objectql`: the published `PLATFORM_OBJECT_TENANCY` record loses its `sys_permission_set` key (the other three names were never in it). A removed key of an exported constant is a narrowing, so `@objectstack/objectql` takes `minor` under the launch-window convention for breaking changes. +- 4806b18: Under a walled posture a package that carries security catalog items is the platform operator's: tenants are refused installing, upgrading, uninstalling or deleting it + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant (managed packages, Studio)."). + + **Why.** A package registers its positions, permission sets and capabilities environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack, with an organization owner granted `manage_metadata` inside their own organization (never the platform operator): + - the owner re-installed an operator-installed package at a new version through `install-local`, adding `manage_users` to a permission set another organization's member holds, and that member's next resolution carried `manage_users`; + - the owner's `install-local` uninstall of that package took the set away from that member; + - the owner's `DELETE /api/v1/packages/:id` of a runtime package the operator had saved a permission set into did the same. + + The existing name-conflict refusal stops only a package that shadows a name another holder has, never a write by the package that holds it. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `POST /api/v1/marketplace/install-local` (`@objectstack/cloud-connection`) of a package whose incoming version declares a position, permission set or capability, or, on an upgrade, whose installed version does: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the package is registered or the ledger written. The whole package is refused, since a package installs whole. The message names the posture, and `error.details.catalogItems` lists the items. + - `DELETE /api/v1/marketplace/install-local/:id` of a package whose installed version declares one: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the ledger is touched. + - `DELETE /api/v1/packages/:id` on the runtime dispatcher, of a writable package holding stored positions or permission sets: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is deleted. + + **Unchanged.** A package with no catalog item installs, upgrades and uninstalls as before, for the same callers. The platform operator does all of the above as before. Under `single` (and with no `tenancy` service) nothing changes. Disabling a package is unchanged: it was measured not to change what any organization resolves. + + **The remedy.** The platform operator installs, upgrades and removes catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-writes-retired`. Both doors ask the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), once per catalog type the package declares. `@objectstack/objectql` now exports `declaredSecurityCatalogNames`, the engine's own reading of what a manifest declares, so a package door judges the declarations the engine registers. +- 1802315: Under a walled posture the `/packages` verbs that publish, discard, roll back, revert, duplicate or adopt security catalog items are the platform operator's + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant"). + + **Why.** A package's positions and permission sets are environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack: the operator published a permission set in a writable package, and another organization's member held it. An organization owner granted `manage_metadata` inside their own organization (never the platform operator) then: + - published the operator's pending draft adding `manage_users`, and that member's next resolution carried it; + - rolled the package back, and the member lost it; + - reverted the operator's commit, and the member lost it; + - copied the set, under the same name, into a package of their own, and `GET /meta` then served their copy; + - discarded the operator's pending draft (through `revert`, and `discard-drafts` is the same discard by a second door); + - adopted the environment's catalog rows into their own package. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner). Every door is on the runtime dispatcher, and each **FROM** is: admitted to any `manage_metadata` holder. Each **TO** is `403 PERMISSION_DENIED`, naming the posture, before the verb runs: + + - `POST /api/v1/packages/:id/publish-drafts`, when the package has a pending position, permission-set or capability draft. + - `POST /api/v1/packages/:id/revert`, when the package has such a draft. + - `POST /api/v1/packages/:id/discard-drafts`, when the package has such a draft. + - `POST /api/v1/packages/:id/rollback`, when a commit it would undo touched a position, permission set or capability. + - `POST /api/v1/packages/:id/commits/:commitId/revert`, when that commit touched one. The commit's own items are judged: the verb takes the commit id alone, so the URL's package segment changes nothing. + - `POST /api/v1/packages/:id/duplicate`, when the source package's published environment rows (the ones it copies) hold one. + - `POST /api/v1/packages/:id/adopt-orphans`, when the environment's orphaned rows include one. + + **Unchanged.** A package whose drafts, commits or rows carry no catalog item is handled as before, for the same callers. The platform operator runs every verb as before. Under `single` (and with no `tenancy` service) nothing changes, and no extra read happens: the verdict is asked before the items are read. + + **The remedy.** The platform operator publishes, discards, rolls back, reverts, duplicates and adopts catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-verbs-retired`. Every door asks the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), through the same helper as the package delete. +- 06cc45d: Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). + + **Why.** The catalog is environment-level, and every organization resolves it by name. Measured on a booted `isolated` and `group` stack: an organization owner granted `manage_metadata` inside their own organization saved `PUT /meta/permission/:name` over a permission set another organization's member holds, and that member's next resolution carried the change. One organization's write changed what another organization's members resolve. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for `position` or `permission`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. + - The same doors for `capability`: admitted where they were admitted. `capability` is a code-only type, so these doors already refused its writes before this change; only the sentence the caller reads changes, to the posture refusal. For `capability`, the accept set narrows at the data door on `sys_capability`, below. + - `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. + - A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. This includes an ADR-0090 D12 delegate whose `adminScope` carries `authorEnvironmentSets`: under a wall it can no longer author environment permission sets at the data door. Its assignment and binding paths are untouched. + + **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. + + **The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-tenant-writes-retired`. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. +- 7895671: feat(metadata-core,rest,runtime,plugin-email,spec)!: the `/meta` doors carry no organization; organization-admin metadata authoring closes and `manage_org_presentation` retires (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis: environment metadata written by Studio belongs to the whole deployment. The `/meta` doors of both transports (`@objectstack/rest` and the runtime dispatcher's `/meta` branch) used to thread the caller's active organization into writes and reads of the five `allowOrgOverride: true` types (`view`, `dashboard`, `report`, `translation`, `email_template`). They no longer do, for any type, and the read and the write flip together. + + **What changes.** + + - **Writes land environment-wide.** `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` on `/api/v1/meta/:type/:name` hand the protocol no organization, so the row and its audit and history rows carry `organization_id` NULL, whatever the caller's active organization. + - **Reads are environment → code.** The item read (both cache arms), the list, the layered view (`/layers` and `?layers=true`), `/published`, `?state=draft`, `GET /meta/_drafts`, `/history`, `/diff`, `/audit` (the environment rows, `organizationId: null`), `GET /meta/diagnostics` and `/references` name no organization. + - **An organization admin's metadata write is refused.** `metaWriteCapabilityVerdict` admits `isSystem` or `manage_metadata` only. A caller holding `manage_org_presentation` and not `manage_metadata` is answered `403` on all four item doors (`FORBIDDEN` on REST, `PERMISSION_DENIED` on the dispatcher) with `… requires the \`manage_metadata\` capability.`, for every type and whatever its active organization. + - **`manage_org_presentation` retires** from `PLATFORM_CAPABILITIES` (`@objectstack/spec/security`). A permission set naming it still parses and loads, and its other grants still apply; the grant itself admits nothing. The `sys_capability` row seeded for it earlier is not pruned (the seeder upserts only); an operator may delete it in Setup. + - **The email-template boot sweep** (`@objectstack/plugin-email`) reads the effective templates environment → code, as the door serves them; it no longer reads in the Default Organization. + + **What moves for consumers.** + + - FROM `import { organizationIdForMetaWrite } from '@objectstack/metadata-core'` TO nothing: a `/meta` write carries no organization. Delete the call and the `organizationId` it fed. + - FROM `import { ORG_PRESENTATION_AUTHORING_CAPABILITY } from '@objectstack/metadata-core'` TO nothing: delete the import. + - FROM `metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType, activeOrganizationId, operation })` TO `metaWriteCapabilityVerdict({ isSystem, systemPermissions, operation })`: drop the two members. + - FROM `import { metaReadOrganizationId } from '@objectstack/rest'` TO nothing: a `/meta` read carries no organization. `metaCallerOrganizationId` stays. + - FROM `bootstrapEffectiveEmailTemplates(engine, metadataService, { protocol, tenancy })` TO `{ protocol }`: the `tenancy` source is gone. + - A permission set granting `manage_org_presentation`: grant `manage_metadata` to whoever must author those five types, and delete the stale grant. + + **What a deployment observes.** An overlay row an earlier release stored under an organization stays in `sys_metadata` untouched, and is no longer served by any `/meta` read or projected by the email-template sweep until the promotion ceremony (ADR-0131 C7) carries it to the environment layer. Under the `single` posture that is every earlier Studio save of the five types, because it was filed under the Default Organization: on the `/meta` doors such an edit reads as reverted to the environment or code definition. Re-save the item in Studio to make the edit live on the `/meta` doors now. Public forms are the exception: until that ceremony the anonymous form doors read a form `view` in the Default Organization and prefer its overlay for the form's body, while a withdrawal in either layer closes the form, fail-closed. So a legacy organization overlay of a public form keeps serving its body there: a Studio re-save of that body (an environment row) does not change the body the public form serves, and a Studio withdrawal (an environment row) still closes it. + + **What does not change.** Saves of every other type were already environment-wide. Flow saves, the capability gate's answer for `manage_metadata` holders and `isSystem`, the protocol's own organization-scoped refusals, and the `/packages` doors are untouched by this change. +- b389e43: feat(metadata-protocol,runtime,service-automation,spec)!: the metadata protocol refuses every organization-scoped write, and an uninstall is environment-wide (ADR-0131 D6/D12) + + Clause-②: yes (narrowing) + + + + **BREAKING, in both directions.** It narrows: an organization-scoped metadata write is refused, and request keys retire. It widens one refusal: a package uninstall naming neither `organizationId` nor `allTenants`, refused before with `400 TENANT_SCOPE_REQUIRED`, is now accepted and runs environment-wide. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The `/meta` doors already carry no organization; now the metadata protocol itself refuses an organization-scoped write from every door, and the per-organization write path behind it is deleted. + + **What changes.** + + - Every protocol write that names an organization is refused with `403 NOT_OVERRIDABLE`, before anything is read or written, for every metadata type and every tenancy posture. This covers `saveMetaItem` (draft and publish), `publishMetaItem`, `deleteMetaItem`, `rollbackMetaItem`, `revertCommit`, `rollbackToPackageCommit`, `publishPackageDrafts`, `discardPackageDrafts`, `revertStoredPackage`, `duplicatePackage` and `reassignOrphanedMetadata`. The message's first sentence names the tenancy posture in force. The five types that declared `allowOrgOverride` (`view`, `dashboard`, `report`, `translation`, `email_template`) and the `OS_METADATA_WRITABLE` hatch no longer open an organization scope. + - The audit ledger (`sys_metadata_audit`) and the commit ledger (`sys_metadata_commit`) record `organization_id` NULL. + - The `/packages` doors of the runtime dispatcher thread no organization into any verb: publish-drafts, discard-drafts, the commit list, commit revert, rollback, revert, adopt-orphans, duplicate, delete, and the package manifest read. + - `deletePackage` retires its `organizationId` and `allTenants` request keys and both `400 TENANT_SCOPE_REQUIRED` refusals. The dispatcher's `DELETE /api/v1/packages/:id` no longer refuses an operator with no active organization. An uninstall removes every row bound to the package in this environment. Who may uninstall is the door's operator gate, as before. + - A seed published with a package no longer takes the publisher's active organization: a seed dataset names the organization it populates (ADR-0131 D9). + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `SaveMetaItem` / `PublishMetaItem` / `DeleteMetaItem` request (`@objectstack/spec`) | drop it: the write lands environment-wide. The key is stripped at a spec parse (the schemas are not strict) and refused at the protocol: a request still naming one answers `403 NOT_OVERRIDABLE` | + | `organizationId` on any other protocol write verb's request | drop it, same refusal | + | `deletePackage({ packageId, organizationId })` or `deletePackage({ packageId, allTenants: true })` | `deletePackage({ packageId })`; either retired key answers `400 INVALID_REQUEST` and removes nothing | + | `DeletePackageRequest.organizationId` / `.allTenants` (`@objectstack/metadata-protocol`) | gone from the type | + | `UninstallCleanup`'s `organizationId` argument | gone; a cleanup receives `{ packageId, actor? }` | + | `TENANT_SCOPE_REQUIRED` in the error-code ledger | retired; no producer emits it | + | `findPlatformScheduleOrgGaps`' `organizationId` input | gone: every write is platform-level | + | a `seed` draft whose records carry no `organization_id`, relying on the publisher's active organization under `group` | set `organization_id` on each record (ADR-0131 D12, item 12); under `group` a seed record that names no organization is refused at load, and under `single` the loader still derives the Default Organization | + + **What a deployment observes.** + + - Rows stored organization-scoped before this release are not touched by any write. `POST /meta/_migrate-stored` reports each one as `skipped` and names the promotion ceremony (ADR-0131 C7); the stored-flow credential move reports such a flow as not moved (`NOT_OVERRIDABLE`) and logs that its credential is still in cleartext in that row — rotate it. A legacy organization-scoped draft is no longer promoted, discarded or reverted by a package verb, a commit recorded in a legacy organization layer is refused by `revertCommit`, and `duplicatePackage` and adopt-orphans copy or adopt the environment's rows only. + - An uninstall removes the package's legacy organization-scoped rows with it, as the declared cross-tenant uninstall did. + + **What does not change.** The protocol's reads still accept an organization and still serve legacy organization rows; that narrowing is a later stage. The anonymous form doors' read of the Default Organization's layer is unchanged. +- c8b062f: feat(metadata-protocol,metadata-core,objectql,rest,plugin-security,spec)!: every metadata read is environment → code; legacy organization rows and overlays of sealed managed content are reported at boot, not served (ADR-0131 D6) + + Clause-②: no (narrowing) + + + + **BREAKING (narrowing).** No read that is refused today is admitted. Graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N`. + + ADR-0131 D6 retires the per-organization overlay axis. The doors carry no organization and the protocol refuses every organization-scoped write; now every protocol read is environment → code as well. + + **What changes.** + + - The item, list, layered, cached (ETag), history, diff, audit, drafts and commit-timeline reads, the `_lock` gate's overlay layer, search's page sweep, diagnostics and references serve the environment's stored row, else the code packages' definition — whatever organization a caller names. A row stored organization-scoped by an earlier release is served by no read and loaded by no boot. + - `overlayScope` on the layered read is `'env'` or `null`; `'org'` is gone. + - `listDrafts` lists the environment's drafts only, and its rows carry no `organizationId`. + - `listCommits` lists the environment's commits only, for every caller: a legacy organization commit is not shown, so an operator on the packages door no longer sees every organization's legacy commits, and a rollback plan never contains one. + - [Triage ruling Q1 → C] An environment row that overlays an item a managed package ships, on a type sealed against overlays (any type whose registry entry admits no overlay — a managed flow, action, hook, object or datasource, for example — written through the `OS_METADATA_WRITABLE` hatch before the seal), is not served: the package's definition is. An `object` such row is not loaded at boot either, so the engine keeps the packaged schema. A stored fork of a code-declared permission set keeps its own ruling instead: the reads do not decline it, a cold boot refuses a deployment that holds one (ADR-0048), and the offline `os migrate security-catalog-overlays` removes it. + - `@objectstack/objectql` binds the environment's authored hook and action rows only; a legacy organization-scoped hook or action row is no longer bound. + - Boot names both populations, once each: `[metadata_org_scoped_unserved]` lists every legacy organization row per type (active and draft), plus the legacy rows of `sys_metadata_commit`, `sys_metadata_history` and `sys_metadata_audit`, and names the v18 migration ceremony (`os migrate`, ADR-0131 D10) that carries them; `[metadata_sealed_overlay_unserved]` lists the sealed overlays per type with the two remedies. Nothing is deleted or rewritten. + - The anonymous public-form doors keep reading the Default Organization's legacy `view` layer, fail-closed, until that ceremony (triage ruling Q3 A) — through the protocol-internal `legacyFormOrganizationId` key, which no spec request declares and which the protocol honours for `view` alone. + - The seed loader's refusal of an unowned record now prescribes `organization_id` on the record first: a seed published through a package carries no caller organization. + + **What moves for consumers.** + + | From | To | + |:--|:--| + | `organizationId` on a `GetMetaItems` / `GetMetaItem` / `GetMetaItemLayered` / `AuditMetaItem` / `HistoryMetaItem` / `GetMetaItemCached` request (`@objectstack/spec`) | drop it: the read is the environment's. The key is stripped at a spec parse (the schemas are not strict) and no longer type-checks | + | `organizationId` on a `listDrafts` / `listCommits` / `diffMetaItem` / `getMetaDiagnostics` / `findReferencesToMeta` request (`@objectstack/metadata-protocol`) | drop it | + | `ListDraftsResponse` draft `organizationId` | gone: every listed draft is the environment's | + | `SysMetadataRepository.listDrafts` row `organizationId` (`@objectstack/metadata-protocol`) | gone: every listed row is an environment draft, whichever repository asks | + | `overlayScope: 'org'` (`GetMetaItemLayeredResponse`) | only `'env'` or `null` | + | `declaresOrgOverride`, `organizationIdForMetaRead` (`@objectstack/metadata-core`) | removed — no read takes an organization. Read `allowOrgOverride` off `DEFAULT_METADATA_TYPE_REGISTRY` where a type's overlay channel is the question | + | `MetadataAuthoringGateContext.organizationId` (`@objectstack/metadata-protocol`) | removed — no write is organization-scoped, and no gate read it | + | `organizationId` on `projectPermissionMutation`'s `evt` (`@objectstack/plugin-security`) | drop it: the projection reads the environment → code layered read, whatever organization the event names | + | a legacy organization-scoped `sys_metadata` row a deployment relied on | re-save the item in Studio to make it environment-wide now, or wait for the v18 migration ceremony, which promotes it | + | an environment overlay of a managed item on a sealed type (flow, action, hook, object, datasource …) | re-express the change as a new item under a new name (a linkage-free clone), then delete the stored row; or delete the row | + + **What does not change.** No stored row is deleted, rewritten or migrated. The anonymous form doors' read of organization-layer withdrawals is unchanged. +- 117d34d: feat(metadata-core,metadata-protocol,metadata,platform-objects,spec)!: `sys_view_definition` retires as inert (ADR-0131 D13) + + Clause-②: no (narrowing) + + + + **BREAKING**, graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, and the fixed group is already majored by the line's opening marker, so this ships in an `18.0.0-next.N` like every other stage. + + `sys_view_definition` was declared for runtime-authored shared and personal views (ADR-0017) and never used for them: no framework code writes or reads its rows, and the Studio console's view doors write the ADR-0005 `view` overlay in `sys_metadata` through the metadata API. ADR-0131 D13 retires it. A runtime-authored view is a `view` metadata item, written through `PUT /api/v1/meta/view/` (the client's `meta.saveItem` for type `view`); nothing about that path changes. + + **What moves for consumers.** + + - **The object.** FROM `import { SysViewDefinitionObject } from '@objectstack/metadata-core'` (or from `@objectstack/platform-objects`, or its `/metadata` subpath) TO nothing: delete the import. Neither `MetadataPlugin` nor the metadata protocol assembly registers the object any more, so the generic data door no longer serves it. + - **The migration exports** of `@objectstack/metadata-protocol`. FROM `ensureViewDefinitionActiveIndex`, `resolveIndexExec`, `buildActiveIndexSql`, `VIEW_DEFINITION_TABLE`, `VIEW_ACTIVE_INDEX_NAME`, `VIEW_ACTIVE_PROBE_INDEX_NAME`, `VIEW_ACTIVE_INDEX_COLUMNS`, `EnsureViewIndexLogger`, `EnsureViewIndexStatus` and `EnsureViewIndexResult` TO nothing: delete the import. `classifyIndexFailure` and the `IndexExec` type are still exported, unchanged, from the shared index-migration module. + - **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['metadata-core']` (`@objectstack/spec/system`) lists four objects, and `isPlatformProvidedObjectName` answers `false` for `sys_view_definition`. A stack that names it (a lookup target, a flow trigger, a permission entry, a platform-global declaration) is now flagged as naming an unknown object: remove the reference. + - **`os migrate duplicates`.** `runtimeIndexPreflight` carries three entries (the two `sys_metadata` overlay indexes and `sys_setting`'s row identity), with no `idx_sys_view_def_active` entry. A serving boot no longer runs the active-row index migration for the table, and no longer logs its `sys_view_definition active-row index` lines. + - **Translations.** The platform translation bundles no longer carry `sys_view_definition` keys. + + **Existing databases.** Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_view_definition`, with any rows a caller wrote through the generic data door; nothing reads them. `os migrate apply --allow-destructive` does not drop it either, because it reconciles declared objects only (measured on one database: it dropped an orphaned column of a declared table and left this table and its row in place). On a project with a host config, `os migrate plan` lists it among the platform-prefixed tables nothing declares. Export any row worth keeping; dropping the table is the operator's call, by hand: `DROP TABLE sys_view_definition`. +- 6729e10: feat(plugin-audit,plugin-security)!: the compliance ledger `sys_audit_log` loses its injected organization column; the organization a row is about stays in `tenant_id`, and an organization reader is scoped on it by a platform row policy (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + + Some ledger rows are about deployment-level actions no organization owns: a change of platform-administrator standing at boot, a change to a global setting, plugin-auth's administrative user writes. An injected organization column made the tenant wall the ledger's anchor, so under a walled posture those rows were hidden from every reader, platform administrators included. ADR-0131 D7 takes the column off: the ledger is governed by object permission, and the organization a row is about is the plain attribution field `tenant_id`, which the tenant-field resolver does not claim. + + - **`sys_audit_log`** (`@objectstack/plugin-audit`) declares `systemFields: { tenant: false }`, so the registry injects no `organization_id` and a new table is provisioned without it. `tenant_id` (a lookup to `sys_organization`) is unchanged and is the only organization column. The record mirror, the record-view writer and the sign-in writer stamp it as before and no longer stamp `organization_id`. A write that still names `organization_id` on the ledger is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. + - **The read scope** (`@objectstack/plugin-security`, the shipped permission sets): a platform row policy, `sys_audit_log_org` (`tenant_id == current_user.organization_id`), in `organization_admin` (and its no-bypass variant), `member_default` and `viewer_readonly`. `organization_admin` also names `sys_audit_log` explicitly, read only and without `viewAllRecords` / `modifyAllRecords`: its wildcard's superuser bypass would otherwise skip the policy on an object with no tenant column. Under an organization wall an organization administrator or viewer reads the rows about its active organization; a platform administrator (`admin_full_access`) reads every row, the rows about no organization included. Under `single` the policy is stripped by provenance (ADR-0105 D3), as every platform tenant policy is. + - **`config_change` rows** (`@objectstack/service-settings`): a GLOBAL-scope settings change is about no organization, so its ledger row carries no `tenant_id`, whatever organization the writing session had active. Tenant- and user-scope changes keep the writer's organization. The settings writer and plugin-security's platform-admin standing writer no longer probe for or stamp `organization_id`. + - **Retention** (`@objectstack/objectql`): a tenant-scope `lifecycle.retention_overrides` window on `sys_audit_log` partitions the reaper's and the archiver's passes on `tenant_id`, and the global pass keeps the rows with no `tenant_id`. + - **`view_all_audit_log`**: its description (`@objectstack/spec/security`) now names the row scope it does not lift; the capability still lifts only the parent-record read gate. + + **What moves for consumers.** + + - **Authored references.** A filter, list-view column, report grouping, formula or seed key that names `organization_id` on `sys_audit_log` names `tenant_id` instead; the two held the same value on every row a writer wrote. + - **Who reads what, under a wall.** A platform administrator now also reads the rows about no organization, global settings changes included. An organization administrator reads exactly its active organization's rows. Under the `group` posture an organization reader is scoped to the ACTIVE organization's rows, not the union of its memberships. A permission set an application ships that grants `viewAllRecords` on the ledger (directly or through a wildcard) skips the row policy, as it skips Layer 1 on every object the wall does not cover. + - **Who reads what, under `single`.** The row policy is stripped under `single` (ADR-0105 D3), as every platform tenant policy is. So a deployment that holds more than one organization under `single` serves every organization's ledger rows to every ledger reader. The remedy is a walled posture. + - **Existing databases — nothing moves automatically** (ADR-0131 D14). Schema sync is additive: the physical `organization_id` column stays and the boot drift report names it orphaned. The v18 upgrade ceremony confirms its values equal `tenant_id` and drops it (`os migrate apply --allow-destructive`), reporting any row where they differ. +- 1920cf3: feat(platform-objects,service-automation,service-realtime)!: seven deployment-level platform tables lose their injected organization column, and reading them needs `manage_platform_settings` (ADR-0131 D7) + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + `sys_job`, `sys_job_run`, `sys_job_queue`, `sys_flow_dispatch`, `sys_migration`, `sys_migration_journal` and `sys_presence` hold deployment-level state. No writer attributes a row of any of them to an organization: every write is a system-context write whose row names none, and nothing writes `sys_presence` through ObjectQL at all. So the injected `organization_id` column only ever held NULL. ADR-0131 D7 takes it off: each object now declares `systemFields: { tenant: false }`. + + With no column there is no tenant wall, so these tables are governed by object permission. Each also declares `requiredPermissions: ['manage_platform_settings']`. Without that gate, a walled deployment's `organization_admin`, whose grant carries the superuser bits on every object, would read every other organization's job errors, queued payloads, dispatch keys and migration traces. + + **What moves for consumers.** + + - **The column.** `organization_id` is no longer a field of these seven objects. A filter, list-view column, report grouping, formula or seed key naming it on one of them is now an unknown field. Delete the reference: no organization owns a row of these tables. + - **Who reads, on a walled posture** (`group` or `isolated`). Before: the wall compared the NULL column to the caller's organization, so every reader got zero rows, platform administrators included (unless the deployment declared the table platform-global, which stood the wall down). Now: a principal holding `manage_platform_settings` (platform administrators hold it) lists every row; anyone else is refused `403 PERMISSION_DENIED`. + - **Who reads, on the `single` posture.** Before: any principal with a read grant on the object read every row, an organization administrator included. Now: only a principal holding `manage_platform_settings` reads; an organization administrator who is not a platform administrator is refused `403 PERMISSION_DENIED`. Grant the capability to an operator who needs these tables. + + **Unchanged.** Every platform writer and reader of these tables uses a system context, which no capability gate applies to, so job scheduling, the queue, flow dispatch, migration flags and the migration journal behave as before. The physical unique indexes are unchanged: none of these objects declares an organization-scoped one. + + **Existing databases.** Schema sync only adds, so the physical `organization_id` column stays on each existing table (with its index, where the deployment indexed it), and the boot drift report names it orphaned. By the writer census it holds only NULL, so dropping it loses nothing: `os migrate apply --allow-destructive` drops it, the remedy the drift report names. +- 41d0d40: feat(objectql,plugin-security)!: an object a deployment declares platform-global gets no organization column on that deployment — the #12699 declaration made total (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING** on a deployment whose `org-scoping` service declares `platformGlobalObjects`. Nothing changes on any other deployment. + + ADR-0131 D7: "an object a deployment declares platform-global gets no organization column on that deployment (the injected-columns plan reads the declaration), so Layer 0 and the driver agree by having nothing to scope." Until now the declaration stood the organization wall down for the object while the object kept its `organization_id` column, so the SQL driver went on scoping a read by the caller's organization that the wall no longer scoped. + + - **The plan** (`@objectstack/spec`): `resolveInjectedSystemColumns(def, deployment?)` takes the deployment's validated `platformGlobalObjects` as an optional second argument. An object it names is planned with no `organization_id`, and nothing else in its plan moves. With no second argument, or an empty list, the plan is the same as before. Author-time callers (the linter, the import mapper, the tenancy census) have no deployment and pass none. + - **The registry** (`@objectstack/objectql`): `ObjectQLPlugin` reads the declaration at the top of `start()`, before the first schema sync, and installs it with the new `SchemaRegistry.setDeploymentPlatformGlobalObjects()`. A declared object is registered with no `organization_id`, no tenant index and `systemFields: { tenant: false }`, and its table is created without the column. An object registered earlier, inside another plugin's `init()`, is re-planned at that moment, before its table exists. The `/meta` read exits serve the same shape. On the way back in, the write path removes the recorded `tenant: false`, so a Studio save stores the body the author wrote. The plugin logs the declared list once at boot. + - **The order** (ADR-0116): every `init()` completes before any `start()`, so a provider that registers `org-scoping` in its `init()` has registered by the time the engine reads it. `OrganizationsPlugin` now declares `providesServices: ['org-scoping']` (`@objectstack/organizations`). A provider that registers the service later, with a different declaration than the one the columns were planned from, fails the boot at `kernel:ready` with an error that names both lists and the fix. + - **The stand-down is retired** (`@objectstack/plugin-security`): `getObjectSecurityMeta` no longer reads `platformGlobalObjects`. A declared object reaches the security layer as its own `systemFields.tenant: false`, the same way every deployment-level object does: Layer 0 composes no organization predicate on it, a wildcard `organization_id` policy does not apply to it, and the ADR-0123 D2 no-active-organization write refusal does not fire on it. The `[security] deployment declares N platform-global object(s)` boot line is gone; the engine logs the list instead. + - **One reader** (`@objectstack/core`): `readDeploymentOrgScopingEntitlement` moved from plugin-security into `@objectstack/core` and is exported there, with its rules unchanged. An absent key declares nothing. A malformed key is refused whole: no object is declared, and the consumer of that key warns once. The engine warns for a malformed `platformGlobalObjects`, and plugin-security for a malformed `suppressUnboundedOrgAdminGrant`. `suppressUnboundedOrgAdminGrant` and its behaviour are unchanged. + + **What moves for consumers.** + + - **On the declaring deployment**, a declared object has no `organization_id`. FROM an authored filter, list-view column, report grouping, formula or seed key naming `organization_id` on that object, TO the same reference with that field removed. A write that still names it is refused `INVALID_FIELD`, and a filter on it `INVALID_FILTER`. The object is governed by object permission, not by the organization wall: a reader the object permission admits reads every row, where the SQL driver used to narrow a read to the caller's organization and the rows with no organization. + - **An object that declares its own `organization_id`** keeps that column, because it is the author's and not the platform's, and the organization wall keeps scoping it. The engine warns once at boot and names the object. Remove the authored field, or drop the object from the declaration. + - **Author-time tools** have no deployment, so they still list `organization_id` among a declared object's columns. Only the declaring deployment differs, and it refuses the name at runtime. + - **Existing databases: nothing moves automatically** (ADR-0131 D14). Schema sync is additive, so on a declaring deployment the physical `organization_id` column stays and the boot drift report names it orphaned. The operator removes it once the rows need nothing from it. No boot step reads or writes it. +- c52bfb4: feat(service-settings,platform-objects)!: the settings cascade's global rung moves to the tenant-less `sys_platform_setting`, and `sys_setting.scope` no longer declares `global` (ADR-0131 D7) + + Clause-②: yes (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes (Changesets pre mode is not on yet). + + A settings value for a key declared at `global` scope is a deployment-wide value: the mail transport, the SMS, storage, AI and knowledge providers, auth policy, the lifecycle retention defaults. It used to be a `scope: 'global'` row of `sys_setting`, a tenant-scoped table, where the injected `organization_id` column only ever held NULL and a walled posture hid the row from every reader. ADR-0131 D7 moves the rung out: it is now stored in a new platform object, `sys_platform_setting`. + + - **`sys_platform_setting`** (registered by the settings service, beside `sys_setting`): one row per `(namespace, key)` for the deployment, with the `value`, `value_enc`, `encrypted`, `locked`, `locked_reason` and `updated_by` columns of a settings row, and no `scope`, `user_id` or `organization_id` (`systemFields: { tenant: false }`). It is governed by object permission: a generic data read needs `manage_platform_settings` (`requiredPermissions`), which platform administrators hold. Writes go only through the settings door. + - **`SettingsService`** writes a global-scope key to `sys_platform_setting` and reads the cascade's global rung from there alone. Its `sys_setting` reads exclude `scope = 'global'`, so there is one source per rung. The cascade order, the lock semantics, `SpecifierScope` and the `source: 'global'` resolution value are unchanged. A global-scope change's `config_change` audit row names `sys_platform_setting` (`CONFIG_CHANGE_GLOBAL_OBJECT_NAME`); tenant- and user-scope changes still name `sys_setting`. + - **`sys_setting.scope`** no longer declares the `global` option: no write produces such a row. `sys_setting_audit.scope` keeps it, because a global-scope change is still audited there. + - **`os secret orphans` / `os secret rewrap`**: the settings family of the `sys_secret` reference union now reads both `sys_setting.value_enc` and `sys_platform_setting.value_enc`. Without that, every credential held at the global rung would read as unreferenced and be swept. An unreadable `sys_platform_setting` gaps the family, which refuses deletion. + + **What moves for consumers.** + + - **Existing databases — nothing moves automatically** (ADR-0131 D14). A `sys_setting` row at `scope = 'global'` is no longer read; until the v18 upgrade ceremony moves it, that key answers from its next rung or the manifest default. The ceremony moves each such row to `sys_platform_setting` by namespace and key, its `value_enc` handle included. An encrypted value needs no re-encryption: the ciphertext's associated data binds the settings scope, namespace and key, never the holding object or an organization. + - **Authored references.** A filter, list-view column or seed that names `scope = 'global'` on `sys_setting` matches nothing: point it at `sys_platform_setting`, which has no `scope` column. + - **Generic data reads of the global values.** Read `sys_platform_setting`; it needs `manage_platform_settings`. The settings door (`/api/settings/:namespace`) is unchanged and keeps applying each manifest's own read and write capability. + - **Kernels that register the settings objects by hand.** Register `SysPlatformSetting` (`@objectstack/platform-objects/system`) beside `SysSetting`; `SettingsServicePlugin` already does. The service reads both on every resolution, so a kernel missing one fails the read loudly rather than answering a cascade with a rung missing. +- eebaf3b: feat(objectql,core): the driver's tenant read scope is the organization wall's verdict — one predicate for Layer 0 and every driver (ADR-0131 D8) + + Clause-②: yes (widening) + + - **What changed.** The engine no longer derives `DriverOptions.tenantId` / `tenantIds` beside the organization wall. `buildDriverOptions` projects the wall's own verdict onto them: the verdict plugin-security records on the operation (`OperationContext.tenantLayer0Verdict`) where its wall ran, and otherwise the same function over the engine's own facts (the posture in force, the context's organization and membership set). The function, `computeTenantLayer0Verdict` with its input type `TenantLayer0Input`, moves to `@objectstack/core` (new export); `@objectstack/plugin-security`'s `tenant-layer.ts` re-exports it and its Layer 0 is byte-identical. + - **FROM → TO, per posture, for a read, count, aggregate, update or delete on an object with an organization column:** + + | posture / caller | driver scope before | driver scope now | + |:--|:--|:--| + | `single`, any caller naming an organization | that organization (plus organization-less rows) | **none** | + | `isolated`, member | the active organization | unchanged | + | `group`, member | the membership set | unchanged | + | walled, exempt `PLATFORM_ADMIN` on a posture-permitting object, or the verified platform owner's read | the active organization | **none** — what Layer 0 already admitted | + | walled, system context naming an organization | that organization | unchanged | + + - **Not changed.** Inserts and upserts keep the active organization as `tenantId` in every posture: the row is stamped with it, its counter scopes by it, and an upsert that would merge into another organization's row is still refused. Both `orWhereNull` compatibility arms in `SqlDriver.applyTenantScope` stay (their retirement is a later stage of ADR-0131 D13). + - **Who notices.** A `single` deployment that holds more than one organization was kept apart by the driver only by accident; postures now differ only in enforcement (ADR-0131 D11), and `single` enforces no wall. To keep organizations apart, run the `isolated` or `group` posture. Code that relied on a context organization to narrow a read on `single` names the organization in its own `where`. + - **Migration.** ADR-0087 semantic entry `driver-read-scope-follows-tenant-wall`. A deployment's `platformGlobalObjects` carve-out already reaches the driver through the column plan (ADR-0131 D7): a declared object carries no organization column, so neither wall has anything to scope. +- c7d6037: A package declares how it may be installed — `installModes: ['managed', 'unmanaged']` — and `os package install --install-mode unmanaged` copies it once into the environment as the environment's own metadata (ADR-0131 D6) + + Clause-②: yes (widening: the new optional manifest key `installModes`; the new exports `INSTALL_MODES`, `InstallModeSchema`, `InstallMode`, `DEFAULT_INSTALL_MODES`, `resolveInstallModes`, `UNMANAGED_INSTALL_KEY_DISPOSITIONS`, `UNMANAGED_INSTALL_REFUSING_DISPOSITIONS`, `UnmanagedInstallKeyDisposition`, `unmanagedInstallValueCarries` and `unmanagedInstallPostureRefusal`; the install-local body field `installMode`; the CLI option `--install-mode`; the error codes `STACK_UNMANAGED_INSTALL_UNCOPYABLE` and `INSTALL_MODE_NOT_PERMITTED`) + + A package installs one of two ways. **Managed**: it is registered as code, stays the authority, upgrades, and nothing it ships is edited in place. **Unmanaged**: its metadata is copied once into this environment under a new writable base; the copy's items are this environment's own metadata, the next save edits them, and it follows no upgrade. This entry adds the manifest key that declares the modes a package permits, and the install door that honours it; the copy itself is the companion entry `15213-unmanaged-import-verb`. + + - **`installModes`** (`@objectstack/spec`, `ManifestSchema`). Optional, a non-empty list of `'managed'` / `'unmanaged'`. Absent means `['managed']`, read through one resolver, `resolveInstallModes(manifest)`; there is no parse-time default, so no existing manifest changes by a byte. A package may permit `unmanaged` alone. + - **Refused at authoring.** `defineStack` — so `os validate` and `os build` — refuses a package that permits `unmanaged` while it carries what the copy cannot write as environment metadata: a non-empty collection of a type the metadata-type registry declares runtime-uncreatable (`agents`, `apis`, `capabilities`, `jobs`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), or an extension of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`). The refusal is `STACK_UNMANAGED_INSTALL_UNCOPYABLE` (422), one `issues` entry per key. Fix: remove `'unmanaged'` from `manifest.installModes`, or move what cannot be copied out of the package. The install door judges the same body with the same key classification (`UNMANAGED_INSTALL_KEY_DISPOSITIONS`), so a package that passes `os validate` is not refused at install for a key nobody named. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local`). The body takes `installMode: 'managed' | 'unmanaged'`; absent means `managed`, so every existing caller is unchanged. Before anything is written, on the catalog and the air-gapped source alike: + - a mode the package's `installModes` does not permit answers `422 INSTALL_MODE_NOT_PERMITTED`, with `details.permitted`; + - `unmanaged` on a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value) answers `409 UNMANAGED_INSTALL_WALLED_POSTURE`, naming the posture; + - a managed install of a package whose namespace a writable base holds — its unmanaged copy — answers `409 RESOURCE_CONFLICT`, naming that base: a managed install would otherwise take the namespace and stop serving the edited copies. + An unmanaged install writes no install-ledger entry, registers nothing under the package's id and does not load its sample data (the answer says so under `seeded`). The answer names the new base, the items copied and `installedFrom` (the source package id and version, for information only). The package's translation bundles are copied as `translation` items, so they reload at every boot with the environment's own translations. + - **`os package install --install-mode managed|unmanaged`** (`@objectstack/cli`). Without the option, nothing is sent and the runtime installs managed. An unmanaged answer prints the base, the item count and the source. A runtime that predates the install mode ignores the option and installs managed; the command then exits 1 and says so. The command's help now calls the catalog and the air-gapped artifact the two *sources*, so "mode" means one thing. + - **Copy semantics** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` copies `translations` (each locale of each bundle as one `translation` item named `_`, bundles merged in order), reads `installModes` without writing it onto the base, and no longer refuses an empty code container (`functions: {}`, `plugins: []`). `unmanagedInstallPostureRefusal` is exported, so the door and the import answer the walled posture with one sentence. + + Nothing existing is removed: every new key, field and option is optional, and an install that does not name a mode is managed, as before, with one stricter answer. That answer is the refusal above: a managed install of a package whose namespace a writable base holds now answers `409 RESOURCE_CONFLICT` before anything is written, where a catalog install of it answered `200`. +- 72b26ed: `importUnmanagedPackage` copies one package into the environment ledger, editable from then on — the import behind the `unmanaged` install mode (ADR-0131 D6) + + Clause-②: yes (widening) + + A package installs one of two ways. Managed: the package is the authority, it upgrades, and nothing it ships may be edited. Unmanaged: every metadata item is copied once into this environment, is fully editable, and never follows an upgrade. This release adds the import; the install door calls it for an unmanaged install, and the manifest key `installModes` declares the modes a package permits. + + - **The verb** (`@objectstack/metadata-protocol`). `ObjectStackProtocolImplementation.importUnmanagedPackage({ manifest, targetPackageId?, targetName? })` writes every metadata item of ONE package body into a NEW writable base through the metadata save door: each row is owned by the base, carries no organization, is served as environment-authored (`_provenance: 'org'`), and the next save edits it. The base's id differs from the source (`targetPackageId`, defaulting to `-copy`), it keeps the source namespace so every object name stays valid, and nothing is registered under the source id. The answer names the source, the base, its namespace and every item copied, in write order. + - **All or nothing.** Every refusal that can be judged is raised before the base exists, so a refused import writes nothing: a deployment whose requested tenancy posture walls organizations (`group`, `isolated`, or an unrecognised value); a body carrying what cannot be copied; a malformed name or an unusable target id; a protocol-incompatible package; a target id that names an installed package, or a namespace another package owns (a managed install of the same package included); an item that does not convert to this protocol; a name an environment row or a managed package already holds. A write that fails afterwards deletes the base — its rows and its tables — and the failure is thrown with the item's own code and status, naming the item and what the rollback did. + - **What is copied, and what is refused.** The items of every manifest collection and the assembled `viewItems` are copied. A body carrying a runtime-uncreatable type (`jobs`, `agents`, `apis`, `capabilities`, `picklists`), code (`functions`, `onEnable`, `plugins`, a plugin artifact's `main` / `runtime` / `packaging` / `integrity`), extensions of another package's items (`objectExtensions`, `picklistExtensions`, `navigationContributions`, `contributes`) or an artifact envelope (`packages`) is refused whole; `OS_METADATA_WRITABLE` does not open a type for it. The package's `translations` are copied too, each locale of each bundle as one `translation` item, and an unmanaged install loads no seed `data`; deployment configuration (`i18n`, `requires`, `datasourceMapping`, …) is not part of a package install in either mode. + - **Two new error codes** (`@objectstack/spec`, registered to `@objectstack/metadata-protocol`). `UNMANAGED_INSTALL_WALLED_POSTURE` (409) and `UNMANAGED_INSTALL_UNCOPYABLE` (422). Both mean the package can be installed managed instead. A collision, an existing target or an owned namespace answers the standard `RESOURCE_CONFLICT` (409). + - **Two new schemas** (`@objectstack/spec/api`). `ImportUnmanagedPackageRequestSchema` is parsed by the verb, so a request key it does not declare is refused with `400`. `ImportUnmanagedPackageResponseSchema` declares the answer. + + Nothing existing changes: no schema, door or code is removed or narrowed. +- 2d21b9c: An unmanaged install no longer copies a package's protection lock, in either spelling: every copied item is editable, and the install's answer names each lock it dropped with the package's reason (ADR-0131 D6, ADR-0010) + + Clause-②: yes (widening: the new response field `strippedLocks` on `ImportUnmanagedPackageResponseSchema`, relayed on the install-local answer for `installMode: 'unmanaged'`) + + An unmanaged install copies a package into this environment as the environment's own metadata. A package can lock an item in two spellings: the `_lock` envelope (`_lock`, with `_lockReason`, `_lockSource` and `_lockDocsUrl`), or the author-facing `protection: { lock, reason, docsUrl }` block that a managed install translates into it. Until now an item locked with `_lock` was copied with that lock, so the next save of the copy answered `403 ITEM_LOCKED`; an item locked with `protection` was copied with an inert block that bound nothing, so the vendor's lock vanished without a word. The package permitted the unmanaged install through its `installModes`, so it chose to let environments take editable copies; a lock whose source is the package does not survive the copy. + + - **The copy** (`@objectstack/metadata-protocol`). `importUnmanagedPackage` writes every item without `_lock`, `_lockReason`, `_lockSource` and `_lockDocsUrl`, and, on a type whose schema declares one, without its `protection` block. Its answer gains `strippedLocks`: one entry per lock that bound (`no-overlay`, `no-delete` or `full`), in the order the items were written — `{ type, name, lock, reason? }`, with `reason` the package's reason verbatim and absent when it gave none. Each entry is the lock a managed install of the same item would have bound: an item carrying both spellings is reported once, with the `protection` block's lock and reason, because that is the one a managed install binds. A package that locks nothing answers `strippedLocks: []`. + - **Refusals are unchanged.** A lock the spec's declarations refuse (an unknown `_lock` value, a reason that is not a string, a malformed `protection` block) is not stripped: the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back. So is a `protection` block on a type whose schema does not declare one (`action`, `hook`, `page`). A managed install is unchanged: the package stays the authority, and its locks bind. + - **The declaration** (`@objectstack/spec`). `ImportUnmanagedPackageResponseSchema` declares `strippedLocks`; `lock` takes the three binding values of `MetadataLockSchema`. + - **The install door** (`@objectstack/cloud-connection`, `POST /api/v1/marketplace/install-local` with `installMode: 'unmanaged'`). The answer carries the import's `strippedLocks` as the import states it. + - **`os package install --install-mode unmanaged`** (`@objectstack/cli`). Each dropped lock prints as one line under `Locks removed`, spelled `.: — `, so the installer reads why the vendor locked an item before editing it. An answer from a runtime that states no list prints no lock line. + + Nothing is removed or narrowed: the answer gains a field, and every refusal of the unmanaged install is unchanged. +- ac9f8bd: A builtin flow node's `config` value that its executor contract refuses is refused at parse, with a location, in the contract's own words: `create_record` `outputVariable: 42`, a screen field `min: '1'`, a `get_record` `limit: '10'` and the like no longer pass the build doors and then fail every run. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** Every builtin executor parses its node's `config` against the contract `getBuiltinNodeConfigContracts()` names before it acts, and refuses the node on any finding. The build doors judged only the keys that contract requires, left out, so a present value it refuses passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied it into `dist/objectstack.json`), registered, and failed every run that reached the node: `create_record 'mk': config does not satisfy the create_record contract — config.outputVariable: Invalid input: expected string, received number`. + + **What is refused.** A node of any builtin type (`get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `script`, `subflow`, `map`, `loop`, `parallel`, `try_catch`), at any depth, whose present config value its executor contract refuses — a wrong type, a value outside the declared set or range, an empty `function` / `flowName`, or a rule finding on present keys (a `notify` `template` beside an inline `title`). The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, anchored at the key (`nodes.N.config.outputVariable`, `nodes.N.config.fields.0.min`), from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the metadata save door (`422 INVALID_METADATA`). + + **What the build doors still accept, byte for byte.** Every value its contract accepts, and the values this arm holds back: + + - a value carrying a `{token}` (also spelled with double braces or a leading `$`) — never refused at the build doors for its pre-interpolation type. That is not a promise it runs: every builtin parses its config as authored, so a token in one of its number or boolean slots (`limit: '{n}'`, `maxIterations: '{cap}'`, a screen field `min: '{m}'`, `multi: '{bulk}'`) still fails at its first run, exactly as before — write a literal there (an `http` node's `timeoutMs` and `durable` refuse the token itself at the build doors); + - on `http`, `signingSecret` (the credential channel may supply it); + - a `loop` with no `body` (its executor does not parse it), and the region slots of `loop`, `parallel` and `try_catch`; + - an undeclared or retired key, a screen field's `visibleWhen` and a CRUD `fields` value — each keeps the judge it had. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `outputVariable: 42` | `outputVariable: 'taskId'` — the variable's name | + | a screen field `min: '1'`, `max: '10'` | `min: 1`, `max: 10` | + | `limit: '10'`, `maxIterations: '5'` (any number slot outside `http`) | `limit: 10`, `maxIterations: 5` — a literal number only: these executors parse the config as authored, so a `{token}` here passes the build and fails every run | + | `multi: 'true'`, a screen field `required: 'yes'` (any boolean slot outside `http`) | `multi: true`, `required: true` — a literal boolean only, for the same reason | + | `http` `timeoutMs: '5000'`, `durable: 'yes'` | `timeoutMs: 5000`, `durable: true` — a literal only: `http` parses its config as authored, and a `{token}` there is refused at the build doors | + | `severity: 'loud'`, `mode: 'view'` | one of the declared values (`'info'` / `'warning'` / `'critical'`; `'create'` / `'edit'`) | + | a `notify` with both `template` and `title` | one content path, as the refusal's sentence says | + + **The one-line fix: write the value the contract declares at the key the refusal names.** The runtime never ran such a node, so the fix changes nothing a working flow does. + + **Who is affected, measured.** At `833d57c9cf`, every builtin node `config` authored in this repository's examples, docs, skills and `packages/qa` fixtures (96 nodes), and every one in hotcrm at `4054ec2680` (138 nodes), parses under this arm. A second census at `d1c7d8d392` that also reads helper calls, same-file constants and assignments into a node config (1065 configs in this repository, 138 in hotcrm) found no other real writer; 64 configs here take a value from an import, a call or a spread that no static reading evaluates, and are not counted either way. The one real writer found to store a refused value is the Studio flow designer, which saved a screen field's Min / Max as strings until objectui `5ba255538a`. Deployed metadata, and other repositories, were not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. + + ### The kit + + - **The refusal.** The value half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`; no new code joins `FLOW_SLOT_REFUSAL_CODES`, and `getBuiltinNodeConfigContracts()` keeps its 13 entries. + - **The ledger.** The D3 semantic entry `flow-builtin-node-config-values-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know the value the author meant. +- 2f70c22: A key a builtin flow node's executor contract does not declare is refused at parse, with a location, on every builtin but `try_catch`: a `notify` `bogusKey`, a screen field's `visibleIf`, a `create_record` `fieldValues`, an `http` `outputVariable` and the like no longer pass `objectstack validate` and `objectstack compile` and then get the whole flow refused at registration. + + Clause-②: yes (narrowing: an undeclared config key on 10 more builtins, every strict-contract builtin but try_catch, is refused at the build doors and the save door, where it passed; widening: a save door with no flow canonicalizer judges the D2-converted body, so a D2 alias spelling it refuses today, script functionName / input and subflow flow, is accepted again, as at every other door) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** Each of these executors parses the node's `config` against a strict contract before it acts. The build doors' executor-contract arm judged required keys and present values on these types, but held key membership back, because `registerFlow`'s undeclared-key walk judged it against the node type descriptor's `configSchema`. So a `notify` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied the key into `dist/objectstack.json`), and then registration refused the whole flow: at boot it was skipped with a warn, and a flow saved from Studio was stored and then silently not registered. + + **What is refused.** A `get_record`, `create_record`, `update_record`, `delete_record`, `notify`, `http`, `screen`, `map`, `loop` or `parallel` node, at any depth, whose config carries a key its executor contract does not declare: at the config itself, on a `screen` field, or on one of a field's `options`. That includes a body-less legacy `loop`, which is judged on key membership alone. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`, `nodes.N.config.fields.0.visibleIf`). It comes from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first), `objectstack validate` and the metadata save door share. Its message carries the contract's own sentence, with the contract's prescription for a known slip (`fieldValues` → `fields`, `bulk` → `multi: true`, `visibleIf` → `visibleWhen`, a did-you-mean for a near miss), and closes with the remedy: rename the key to one the contract declares there, or remove it. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the save door. `script` and `subflow` keys were already refused this way. The new `builtinNodeConfigKeysJudged(nodeType)` export names exactly the builtins the spec judges. + + **`@objectstack/service-automation`.** `registerFlow`'s descriptor walk (`validateNodeConfigKeys`) stands aside for every type `builtinNodeConfigKeysJudged` names, so each node type has one judge. Before the move, the declared key sets were measured equal: on each of these types the descriptor's declared keys, at every position the walk descends to, equal the keys the contract accepts there. So registration refuses exactly what it refused before. The refusal now arrives as the parse's located issue instead of the walk's `Flow '…' rejected: N undeclared config key(s)` text. + + **`@objectstack/metadata-protocol`.** The save door judges a flow in its canonical spelling on every path. When no flow canonicalizer resolved (a host with no automation service), or the canonicalizer threw (a draft with a temporary cycle), the schema gate and the runtime authoring gate now judge the body with the ADR-0087 D2 conversions applied (`applyConversionsToFlow`), for the verdict only. The stored body stays the raw request body, exactly as before. A D2 spelling the load path still rewrites (`filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`; `functionName` and `input` on a `script`; `flow` on a `subflow`) is therefore accepted at that door, as at every other one, and an undeclared key that no conversion rewrites is refused there, located. This widens the fallback path back for the `script` and `subflow` aliases the previous key arm had begun refusing there. `duplicatePackage` re-saves through the same gate. + + **What stays as it was.** + + - `try_catch`: its undeclared keys stay registration's, judged by the walk. Its contract's `retry` is the shared `RetryPolicySchema`, which strips an unknown key, while its descriptor closes `retry` to `maxRetries`, `backoffMs`, `backoffMultiplier`, `maxRetryDelayMs` and `jitter`. Moving the judge would have widened registration. + - Every plugin node type (the spec does not declare its contract): judged by the walk against its descriptor's `configSchema`, with the walk's own prescriptions. + - A key at or under a region slot (a `loop` body or a `parallel` branch, and their nodes and edges): judged at registration by the region check, as before. + - A key inside a free-form map (`filter`, `fields`, `headers`, `defaults`, `input`, `payload`, `templateData`): author data, never judged. + - `assignment`, whose top-level keys are the author's variable names, and `decision`, which parses no contract: unjudged. + - A spelling an ADR-0087 D2 conversion still rewrites at load (`object` and `filters` on a CRUD node; `to`, `subject`, `body` and `url` on a `notify`; `flow` on a `map`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`, the save door). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | a typo of a declared key (`titl`, `outputVariabel`) | the declared key the refusal's did-you-mean names | + | `fieldValues` on `create_record` / `update_record` | `fields` | + | `bulk`, `all`, `multiple` or `options: { multi: true }` on `update_record` / `delete_record` | `multi: true` at the top level of `config` | + | `visibleIf` on a screen field | `visibleWhen` | + | `itemVariable` on a `loop` | `iteratorVariable` | + | `outputVariable` on an `http` node | delete it: the http executor binds no output variable | + | a key nothing reads | delete it | + + **The one-line fix: rename or delete the key the refusal names.** Registration already refused such a flow, so the fix changes nothing a running flow does. + + **Who is affected, measured.** At `fbcbcf124`, every builtin flow node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (95 nodes) carries only keys its contract declares. The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys whenever the engine publishes the node's descriptor. Its fallback form (while the descriptor list is loading, or when it cannot be read) offers `outputVariable` on an `http` node and the `url` alias on a `notify` node. Registration already refused the first and the conversion rewrites the second; the designer fix is tracked in objectui. hotcrm, deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, over every builtin in `getBuiltinNodeConfigContracts()` but `try_catch`, named by the new export `builtinNodeConfigKeysJudged`; no new code joins `FLOW_SLOT_REFUSAL_CODES`. + - **The ledger.** The D3 semantic entry `flow-builtin-node-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. +- 78f841b: A `script` or `subflow` flow node whose `config` carries a key its executor contract does not declare is refused at parse, with a location, in the contract's own words: a `script` `bogusKey`, a `subflow` `timeoutMs` written inside `config`, and the like no longer pass the build doors and registration and then fail every run. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** The `script` and `subflow` executors parse the node's `config` against a strict contract (`ScriptConfigSchema`, `SubflowConfigSchema`) before they act, and refuse the node on an undeclared key. No door before the run judged one: `registerFlow`'s undeclared-key check reads the node type descriptor's `configSchema`, and these two descriptors publish none, while the build doors' executor-contract arm judged required keys and present values but not key membership. So a `script` node carrying `bogusKey` passed `FlowSchema.parse`, `objectstack validate` and `objectstack compile` (compile copied it into `dist/objectstack.json`), registered, and failed every run that reached the node: ``script 'n': config does not satisfy the script contract — config: Unrecognized key(s) on this script node config: `bogusKey` ``. + + **What is refused.** A `script` node, at any depth, whose config carries a key other than `function`, `inputs` and `outputVariable`, or a `subflow` node whose config carries a key other than `flowName`, `input` and `outputVariable`. The refusal is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType, key }`, one per undeclared key, anchored at the key (`nodes.N.config.bogusKey`), from the one judge `flowNodeConfigRefusals` that `FlowSchema.parse`, `AutomationEngine.registerFlow` (which parses first) and `objectstack validate` share. The issue's `code` is `custom`. That covers `FlowSchema`, `defineFlow()`, `defineStack` (`STACK_SCHEMA_INVALID`, 422, at `flows.N.nodes.M.config.`), `os validate`, `os compile`, an artifact's parse, `registerFlow` and the metadata save door. + + **What stays as it was.** + + - Every other builtin node type: its undeclared keys are judged at registration against its descriptor's `configSchema`, with that check's own prescriptions, and the build doors do not judge them. + - `decision`: it publishes no descriptor `configSchema` either, but its executor parses no contract, so an undeclared key fails no run and stays unjudged. + - A retired `script` key (`actionType`, `template`, `recipients`, `variables`, `script`) keeps its tombstone path. + - A spelling an ADR-0087 D2 conversion still rewrites at load (`functionName` and `input` on a `script`, `flow` on a `subflow`) is converted before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`). Met by a direct `FlowSchema.parse` or `defineFlow()`, it is refused like any other undeclared key, as its missing canonical key already was. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | a typo of a declared key (`funtion`, `outputVariabel`) | the declared key: `function`, `inputs`, `outputVariable` on a `script`; `flowName`, `input`, `outputVariable` on a `subflow` | + | a value the function or child flow should receive, as its own config key (`config: { function: 'f', taskId: '{record.id}' }`) | inside the input map: `config: { function: 'f', inputs: { taskId: { dialect: 'cel', source: 'record.id' } } }` (`input` on a `subflow`) | + | a `subflow` `config.timeoutMs` | on the node: `{ id, type: 'subflow', timeoutMs: 30000, config: { … } }` | + | a key nothing reads | delete it | + + **The one-line fix: rename, move or delete the key the refusal names.** The runtime never ran such a node, so the fix changes nothing a working flow does. + + **Who is affected, measured.** At `15ec50e528`, every `script` and `subflow` node authored in this repository's examples, platform objects, apps, scaffolding templates, skills and docs (8 nodes: 6 `script`, 2 `subflow`) carries only declared keys, and so does every one in hotcrm at `c9678036d9` (5 `subflow`, no `script`). The Studio flow designer at the pinned objectui `a58626c88d` writes only declared keys for both types (its `timeoutMs` field writes the node, not `config`), and seeds a new node with an empty `config`. Deployed metadata, and other repositories, were not measured. Where such a node already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. + + **`@objectstack/lint`.** `validateStackExpressions` keeps the pre-conversion tolerance it declares: on a raw source, a `script` node's `functionName` alias stays the callable check's to read, not an undeclared-key error, while every other undeclared `script` key is refused there as at the build doors. + + ### The kit + + - **The refusal.** The key half of the executor-contract arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts`, judged for the builtins in the spec's schemaless class (`SCHEMALESS_NODE_CONFIG_SCHEMAS`) that have an executor contract; no new code joins `FLOW_SLOT_REFUSAL_CODES`, and `getBuiltinNodeConfigContracts()` keeps its 13 entries. + - **The ledger.** The D3 semantic entry `flow-script-subflow-config-undeclared-keys-refused` (protocol 18). No key is removed, so there is no tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. +- c565813: feat(spec,analytics): a dataset answer's measure column states its aggregate, labelled or not (`fields[].aggregate`) + + Clause-②: yes (widening) + + - **What a renderer can now read.** Each measure column of a dataset answer (`POST /analytics/dataset/query`) carries `fields[].aggregate`: the aggregate its dataset measure declares, in the closed `AggregationFunction` vocabulary (`count`, `sum`, `avg`, `min`, `max`, `count_distinct`). It is there whether or not the author gave the measure a `label`. So a chart can tell a count from a sum, for example to draw whole-number axis ticks for a count instead of 0.75 / 1.5 / 2.25. + - **What was missing.** The only aggregate on the wire was `builtinAggregate`, and it is present only when the measure has no `label`. A labelled measure, such as a `count` named "Tasks", reached the wire as `{ name, type: 'number', label }`, with nothing to say what kind of number it was. + - **Where it is set.** `AnalyticsService` writes it in the one step that describes a dataset answer's columns from the dataset's own measures. That step runs for both the live query and the draft-data preview, so the two answers agree. A measure's `__compare` column carries the same aggregate. + - **Where it is absent.** Dimension columns. Derived measures, which combine other measures and have no single aggregate (a stray `aggregate` written beside `derived` is ignored when the dataset compiles, so it is not stated here either). And a cube query answer (`POST /analytics/query`), which does not run through the dataset column step. + - **Unchanged.** `builtinAggregate` keeps its meaning: present only on a label-less measure column, to mark a header that is the server's default. No authoring key is added; `aggregate` is a response member only. `AnalyticsResultResponseSchema` and the `AnalyticsResult` contract declare the member, and the REST route relays it as it does every other column key. +- 56c8844: `@objectstack/spec/ui` now exports the rule that decides which forms a `view` body opens to anonymous intake, so a console reads "published" from the same rule the server's anonymous form doors serve + + Clause-②: yes (widening) + + - **New on `@objectstack/spec/ui`:** `publicFormSlug`, `anonymousFormIntakeSlug`, `anonymousFormIntakeCandidates`, `anonymousFormIntakeSlugs` and the `AnonymousFormIntakeCandidate` type. They lived only in `@objectstack/metadata-core`, which a browser console should not depend on. They are pure functions with no imports, beside the `SharingConfigSchema` they read. + - **What they decide is unchanged.** A form is open when its `sharing` has `enabled === true`, `allowAnonymous === true` and a non-empty `publicLink`. The scan covers the same three shapes in the same order: the nested `form`, every `formViews` entry, then the `config` of a `viewKind: 'form'` item. + - **`@objectstack/metadata-core` re-exports the same functions** from `@objectstack/spec/ui`. They are the spec's own bindings, not wrappers or copies, so there is still one copy of the rule. Its exports, names and types are unchanged, and `@objectstack/rest` and `@objectstack/metadata-protocol` keep importing from it. Its built output now loads `@objectstack/spec/ui` to get them. + - **Not covered by the new export:** whether another metadata layer withdraws a form (`anonymousFormIntakeWithdrawnIn`), and whether the deployment's tenancy posture lets the form take an anonymous submission (`anonymousFormIntakeUnavailability`). These two read server state and stay in `@objectstack/metadata-core`. `anonymousFormObjectName`, which names the object a form submits into, stays there beside them; it is a pure read of the form and the view, not of server state. A form the new functions call open can still be withheld by a withdrawal in another layer or by the posture. +- d4680d2: feat(spec)!: `NotifyConfigSchema.title` and `NotifyConfigSchema.message` are template slots: each takes a bare string or a `{ dialect: 'template', source }` envelope (the `tmpl` helper), as the expression dialect table already listed notification subjects and bodies among the `template` slots, and a blank bare string is now refused there + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing at two authorable keys (`automation/NotifyConfig:title`, `automation/NotifyConfig:message`), shipped as `minor` under the repo's launch-window convention for breaking changes. It is the grade `TemplateExpressionInputSchema`'s blank-string rule shipped with when it reached the first twelve typed keys. + + - **What widens.** Both keys are typed with `TemplateExpressionInputSchema`, the input every other `template` slot uses. Before, both were `z.string()`, so a notify node written with `` tmpl`…` `` passed `defineFlow` and registration and then failed every run at the execute-time contract parse (`expected string, received object`). It now parses and runs. + - **What narrows.** A blank bare string (`''` or whitespace-only) at either key is newly refused, by the shared template input's non-blank rule (`invalid_union`, with the `TYPED_EXPRESSION_SOURCE_REQUIRED.template` sentence). Before, every blank value parsed: + - `title: ''` then failed every run at the executor's guard ("notify: title is required"), so it fails either way, now earlier; + - a whitespace-only `title` passed that guard and was delivered as the notification title, and it is now refused; + - `message: ''` or a whitespace-only `message` was delivered as an empty or blank body, and it is now refused. + + The fix is to write the text, or to delete the key (`message` is optional). + - **Parse output.** `NotifyConfigSchema.parse(...).title` and `.message` go from `string` to `{ dialect: 'template', source }`, for both spellings, because the parse normalizes a bare string to that envelope. The exported `NotifyConfigParsed` type changes with them. Code that reads parse output reads `.source`. The `notify` executor, the one reader in this repo, now does, so both spellings of one text deliver the same `payload.title` and `payload.body`, and a bare string renders exactly what it rendered before. + - **Still refused, with a new sentence.** A value that is neither a string nor a template envelope (a number, an array, a `cel` envelope) was refused before (`invalid_type`). It is refused now as `invalid_union`, with the `TYPED_EXPRESSION_DIALECT_ONLY.template` sentence. + - **New, notify-only.** A template envelope on either key must carry a non-blank `source`. The executor renders `source` and has nothing to render from `ast` alone, so such an envelope is refused at the key instead of failing every run (`title`) or sending an empty body (`message`). An envelope never parsed at these keys before, so this refuses nothing that used to parse. + - **Placeholder spelling.** These two slots render `{{ }}` holes (`{{ record.name }}`), for a bare string and an envelope alike, and a single-brace `{token}` in them is refused. The `.describe()` on both keys now says so, and no longer says the text is "sent verbatim". + - `@objectstack/service-automation`: the `notify` executor reads `source` from the two template slots, and the descriptor's `title` / `message` descriptions state the `{{ }}` holes in place of "sent verbatim". +- cdeabec: `os migrate meta` stops listing semantic notices whose surface the stack provably does not declare. It counts them instead, and `--all` lists them in full. + + Clause-②: yes + + - **`SemanticMigration.relevantWhen`** (`@objectstack/spec/migrations`) is a new optional field. It holds a structured question over the loaded stack, `{ kind: 'stack-declares', keys: [...] }`: does the stack declare anything under one of these top-level keys? A key's value in a `packages[].manifest` body counts the same as a top-level value. The question is closed and named. It is never free text, and it never matches against the prose of `surface`. The new types `SemanticRelevance`, `StackDeclaresRelevance` and `SemanticRelevanceKey` are exported beside `SemanticMigration`. + - **`applyMetaMigrations`** asks each entry's question of the stack it is given and of every hop checkpoint. + - **`todos` is unchanged.** `MigrationChainResult.todos` and `MigrationHopResult.todos` still hold every semantic entry of every hop crossed, whatever the stack holds, as before. + - **`absentTodos` is a new required member** of `MigrationChainResult` and `MigrationHopResult`. It names the subset of `todos` whose question answered `absent` in all of them: the same objects, in chain order. Code that only reads a chain result needs no change. Code that builds one of these two interfaces itself must now supply `absentTodos` (an empty array when nothing is proven absent). + - **Only a positive proof names an entry.** These cases answer `unknown` and leave it off `absentTodos`: + - a value the question cannot read (a function, a promise, a scalar, a getter that throws); + - a stack that is not a plain object; + - any `plugins`, `devPlugins` or `tiers` entry, since a plugin, or the platform plugins a tier preset loads, can register metadata the stack does not show. + + An entry that judges a conversion which applied an edit in the same run is never named either. + - **The first batch is 25 entries** (5 from protocol 17, 20 from protocol 18). Each one's surface lives only under named top-level stack keys: `analyticsCubes`, `apis`, `jobs`, `mappings`, `hooks`, `agents`, `tools`, `dashboards` (with `reports` and `pages` for the chart-config entry), `datasets`, `permissions` and `sharingRules`. None of them names a code door. Each entry was also checked to confirm that its acceptance criteria send the author to no stored row and no runtime door. Every other entry is never named absent, so it is listed exactly as before. + - **`os migrate meta`** lists `todos` minus `absentTodos`. After the listed notices it prints one line that counts the entries proven absent and names `--all`. A second line says that the proof covers the stack this run loaded, and not metadata a deployment stores. + - `--all` prints each of those entries in full, with the keys it was proven absent under. + - `--json` keeps `todos` whole and adds `absentTodos`, plus `hops[].absentTodos` with `--step`. + - `--step` reports each hop's listed count, and adds a `not listed` count to the hop line when that count is not zero. + - A run whose only notices are proven absent still writes `--out`. +- db4c45b: feat(spec)!: `FlowSchema` refuses an edge whose `source` or `target` names no node of its graph, and an edge that repeats an earlier one + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** `FlowSchema` held node ids and edge ids unique, and checked nothing else about an edge. A flow whose edge named a node it no longer held (`start → node_1` over nodes `[start, end]`), or that held `start → node_1` three times, passed `FlowSchema.parse`, `objectstack validate` and the metadata save door, and publish answered 200 with `_diagnostics.valid: true`. Then it ran. The dangling edge carried the run nowhere, silently, and the repeated edge ran its target once per copy: one record update created three identical records. The Studio flow designer produced both shapes after a node was removed. + + **What is refused.** Both rules run in the region walk the node-id rule already uses, at every depth it reaches. The issue's `code` is `custom`, and each issue is anchored on the edge to fix: + + - **An endpoint that names no node of the edge's own graph**, at `edges.N.source` / `edges.N.target`. For an edge inside a `loop` / `parallel` / `try_catch` region the path is the region path, such as `nodes.N.config.body.edges.M.target`. The graph is the flow's own `nodes` for a top-level edge, and the region body's `nodes` for a region edge, because the engine resolves an endpoint there alone. So a top-level edge into a region node is refused too, and the message names the graph the node does live in. The node-id space is still one across the flow for uniqueness. + - **A repeated edge**, at `edges.N`, naming the earlier copy. Repeated means the key the engine selects on: the same `source`, `target`, `type`, `condition` (its dialect and source, so a bare CEL string and its envelope are one condition) and branch `label`. An `isDefault` copy of an unconditional edge is a repeat. A repeat is judged only between edges whose endpoints both resolve. + + That covers `FlowSchema`, `defineFlow`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `os validate`, `os compile`, an artifact's parse, `AutomationEngine.registerFlow` (which parses first) and the metadata save door (`422 INVALID_METADATA`, in draft and in publish mode). + + **What is still accepted, byte for byte.** Two nodes joined by edges the engine tells apart: different conditions, a `fault` edge beside a default one, or `approve` and `reject` branch labels into one node. Every flow whose edges all resolve in their own graph and repeat nothing. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | an edge into a node that is not in the same `nodes` list (`target: 'node_1'`, no `node_1`) | point it at the node it was meant to reach, or delete the edge | + | a top-level edge into a node inside a region body | an edge into the region's container node; the region's own edges reach the nodes inside it | + | the same `source` → `target` edge twice, with the same `type`, `condition` and `label` | one edge. Delete the later copy; an edge meant to take its own route needs its own `condition` or branch `label` | + + **The one-line fix: delete the edge the refusal names, or re-point its endpoint.** Deleting a dangling edge changes nothing a run did, with one exception. A conditioned edge into a missing node still counted as the branch taken when its condition held, so where a flow relied on that, point the edge at a node that ends the branch. Deleting a repeated copy runs its target once per traversal instead of once per copy, which is the defect being removed. + + **Who is affected, measured.** At `aa71c4d9d`, every flow the examples ship (`app-showcase`, `app-crm`, `app-todo`: 35 flows, 55 graphs counting region bodies, 131 edges) has no dangling endpoint and no edge pair sharing a `source` and `target` at all. The flows the packages ship (the `os generate` and `os explain` templates, the new-flow seed, the `@objectstack/verify` fixture) and the platform test checklist's flow bodies are clean by reading. The CLI's golden eval corpus carries no flow. Deployed metadata and other repositories were not measured. Where such an edge already sits in a stored flow, the whole flow is refused at registration: at boot it is skipped with a warn naming it, its trigger not armed, while the flows beside it register. + + ### The kit + + - **The refusal.** Two blocks in `FlowSchema`'s `superRefine`, after the edge-id rule, over `collectFlowGraphs`. No new error code: the issue is the same `custom` issue the id rules raise. + - **The ledger.** The D3 semantic entry `flow-edge-unresolved-or-repeated-refused` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: a dangling endpoint carries no intent a rewrite could recover, and dropping a copy changes how often its target runs. +- 8f2e808: feat(meta): the `/meta` item read serves the ADR-0008 version token, `If-None-Match: *` pins a save that expects no row, and the 409 `METADATA_CONFLICT` body carries the current version as data + + Clause-②: yes (narrowing) + + **BREAKING** for two request shapes on `PUT /api/v1/meta/:type/:name` that were answered `200` before this release and are now refused `400 VALIDATION_ERROR`, with nothing written: an `If-None-Match` header whose value is anything but `*` (an entity-tag, a list, a weak `W/"*"`, an empty value), and an `If-None-Match` header sent beside `If-Match`. Before, the door did not read `If-None-Match` at all and wrote the body, unguarded or under the `If-Match` alone. The remedy: send `If-None-Match: *` alone to save only where no row exists, or `If-Match` with the version you read alone to save only over that version. No first-party client sends `If-None-Match` on a `PUT`: the SDK sends it only on its cached `GET`, and objectui's ETag hook has no caller. + + ADR-0008's optimistic lock on `PUT /api/v1/meta/:type/:name` takes the version a client read as `If-Match`, but a client can only send a token it was served, and only a save receipt served one. So the first save after a load could not be pinned: two editors who each loaded an item and saved once overwrote each other. Three additions close that. + + - **The read serves the token.** `GET /meta/:type/:name` now carries `version` (declared on `GetMetaItemResponseSchema`): the keyed token of the stored row a save to this item would compare against, at the read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` for the draft row, the plain read for the active row). It is the same producer and the same row as the save receipt's `version`, so a read after a save serves the receipt's token byte for byte. Send it back as `If-Match`. `null` means no stored row is there (an item served from code or a package artifact, or from a row in a scope the save does not write), so the next save is a create. The cached published-value branch (the default plain read) and `?preview=draft` publish no `version`, as they publish no `lock`; their `ETag` stays the cache validator and is not the token. + - **"Expect no row" can be said.** `If-None-Match: *` on `PUT /meta/:type/:name` (with `?mode=draft` for a draft) saves only where no row of that lifecycle exists, and is refused `409 METADATA_CONFLICT` once one does. This is the `parentVersion: null` pin `SaveMetaItemRequestSchema` already declared, now reachable over HTTP. The header takes `*` alone: any other value, or `If-None-Match` beside `If-Match`, is refused `400 VALIDATION_ERROR` and nothing is written. No first-party client sends `If-None-Match` on a `PUT`. A save with neither header is last-writer-wins, as before. + - **The conflict body names the current version as data.** The 409 of the `/meta` item write doors (save, publish, rollback, reset) now carries `currentVersion` beside the unchanged `error` sentence: the token the sentence names, or `null` when no row of the target lifecycle exists (declared as `MetadataConflictErrorSchema`). On the save door it equals the token the next read at the same address serves, so a client offering "reload" or "overwrite" re-pins without parsing prose. The SDK already exposes it as `err.details.currentVersion`. + - **The receipts' `version` describes are corrected.** `SaveMetaItemResponseSchema.version`, `PublishMetaItemResponseSchema.version` and the package publish door's `published[].version` said "Content hash … currently emitted as `sha256:`" and "409 `metadata_conflict`". The token has been the keyed `hmac-sha256:` digest of the stored content hash since the doors stopped serving the raw hash, and the conflict code on the wire is `METADATA_CONFLICT`. Only the description text moves. + + +- 9ad3d24: `defineSeed()` refuses a seed record key that names no column of the target object, whatever shape the records arrive in, and its record type now admits the system columns the platform injects (`created_at`, `owner_id` and the rest), which it used to refuse in a record literal. + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. It is a call-time refusal, not a type-only change: the check runs when `defineSeed` is called, so it is reached by `os validate`, `os build` and boot through the config module's evaluation. + + **Why.** The docblock promised that "typos in record field names are caught at compile time", and nothing else checked them. The promise held only for a record written as an object literal directly in `records`. TypeScript's excess-property check is the only thing the record type enforced, and it does not run for records from a variable or a `.map()`, for an object typed `ServiceObject`, or for any inline record in an array that also spreads a `Record[]`. A seed with a misspelled key passed `tsc` and `objectstack validate`, and the mistake surfaced, if at all, only when the seed loaded. Measured with the published 17.7.0 types on a real app: an `ObjectSchema.create()` object keeps its literal field keys, and the spread is what silenced the check. The same type refused `created_at` in a record literal, a key the seed loader keeps on insert, so the one legitimate way to seed it was the shape that also hid typos. + + **What is refused.** `defineSeed(obj, config)` throws when any record carries a key that is neither one of `obj.fields` nor a system column the platform injects on `obj`. The injected set is `resolveInjectedSystemColumns(obj).names`, the same per-object answer the registry's injection reads. It holds the driver's `id` always, plus `organization_id`, the audit columns (`created_at`, `created_by`, `updated_at`, `updated_by`), `owner_id` and `owning_business_unit_id` as the object's `systemFields`, `tenancy`, `ownership` and `managedBy` select them. All unknown keys are reported in one error, one line per key, naming the object, the record index and the key, with a near-miss suggestion: + + ```text + defineSeed('crm_case'): unknown field(s) in records — created_atx. + • records[0]: `created_atx` is not a field of `crm_case`. Did you mean 'created_at'? + ``` + + **What is admitted that was not.** The record type adds every injectable system column name (the new exported type `InjectedSystemColumnName`) to the keys a record literal may carry, typed `unknown`. A literal writing `created_at` or `owner_id` now passes `tsc`. The type cannot evaluate an object's opt-outs, so the call narrows it: `created_at` on a `systemFields: false` object, or `owner_id` on an `ownership: 'org'` object, is refused when the call runs. A declared field of the same name keeps its declared value type. + + **Remedy.** Correct, declare or remove the key the refusal names. A misspelled key takes the spelling the refusal suggests (the declared field, or the system column such as `created_at`). A key for a field the object does not declare needs that field declared on the object, or the key removed. A system column the object opts out of (`created_at` on `systemFields: false`, `owner_id` on `ownership: 'org'`) does not exist on that object, so the key is removed. The key named no column of the object, so no value it carried could be stored under it, and no working seed depends on it. + + **Who is affected, measured.** At `0767335c`, every `defineSeed` call in this repository passes: `examples/app-crm` (5 seeds, 28 records), `examples/app-showcase` (19 seeds, 132 records) and `examples/app-todo` (1 seed, 8 records), evaluated against the built package. A misspelled key in the same context is refused, which is the control. Every seed module in hotcrm at `99d290a` passes too (8 modules, 354 records, including the `created_at` its case seeds author), and its `crm_case` with the misspelled `created_atx` is refused. Other repositories and deployed packages were not measured. +- 4578c56: fix(import): `createMissingOptions` now keeps an unmatched option value through the engine's option check + + Clause-②: yes (widening) + + - **What was wrong.** With `createMissingOptions: true` on an import (objectui's Import Wizard sends it for *Keep unknown option values*), the import coercion kept a `select` / `radio` / `multiselect` cell that matched none of the field's options. The engine's write-path option check then refused the same value ("Priority must be one of: high, low"), so on every writable field the row still failed. Only the stage that refused it changed. + - **What happens now.** The import stores the kept value: a single-value cell trimmed, and each unmatched item of a multi-value cell. The dry run admits the same rows the write does. The engine admits exactly the values the import kept, on that import's writes. Every other value outside the options is still refused, and a field bound to a shared picklist that did not resolve is still refused. + - **The field's options do not change.** No option is added to the field. A later write that sends the field is judged against the options again, so sending the kept value back is refused and picking an option is accepted. A write that leaves the field out, such as an edit form saving only the fields it changed, is not judged on that field. + - **`@objectstack/spec`.** `ExecutionContext` declares `keptOptionValues`: per field name, the option values an import kept. It is server-constructed only, like `skipStateMachine` and `preserveAudit`, and is never client-supplied. The `ImportRequest.createMissingOptions` description now states exactly the behaviour above. + - **`@objectstack/core`.** `coerceRow` returns `keptOptionValues` beside `data` and `errors`, and `coerceFieldValue` reports the values it kept. `runImport` puts exactly those values on each write's context. A per-row write carries its own row's values, and a batched create carries the union of its rows' values. A row that kept nothing is written with the import's context unchanged. + - **`@objectstack/objectql`.** `ValidateRecordOptions.keptOptionValues` lets the single-value and multi-value option arms admit a listed value. The engine fills it from the write's `ExecutionContext.keptOptionValues` on insert, update and `validate`. +- c512c25: feat(spec,metadata-protocol): each `GET /meta/_drafts` row carries the draft body's own `label`, or `null` + + Clause-②: yes (widening) + + - **What a client can now read.** Every row of the pending-drafts list (`GET /api/v1/meta/_drafts`, the runtime's `GET /metadata/_drafts`, and the SDK's `client.meta.listDrafts()`) carries `label`: the draft body's own top-level `label`. This list is the only place a client finds an item that exists only as a draft, so before this such an item could be shown by its machine name alone (a draft permission set read `technician`, not "Technician"). + - **Its shape.** `I18nLabel | null`, required on the wire. It is carried as authored: a plain string, or an inline locale map (`{ en: …, 'zh-CN': … }`) on the types whose `label` is an `I18nLabel`. The route takes no locale, so the reader resolves a map the way it resolves every other `I18nLabel` (`resolveI18nLabel` in `@objectstack/spec/ui`). + - **When it is `null`.** The body declares no `label`; it declares one `I18nLabelSchema` refuses (a row stored before its type's schema was enforced on save, or a row of a type with no registered schema); or its stored bytes do not parse, in which case the draft stays listed and every read of its body still fails. It is never the item name standing in for a missing label: a reader that wants a fallback chooses it, knowing the label is absent. + - **Where it comes from.** `SysMetadataRepository.listDrafts` reads it off the `sys_metadata` row it already fetches, so there is no second query, and `ObjectStackProtocolImplementation.listDrafts` passes it through. Of the stored body, only this one member leaves; field definitions and every other body key stay off the header. + - **Unchanged.** The other six members, the filters (`?packageId=`, `?type=`), the org scope, and the authoring gate on both routes. +- 215e662: A `date` or `datetime` field can declare that it is a deadline, and when that deadline is settled: `dueLike` and `settledWhen` + + Clause-②: yes + + - **`dueLike: boolean`** (optional, no default) declares the date a deadline. A renderer may then show relative overdue wording and an overdue colour once the date has passed. Absent means not a deadline: nothing is inferred from the field's name. + - **`settledWhen`** (optional) is a per-record CEL predicate in the `visibleWhen` / `readonlyWhen` / `requiredWhen` family. While it holds, the deadline is settled and no overdue wording or colour applies to that record, for example `record.status == 'done'`. + - **The doors that judge them.** `FieldSchema` refuses either key on any type other than `date` / `datetime`, and refuses `settledWhen` unless the field also declares `dueLike: true`. The CEL is judged by `@objectstack/lint`'s field-rule pass, the one `visibleWhen` goes through, at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no root but `record` (plus `previous`, and `parent` on a master-detail line item). + - **Display only.** Nothing on the write path reads either key. Every metadata that parsed before parses unchanged; a field without the keys behaves exactly as before. + - **Who reads them.** objectui's date and datetime cells read `dueLike` on the record detail surfaces today. Reading `settledWhen`, and retiring the field-name guess, follows in objectui once this spec version is published. + - **Where to author them.** The field designer offers both on date and datetime fields, `settledWhen` only once the field is a deadline. The example apps declare them on the showcase and todo `task` and the CRM `activity`. +- 3d886ee: A field can declare cell formatting rules: `conditionalFormatting: [{ condition, style }]`, the rule a list view already declares for its rows + + Clause-②: yes (widening) + + - **`FieldSchema.conditionalFormatting`** (optional) is an ordered list of `{ condition, style }` rules. The first rule whose CEL `condition` holds applies its CSS `style` map to that field's cell, wherever the field renders. A list view's row rules still style the row, and both may apply to one record. Example: `amount: Field.currency({ conditionalFormatting: [{ condition: P\`value < 0\`, style: { color: '#b91c1c' } }] })`. + - **One rule element, no second style vocabulary.** The `{ condition, style }` element moved out of `ListViewSchema` into its own spec module, and both `ListViewSchema.conditionalFormatting` and `FieldSchema.conditionalFormatting` mount it. The list view accepts exactly what it accepted before. Its refusal of an unknown rule key now reads the same on both members: for a `color` written beside `style`, it says to put the colour in `style` first, then points a list view at `rowColor`. + - **The scope is `value` and `record`.** `value` is this field's value on the record and `record` is the row. `@objectstack/lint` judges each condition at `objectstack build`, `objectstack validate` and the object save: it must parse, read fields as `record.`, name declared fields, and read no other root (`previous`, `parent` and the user roots are refused, naming the field and the rule). + - **For presentation only.** Use it for rules that carry no meaning of their own, such as an amount below zero in red or stock under its safety level in amber. A deadline's overdue state stays a declaration (`dueLike` with `settledWhen`). + - **Display only.** Nothing on the write path reads the key, and every metadata that parsed before parses unchanged. Field-level security drops a rule whose condition reads a field the caller cannot read, keeping the other rules in order (`@objectstack/metadata-core`). The SQL driver classifies the key as presentation, so it never reaches column DDL (`@objectstack/driver-sql`). + - **Who reads it.** objectui's cell renderers read the rules in a follow-up release, once this spec version is published. Until then a rule is validated when it is authored and styles no cell. +- 5910b5e: feat(spec): the error-code ledger lists `INVALID_REQUEST` under `@objectstack/verify` + + Clause-②: yes (widening: a new owner provenance row in the published error-code ledger) + + `ERROR_CODE_LEDGER['@objectstack/verify']` now lists `INVALID_REQUEST`. The verify handle's two write doors, `hooks.run` and `hooks.updateWhere`, answer it for a malformed call, such as a call naming two callers (`{ as: token, system: true }`), or a `hooks.updateWhere` the engine would write by id. They answer in-process, with a thrown `Error` carrying `code`, `status` and `statusCode`; it is a test door with no HTTP path. No code is added: `ErrorCode`, `RegisteredErrorCode` and `REGISTERED_ERROR_CODES` are unchanged, so `ApiErrorSchema` accepts exactly what it accepted before. +- 1884948: feat(spec): the error-code ledger lists `PAYLOAD_TOO_LARGE` under `@objectstack/service-storage` + + Clause-②: yes (widening: a new owner provenance row in the published error-code ledger) + + `ERROR_CODE_LEDGER['@objectstack/service-storage']` now lists `PAYLOAD_TOO_LARGE` (`413`), the code `@objectstack/rest` already registers for its import row ceilings. The storage upload doors answer it for an upload over the File Storage settings' `max_upload_mb`: the presigned upload's declared `size`, the chunked upload's declared `totalSize`, the local raw PUT body and each chunk's running total. + + No code is added. `ErrorCode`, `RegisteredErrorCode` and `REGISTERED_ERROR_CODES` are unchanged, so `ApiErrorSchema` accepts exactly what it accepted before. What widens is the per-package list a reader consults to learn which packages answer a code: a code emitted by several packages is listed under each of them. +- ac8f2c5: The shared retry policy refuses a key it does not declare, and the build doors now judge a `try_catch` node's config keys like every other builtin's: a `retry.maxRetry` slip under a `try_catch` node, or on a job's `retryPolicy`, no longer passes `objectstack validate` and `objectstack compile` to be dropped or refused later. + + Clause-②: no (narrowing: an undeclared key on the shared retry policy is refused at parse wherever it is written, a job's retryPolicy and a try_catch node's retry, and an undeclared try_catch config key at the build doors and the save door, where each passed) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** `RetryPolicySchema` — the one declaration behind `job.retryPolicy` and a `try_catch` node's `retry` — was a plain object schema, so it dropped a key it did not declare. Its defaults are opt-in (`maxRetries: 0`, `backoffMultiplier: 1`), so a dropped key fell back to "no retry" or to a flat delay: a job with `retryPolicy: { maxRetry: 3 }` parsed, deployed and never retried, and nothing said so. On a `try_catch` node the same strip kept the flow parse from judging the node's keys at all: its node type descriptor closes `retry` to five keys, so only `registerFlow`'s descriptor walk refused such a key, after `objectstack validate` and `objectstack compile` had passed it. + + **What is refused.** + + - **A key the retry policy does not declare**, on a job's `retryPolicy` or under a `try_catch` node's `retry`. The policy declares `maxRetries`, `backoffMs`, `backoffMultiplier`, `maxRetryDelayMs` and `jitter`. The refusal names the key and, for a near miss or a spelling borrowed from a neighbouring retry vocabulary, the declared key to write (`maxRetry` → `maxRetries`, `maxDelayMs` → `maxRetryDelayMs`, `baseDelayMs` or `initialDelayMs` → `backoffMs`, `retries` → `maxRetries`). A `maxAttempts` is answered with the off-by-one it carries: write `maxRetries` one lower. On a job it is the issue `unrecognized_keys` at `jobs.N.retryPolicy` (`defineStack` → `STACK_SCHEMA_INVALID`, 422; an artifact carrying it is refused whole at load). + - **A key a `try_catch` node's executor contract does not declare** — beside `try`, `catch`, `errorVariable` and `retry`, or under `retry` — at every door that parses a flow: `FlowSchema`, `defineFlow()`, `defineStack`, `objectstack validate`, `objectstack compile`, an artifact's parse, the metadata save door and `registerFlow`. It is the existing closed-set code `node-config-refused-by-contract`, `params: { nodeType: 'try_catch', key }`, anchored at the key (`nodes.N.config.retry.maxRetry`), in the contract's words and closed with the rename-or-remove remedy, from the same judge (`flowNodeConfigRefusals`) that already refused an undeclared key on every other builtin. `builtinNodeConfigKeysJudged('try_catch')` now answers `true`. + - **A `retryDelayMs` the load-path conversion leaves behind** under a `try_catch` node's `retry`: the conversion renames the pre-17 spelling to `backoffMs`, but keeps it when a `backoffMs` beside it holds a different value, and leaves a `null`. That copy is refused at `nodes.N.config.retry.retryDelayMs` in the tombstone's own words (rename it to `backoffMs`). Registration refused both before, as undeclared keys. + + **What stays as it was.** + + - Registration refuses exactly what it refused before. The descriptor walk (`validateNodeConfigKeys` in `@objectstack/service-automation`) stands aside for `try_catch` and now judges plugin node types only. A `try_catch` flow that carried such a key was already refused there, so a running flow changes nothing; the refusal now arrives as the parse's located issue instead of the walk's `Flow '…' rejected: N undeclared config key(s)` text. + - A key on a `try_catch` region object (`try`, `catch`), or on a region's nodes and edges: the region check's, at registration, as before. + - A pre-17 `retryDelayMs` alone: still renamed to `backoffMs` before the judge at every door that converts first (`defineStack`, `os validate`, `os compile`, `registerFlow`, the save door). Met by a direct `FlowSchema.parse` or `defineFlow()`, it meets its tombstone, like every other retired spelling. + - `Flow.errorHandling`, which carries the same policy keys, was already closed; a `script` node's retired keys keep the scope they had. + + **Also corrected:** `Flow.errorHandling`'s refusal of a `maxAttempts` key said a bare rename to `maxRetries` would run one attempt fewer than asked for; it runs one more (`maxAttempts: 3` is 3 runs, `maxRetries: 3` is 4), and the message now says so — the prescription, `maxRetries` one lower, is unchanged. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `maxRetry`, `retries` or `attempts` | `maxRetries` | + | `maxAttempts: n` (counts the first attempt) | `maxRetries: n - 1` | + | `baseDelayMs` or `initialDelayMs` | `backoffMs` | + | `maxDelayMs` | `maxRetryDelayMs` | + | `retryDelayMs` beside a `backoffMs` | delete it, keeping the `backoffMs` you meant | + | a key nothing reads | delete it | + + **The one-line fix: rename or delete the key the refusal names.** + + **Who is affected, measured.** Before closing the policy, every writer of either parser was read: in this repository the showcase job and flows, the `app-todo` flows, the docs, the service READMEs, the test fixtures and the load-path conversion's output; in the pinned objectui the job preview sample and the flow designer, whose descriptor form closes `retry` to the same five keys. All of them write only declared keys, and `objectstack validate` over the showcase and `app-todo` examples stays clean. `service-job`'s `runWithPolicy` reads an already-parsed policy and parses nothing. hotcrm, deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `RetryPolicySchema` (`shared/retry-policy.zod.ts`) is a `strictObject` with a curated near-miss table; the key arm of `flowNodeConfigRefusals` in `automation/flow-node-config-refusals.ts` judges every builtin in the executor contract map, and refuses a tombstoned key the author wrote on every judged type but `script`. No new code joins `FLOW_SLOT_REFUSAL_CODES`. + - **The ledger.** The D3 semantic entry `try-catch-and-retry-policy-undeclared-keys-refused` (protocol 18). No key is removed, so there is no new tombstone, and there is no D2 conversion: the platform cannot know what an undeclared key was meant to be. +- db9cf80: feat(cli): `os migrate security-catalog-overlays` lists, and with `--apply` deletes, the environment-wide rows a v18 cold boot refuses — run it before the first v18 boot + + Clause-②: yes (widening) + + - **What it is for.** A v18 cold boot refuses to start when the environment catalog holds a permission-set or position name that a configured package also declares (ADR-0048 N.3). A deployment upgraded from 17.x can carry such rows: a set or position saved in the environment over a package's name before the packaged locks refused that, or over one of the platform security plugin's own sets (`member_default`, `admin_full_access` and the rest). The server cannot clear them, because it does not start. This step is the offline remedy. + - **What it lists.** Every active, environment-wide `sys_metadata` row (`organization_id IS NULL`, `state = 'active'`) of type `permission` or `position`, the legacy plurals `permissions` and `positions` included, whose name a configured package holds. That is the population the cold boot refuses: the list is computed from the engine's own reading of who holds a name, not from a copy of it. A draft row and an organization-scoped row are not loaded at boot, so they are never listed. Each row is shown with its stored type, its `package_id` binding and the package that holds its name. + - **How it gets there without the refusal.** It composes the deployment as `os serve` does: the host config's plugins, the application or the compiled artifact, and the security plugin behind `serve`'s auth gate. It runs the kernel's first phase for them only, and boots without reading `sys_metadata` back into the registry, so the cold-boot check meets nothing and the boot comes up. No server starts. The security plugin's shipped sets are package-held names only where `serve` composes that plugin, so the step reads the environment `serve` reads. With `OS_AUTH_SECRET` (or `AUTH_SECRET` / `BETTER_AUTH_SECRET`) set, or on a development boot, the plugin is composed and its sets are held, unless the `auth` tier is off. The step takes `os serve`'s `--preset` and `--dev` with `serve`'s meaning, read through the rules `serve` reads them by: `--preset` names the tier preset the auth tier falls back to, and `--dev` composes the config's `devPlugins` and makes the boot a development one (as does `NODE_ENV=development`). The report says which way it went. **Run the step with the flags and environment the deployment boots with.** + - **Preview by default.** The preview boots read-only, like `os migrate plan`. It writes nothing, creates no database file, and exits `1` when it lists a row: the next boot would be refused. `--apply` deletes the listed rows, after a `[y/N]` prompt or `--yes`, and prints one audit line per row: what was deleted, through which write path, or why not. It exits `0` when every listed row is gone. `--json` prints one document (`listed`, `deleted`, `failed`, `rows[]` with `outcome`, `securityPlugin` and `serveFlags`). `--force` gets past a busy SQLite file. `--database-url` / `$OS_DATABASE_URL` name the target. + - **How it deletes.** A row stored under `permission` or `position` goes through the metadata protocol's own delete, the one `DELETE /api/v1/meta/:type/:name` uses. That writes a `sys_metadata_history` tombstone and a `sys_metadata_audit` row, with actor `os migrate security-catalog-overlays`. A row stored under a legacy plural is out of that door's reach, so it goes through the `sys_metadata` repository beneath it. That delete is addressed by the row's stored type, name, `package_id` and checksum, and writes the same history tombstone. + - **The refusal names it.** The cold boot's `NAMESPACE_CONFLICT` message now points at `os migrate security-catalog-overlays` for the environment's rows, in place of "through the metadata API on a boot that leaves the package out of the configuration, or in the database". The envelope (`code`, `status`, `conflicts[]`) is unchanged. + - **What it never does.** It adopts nothing: `managed_by` and `package_id` are never rewritten, and no row is renamed. A row the environment needs under its own name must be re-created under a name no package holds. A host config that exists and cannot be loaded is refused before any row is read, because the held names would be incomplete. + - **New exports it is built on.** + - `@objectstack/objectql` exports `findPackageHeldSecurityCatalogNames(registry)`: every package-held permission-set and position name, with its holder packages. The cold-boot check now reads the same list. + - `@objectstack/core` exports the auth gate `os serve` and the step share. `resolvePlatformAuthComposition` answers whether the platform composes `AuthPlugin` and the security plugin beside it, or why not. With it come `resolveStackTiers`, `STACK_TIER_PRESETS`, `CAPABILITY_TO_TIER`, `resolveAuthSecret`, `isDevelopmentBoot`, `DEV_AUTH_SECRET_FALLBACK`, `stackSuppliesAuthPlugin`, `isHostKernelComposition` and the `PlatformAuthComposition` / `PlatformAuthSkipReason` types. `Serve.TIER_PRESETS` and `Serve.CAPABILITY_TO_TIER` are now handles over the core declarations, and `os serve` composes exactly what it composed before. + - `@objectstack/runtime`'s `createStandaloneStack` accepts `hydrateMetadataFromDb: false`, which skips the `sys_metadata` read-back. The default stays on. + - `@objectstack/spec` registers the ADR-0087 D3 entry `security-catalog-environment-overlay-refused` in its migration registry and `spec-changes.json`, so `os migrate meta --from 17` names the refusal and this step as its remedy. Its projection is the entry under protocol 18 in the upgrade guide (`docs/protocol-upgrade-guide.md`). +- e75dced: `AuthSessionApi.getSession` (`@objectstack/spec/contracts`) now declares the optional `query.disableRefresh` that the in-process session readers send + + Clause-②: yes (widening) + + - **The declaration.** `getSession`'s input is `{ headers: unknown; query?: { disableRefresh?: boolean } }`. It was `{ headers: unknown }`. The return type and the rest of `AuthSessionApi` are unchanged. + - **Why.** The in-process readers call `api.getSession(inProcessSessionReadInput(headers))` (`@objectstack/types`). For a request that carries a better-auth session cookie, that input is `{ headers, query: { disableRefresh: true } }`, so the session renews only on the `get-session` route, which re-issues the cookie. A bearer-only request is still read with `{ headers }` alone. The declaration said the readers send only `{ headers }`, which stopped being true when the readers moved to the helper. + - **Nothing to migrate.** The added key is optional. A caller that passes `{ headers }` compiles as before, and so does an implementation of `getSession` that accepts `{ headers }` and ignores the rest. An implementation that reads `input.query?.disableRefresh` now type-checks against the contract instead of needing a cast. +- 9af0005: `ObjectSchema.attachedOnRead`: an object declares the blocks a service attaches to each row it serves, computed per caller on read and never stored + + Clause-②: yes (widening: a new optional `ObjectSchema` key) + + - **The key.** `attachedOnRead` is an optional map from block name to that block's leaves, each leaf naming its value type: `attachedOnRead: { viewer: { can_act: 'boolean', can_override: 'boolean', is_submitter: 'boolean' } }`. Block names and leaf keys take the field-name grammar (lowercase snake_case). A leaf's type is one of `number`, `text`, `boolean` or `date`, the four value types `Field.returnType` declares. + - **It is not a field.** It provisions no column, and no driver, form, list view, export, write path or translation bundle reads it. Its reader is the shared build validator, `@objectstack/lint`'s expression rule over `@objectstack/formula` (what `os build` and `os validate` run): `record.` resolves, and `record..` resolves only to a leaf the block declares (see the `@objectstack/formula` and `@objectstack/lint` entries). No other field-existence check reads it. + - **Refused at parse:** a leaf type outside the four, a leaf that is a nested block or a field definition, a block or leaf name outside the grammar, a block that names no leaf, and a block that repeats a field name the object declares. Each refusal is located at the offending key. + - **Nothing to migrate.** The key is optional and nothing writes it by default; an object without it parses and validates exactly as before. +- b2549a8: feat(spec): `ConnectorProviderContext.resolvePackagePath` — a connector provider factory can resolve a path against the declaring app's root + + Clause-②: yes (widening) + + - **What is new.** `ConnectorProviderContext` gains one optional, host-provided member, `resolvePackagePath(relativePath): Promise`, beside `loadPackageFile`. It resolves a relative path against the root of the stack or package that declared the connector entry and returns the absolute path. `'.'` returns the root itself. It refuses (throws on) an empty path, an absolute path, and a path that escapes the root after resolution, such as `../x` or `a/../../x`. That is the same rule `loadPackageFile` follows. It reads nothing and does not check that the path exists. + - **Who hands it.** The connector materializer in `@objectstack/service-automation` hands it to every provider factory. It is anchored at the plugin's `packageRoot` option, the same root `loadPackageFile` reads under, and falls back to `process.cwd()` the same way. The two members now share one confinement check, so they cannot disagree about what is inside the root. `loadPackageFile`'s behaviour and error messages are unchanged. + - **Who reads it.** Nothing yet. `@objectstack/connector-mcp` is next: a declarative stdio transport will use it as the launched process's working directory, so a relative command or argument resolves against the app's root instead of the directory the server was started from. + - **What does not change.** Which commands a declarative stdio transport may launch is untouched. A factory that does not read the member behaves as before. + - **Nothing to migrate.** A host that builds its own `ConnectorProviderContext` may leave the member out. A factory must then keep its existing behaviour, or fail with a clear message if it needs the root. +- ee8751d: feat(storage)!: the storage scope `public` is retired — no scope ever made a file publicly readable, and `acl: 'public_read'` stays the one opt-in for anonymous download (#22443) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on two published surfaces, shipped as `minor` under the launch-window convention for accept-set narrowings. + + `StorageScopeSchema` described `public` as "publicly accessible static assets", and the upload doors stored a caller's `scope: 'public'` on the new file. Neither ever made a file public. The download doors judge a file by its `acl`, the `attachments` scope and field ownership alone, so a file uploaded with scope `public` and the default acl was stored private, needed a signed-in caller, and nobody was told (ADR-0049 enforce-or-remove). ADR-0104 makes `acl: 'public_read'` the one opt-in for anonymous download, so the scope is retired, not enforced: enforcing it would have let any uploader make a file anonymous at upload. + + ### What now refuses `public` + + - **The presigned upload door and the chunked upload door** (`@objectstack/service-storage`) answer an upload naming `scope: 'public'` with `400 INVALID_REQUEST`, before any file row, session row, upload URL or backend upload exists. The message names the remedy. Every other scope, and an omitted one, is taken exactly as before. + - **`StorageScopeSchema`** (`@objectstack/spec`): `public` left the enum. Writing it fails `tsc`, and parsing it, on its own or as `ObjectStorageConfig.scope`, fails with the prescription instead of zod's generic enum message. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | an upload with `scope: 'public'` | another scope, or no `scope` for the default `user`; then set `acl: 'public_read'` on the stored file record of each file that must be readable before sign-in | + | `ObjectStorageConfig` with `scope: 'public'` | another scope, or no `scope` for the default `global` | + + **The one-line fix: stop naming scope `public`, and mark each file that must render before sign-in `acl: 'public_read'` on its stored file record.** The upload request carries no `acl`: every upload is stored `acl: 'private'`, so adding an `acl` to the upload request changes nothing. + + **Files already stored with scope `public`** download exactly as they did: a signed-in caller gets them, an anonymous one gets `401`, unless the file is `acl: 'public_read'`. Their stored file records are rewritten to scope `user` by the one-time operator sweep `@objectstack/service-storage` exports, which changes no access: the storage key and the bytes stay as they are. + + ### The retirement kit + + - **Schema.** `StorageScopeSchema` retires the member with `enumWithRetiredValues`. No D2 conversion: no metadata type carries this schema or the upload request, so `os migrate meta` has no authored source to rewrite. + - **D3 entry `storage-scope-public-retired`** carries the judgement no rewrite can make: whether a file that was uploaded as `public` must really be readable before sign-in. + - **Upload request contract.** The `scope` description on the presigned and chunked request schemas no longer offers `public` as an example, and says what the scope is and is not. + - **Liveness.** No ledger row: the liveness ledger walks metadata types, and no metadata type carries `StorageScope`. + + **Measured producers: none.** At origin/main da159f74e6, nothing in `packages/`, `examples/`, `apps/`, `skills/` or `content/docs/` uploads with scope `public` or declares an `ObjectStorageConfig` with it. The one hit was a `@objectstack/spec` request-schema test fixture, changed to `tenant` here. The same search finds 29 `scope: 'attachments'` writes, which is its control. At the `.objectui-sha` pin f0268ad784 and at objectui main 2063f7a, the upload adapter forwards a caller's scope and no caller names `public`: the console passes none, and the record attachments panel passes `attachments` (the control). Deployed callers and stored rows NOT MEASURED. +- 194a42d: feat(spec): `ComponentPropsMap` gains a `list-view` row, so a list view embedded on a page is judged as a list view; stored page nodes in the old `objectName` / `viewType` spelling convert to `data` / `type` on read + + Clause-②: yes (widening) + + - **The row.** `ComponentPropsMap['list-view']` is the list view's own members by reference (`ListViewSchema.shape`), with both of its object-level checks attached (the calendar visualization and the chart dataset binding), plus the two members the list overlay member declares instead: `columns` is optional, and the legacy `options` bag is judged per kind (`options.kanban` by the kanban block's schema, `options.grid` refused because a grid has no per-kind block). Until this row a page `list-view` node's `properties` were judged by nothing, so a kanban block's `groupField` or an `options.grid` bag, refused at every list view door, saved green on a page. `os validate`, `os build`, `os lint` and the page save door now report them through the existing component-props rule (`component-props-unknown-key`), naming the key. No rule, tier or door is added, and `PageSchema`'s accept set does not move. + - **The page carrier's old spelling.** A page `list-view` node names its object in `properties.data` and its list kind in `properties.type`, the spelling every other list view door uses. `properties.objectName` and `properties.viewType` are refused by name, with the prescription the react tier gives the same two aliases on `ListView` (`REACT_RETIRED_OVERLAY_PROPS.ListView`, now declared once for both tiers). Fix: replace `objectName: 'deal'` with `data: { provider: 'object', object: 'deal' }` (or name the object on the node's `dataSource.object`), and `viewType: 'kanban'` with `type: 'kanban'`; the node's own `type` stays `list-view`. `os migrate meta --from 17 --write` makes the edit where it can prove it. + - **Stored pages.** The ADR-0087 D2 conversion `page-list-view-object-name-view-type-to-data-type` moves the two aliases to `data` and `type` on every page `list-view` node it reaches (regions, slots, nested containers), retired from the load path: stored `sys_metadata` page rows, compiled artifacts and installed packages replay it on read, while an authored source meets the refusal. An alias beside a spec key that says something different is left as stored and reported as a TODO (`OS_METADATA_CONVERSION_TODO`). Its D3 record is the semantic entry `page-list-view-object-name-view-type-retired`. + - **Also classified:** a `list-view` inside a print page is refused with its reason (it pages its rows), and the view-level API data source refusal names `list-view` among the page components whose `data` prop takes a view data source. +- 35ef501: An `update`-mode `validate()` preview judges the stored row merged with the patch, as the by-id update does, so an import dry run of a matched row admits the rows the import's update admits (#22445) + + Clause-②: yes + + The preview's accept set widens: rows it refused, and the write admits, are now admitted. No key, export or error code is added, removed or renamed. + + - **Before.** An `update`-mode preview (`engine.validate(object, rows, { mode: 'update' })`, `validateData`, and the import dry run of a row that matched a stored record) read no stored row, so the record its rules judged held only the patch. A `requiredWhen`, an option `visibleWhen` or a `validations[]` rule that reads a column the patch omits faulted there, and the preview refused the row with `rule_violation` / `unevaluable`, although the real update reads the stored row first and admits it. The refusal also said the omitted column was one "which this object does not declare", sending the author looking for a field that exists. + - **Now.** A row that carries its `id` (the address every update door already folds into the payload) is judged against the row that id names: the stored row is the rules' `previous`, and their record is the stored row merged with the patch, exactly as the by-id update judges it. A reference field a traversing rule reads is resolved from that merge too. The import dry run of a matched row sends the matched record's id this way, so nothing new crosses the protocol. + - **Read under the caller's access.** The engine's read door, under the caller's own context, decides whether there is a row to judge: a row the caller cannot read gets the verdict a missing row gets. The values judged are the row as stored, read the way the by-id update reads its prior row (so a formula column, which has no stored value, is judged as the update judges it). A stored column is judged only where the read door served this caller that very value. A column the read door hid, or served transformed (a partially masked field, a masked secret), is judged as empty, so the verdict never depends on a value the caller could not read in full. File fields are compared in their stored form, so a readable file reference is judged as the id it holds. Measured on `driver-sql` (SQLite) and `driver-memory`, a plain readable column (number, currency, percent, boolean, date, datetime, JSON, multiselect, select, text) reaches the rules as its stored value. + - **Unchanged.** A row with no `id`, or whose id names no row the caller can read, is judged on the patch alone, as before. `required` and value checks still look only at the supplied keys, matching a PATCH. A merge that really violates a rule is refused, as the write refuses it. The preview still runs no `readonlyWhen` or primary-key strip, so it can report fewer dropped fields than the update. + - **The refusal names the real cause.** Where a rule reads a column the object declares and the judged record does not hold it (a preview with no stored row), the refusal now says the value was not supplied and no stored row was read, instead of saying the object does not declare the column. A column the object really does not declare keeps the old sentence. + + `@objectstack/core`: the import runner's dry run passes the matched record's id with an update row. `@objectstack/spec`: the `ValidateDataRequest.mode` description and the `ValidateDataResponse` note say what an `update` preview now reads. + + The pending entry for the option-gate change in this release says an `update`-mode preview reads no stored row and refuses a cascade pick whose parent column the patch omits. That describes the preview before this change: with the row's `id`, the preview now reads the stored row and judges the pick as the write does. +- faf6348: feat(i18n): a refused `end` node's message translates in the run's locale (`flows..refusals..message`) + + Clause-②: yes (widening) + + - **What is new.** An `end` node declaring `outcome: 'refused'` shows its `message` to the person who started the run. Translate it under `flows..refusals..message`, keyed by the flow's `name` and the `end` node's `id`, the same addressing the `screens` keys use. Before this key, a refusal rendered in the source language on every console. + - **Keep the holes.** The translation is a `{{ }}` template like the message it translates (`'已拒绝:{{ record.name }} 是重复记录'`). A single-brace `{token}` in a translation is refused when the bundle is parsed, with the same text-slot refusal the source message gets. + - **Where it is read.** The engine picks the translated template before it renders the holes, through the `i18n` service, in the run's locale, and the run stores and returns that text (`refusalMessage`). With no translation for that locale, the authored message renders. `flowRefusalMessageKey(flowName, nodeId)` (`@objectstack/spec/system`) spells the key. + - **The run's locale.** `AutomationContext.locale` (`@objectstack/spec/contracts`) is new and optional. The trigger door (`POST /api/v1/automation/:name/trigger`, the legacy trigger route, a `type: 'flow'` endpoint) and the action door (`POST /api/v1/actions/...`, the MCP `run_action` bridge) set it from the request's resolved locale: `Accept-Language` first, then the `localization` settings. It is persisted with a paused run, so a resumed leg renders in the locale the run was started in. A run no person started (a record-change or schedule trigger) has no locale and stores the authored message. + - **`objectstack validate`** refuses a refusal key over an unknown flow, an unknown node, a completed `end` node, or a node of another type (`translation-target-unknown`, error). + - **`os i18n extract` and the coverage gate** now scaffold and demand the refusal message of every refusing `end` node in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` and has a refusing `end` with no translation now gets one missing-key issue per locale for it. + - **`AutomationEngine.setI18nServiceSource()`** (`@objectstack/service-automation`) attaches the `i18n` reader. The automation plugin wires it, so a host using `AutomationServicePlugin` needs no change. A host constructing the engine directly gets the authored message until it attaches one. +- 557ae7c: `ISecurityService` declares an optional, feature-detected member that answers the existing ADR-0055 master-detail write check: `checkControlledByParentWrite(object, recordId, context)`. + + Clause-②: yes (widening) + + - **What it answers.** It answers what the master-detail write check decides for an update of one record by the caller. A by-id write to a `controlled_by_parent` record requires edit access to its master, and the write path checks each master up the chain: the object-level `update` grant, the master's write row-level security, and record sharing. The answer equals what a by-id update of the same record gets from that check, the ADR-0090 D10 delegator leg included. It adds no verdict: it exposes the one the write path already reaches, so a gate on a record's attachments or comments can judge the record as its own update is judged. + - **What it resolves with.** `ControlledByParentWriteOutcome` is a discriminated union. `allow` means the check does not refuse. `deny` names the leg that refused (`ControlledByParentWriteDenialLeg`: `object_permission`, `row_level_security`, `record_sharing` or `master_chain`). `not_applicable` means the object is not `controlled_by_parent`. `unresolvable` names why the check reached no verdict (`ControlledByParentWriteUnresolvedReason`: a missing `master_detail` relation, a record that does not exist, or an empty master reference). A store fault is not an outcome: the member rejects with the engine's own error, so the fault keeps its declared status (a datasource outage is `503`). + - **Optional, and absence is typed.** A security service without the member still satisfies the contract, and an unguarded call does not compile. Absence means no master check is composed for a consumer to ask, which is not a policy that admits. + + Nothing an author writes changes, and no runtime behaviour changes. `@objectstack/plugin-security` does not serve the member yet. An implementation typed as `ISecurityService` that serves this name must serve it under the declared signature. +- 6a3fe25: feat(spec,service-storage,client)!: one upload-scope list — the upload requests' `scope` and the SDK's `storage.upload` close to the new `UploadScope` enum, the `sys_file` scope select is built from it, and the upload doors answer any other scope with `400` naming the allowed values instead of `500 INTERNAL` (#22470) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published request contract and on the SDK method that sends it, shipped as `minor` under the launch-window convention for accept-set narrowings, plus one new export. + + The presigned and chunked upload requests declared `scope` an open string, while the stored file record (`sys_file.scope`, a closed select) only ever took `user`, `tenant`, `private`, `temp` and `attachments`. Any other value reached the `sys_file` insert, the data engine refused it as an invalid option, and the upload door answered that caller error as `500 INTERNAL`, with a message telling the operator to restore the data engine. + + ### What changes + + - **`UploadScopeSchema` / `UploadScope`** (`@objectstack/spec`, new, exported from `@objectstack/spec/api`): the upload-scope vocabulary, declared once — `user`, `tenant`, `private`, `temp`, `attachments`. It is a different list from `StorageScopeSchema`, which classifies a storage configuration and is not read by any upload. + - **`GetPresignedUrlRequestSchema.scope` and `InitiateChunkedUploadRequestSchema.scope`** read it. The default stays `user`. A literal outside the list fails `tsc`, and a parse refuses it on the `scope` key. + - **`client.storage.upload(file, scope)`** (`@objectstack/client`): the `scope` parameter is typed `UploadScope` instead of `string`, default `user` unchanged, so a scope outside the list fails `tsc` at the SDK call. `client.storage.getPresignedUrl` and `client.storage.initChunkedUpload` take the request types above and narrow with them. + - **The `sys_file` scope select** (`@objectstack/service-storage`) takes its options from the enum, in its order, with the same labels. The stored values do not change. + - **The presigned upload door and the chunked upload door** answer a scope outside the list — any string, a case variant, `null`, a number — with `400 INVALID_REQUEST`, naming the allowed values, before a file record, a session record, an upload URL or a backend upload exists. `public` is refused by the same gate and keeps its remedy (`acl: 'public_read'` on the stored file record). An omitted scope is the default `user`, as before. A real data-engine fault still answers `500`. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | an upload naming a scope outside the list (a key prefix such as `avatars`, a folder, a record path) | one of `user`, `tenant`, `private`, `temp`, `attachments`, or no `scope` for the default `user` | + | `client.storage.upload(file, scope)` called with a `scope` typed `string` (`@objectstack/client`) | pass one of the five, or type the value `UploadScope` (`import type { UploadScope } from '@objectstack/spec/api'`) | + | a caller passing a scope typed `string` into either upload request | type it `UploadScope` | + + **The one-line fix: send one of the five upload scopes, or none.** `attachments` is for a file whose referrers are record attachment rows (it is what orphan tombstoning reads); `temp` for a scratch file; otherwise `user` or `tenant`. + + **No stored file record moves**: no upload naming another scope ever succeeded, so no stored record carries one. + + ### The kit + + - **D3 entry `upload-request-scope-closed`** carries the judgement no rewrite can make: which scope a file that was sent under a free name really belongs under. No D2 conversion: no metadata type carries the upload request, so `os migrate meta` has no authored source to rewrite. + - **Liveness.** No ledger row: the liveness ledger walks metadata types, and no metadata type carries the upload request. +- 7731d70: `record:approval_decision` is a declared page component type — the approval decision panel of a `sys_approval_request` record page — with a `ComponentPropsMap` row that accepts no props + + Clause-②: yes (widening) + + - **New `PageComponentType` member:** `record:approval_decision`, in the reserved `record` namespace. One node shows an approval request's decision progress and its declared decision actions. It reads the record context (the request row the page binds, with its `viewer` and `decision_progress`) and `sys_approval_request`'s own declared actions, and nothing authored. Outside a `sys_approval_request` record page it draws nothing. + - **Its `ComponentPropsMap` row is an empty strict object.** A node with no `properties`, or `properties: {}`, passes. Any key inside `properties` is refused by name: `os validate` / `os build` / `os lint` report it as `component-props-unknown-key`, naming the component and the key. Node-level keys (`id`, `className`, `visibleWhen`, …) stay on the node as for every component. + - **A misspelling is refused.** Because `record` is a reserved namespace, `record:approval_decison` and other near spellings are `component-type-unknown` errors that offer `record:approval_decision`. + - **Not printable.** Inside a page that declares `print`, the type is refused with its own reason (decision controls that differ by viewer, with nothing to print), like the other action-control blocks. + - **Consumers.** objectui renders it (objectui#12045, the approvals request page), and `@objectstack/plugin-approvals` places it on the request page it ships (objectstack#22473). A consumer that derives coverage from `PageComponentType.options` — a designer palette, a renderer registry — sees one more member to classify. +- 3073b72: A `{{ }}` hole in a flow text slot (a `notify` node's `title` and `message`, a `screen` node's `title` and `description`, a refusing `end` node's `message`) whose root is a `$` name the flow engine does not bind is refused. It is refused at the same doors, and by the same judge (`textSlotTemplateRefusal`), as a single-brace token: the node contracts, `registerFlow` and `objectstack validate`. `'By {{ $User.Id }}'` used to pass all three and send `'By '`. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** The hole grammar admits `$` in a name so that the engine's own variables have a spelling (`{{ $error.message }}`). That also makes `{{ $User.Id }}` a well-formed hole, but over a root no flow variable answers to. The text went out with the fragment missing, the run reported success, and nothing warned. Its single-brace spelling, `{$User.Id}`, was already refused with a remedy. The `$` names are reserved for the engine: a resume signal may not write one. + + **What is refused.** + + - A hole whose root is a `$` name other than the variables the engine binds: `$record`, `$runId`, `$flowName`, `$flowLabel`, `$error`, and a flat-graph `loop`'s `$loopItems` / `$loopIndex`. + - `NotifyConfigSchema`, `ScreenConfigSchema` and `EndConfigSchema` raise a `custom` issue at the slot's key. + - `registerFlow` refuses the flow, and a stored flow carrying such a hole is skipped at boot with a warn naming it. + - `objectstack validate` reports `expression-invalid` at `error`. + - The remedy for `{{ $User. }}` is the sentence `{$User.}` gets: for `$User.Id`, compute the run user's id into a variable with an `assignment` node's CEL value envelope (`current_user.id`), then write the variable as a hole; any other `$User` path never resolved, and the refusal says how to read the user record by that id. Any other root is named in the refusal, beside the variables the engine does bind. + - A single-brace path token over such a root (`'Failed: {$caught.message}'`) is no longer prescribed the `{{ }}` spelling, which would be refused in turn; it gets the same remedy. + + **Unchanged.** `{{ $error.message }}`, `{{ record.name }}`, a node output `{{ lookup.result }}` and every hole over an engine-bound `$` variable. The template engine binds no new variable. + + **`@objectstack/lint`.** In a text slot, `flow-bare-dollar-reference` prescribes the hole for a bare `$X.y` written outside the holes only when the judge admits that hole. A bare `$User.Id` gets the judge's refusal and remedy instead of a `{{ $User.Id }}` the judge refuses. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `message: 'By {{ $User.Id }}'` | an `assignment` node first, `assignments: { by: { dialect: 'cel', source: 'current_user.id' } }`, then `message: 'By {{ by }}'` | + | `errorVariable: '$caught'` with `message: 'Failed: {{ $caught.message }}'` | `errorVariable: 'caught'` with `'Failed: {{ caught.message }}'`, or keep the default `$error` and write `{{ $error.message }}` | + + **The one-line fix: compute a run-user value into a variable first, and name a variable the flow binds itself without the `$`.** + + **Who is affected, measured.** The last published spec, `@objectstack/spec@17.7.0` (npm `latest`), has no text-slot judge. Its `NotifyConfigSchema.title` / `.message`, `ScreenConfigSchema.title` / `.description` and `EndConfigSchema.message` are plain strings, so it accepts `'By {{ $User.Id }}'` in every one of these slots. Its single-brace interpolator substituted the inner `{ $User.Id }` token and left a literal brace on each side. This repository was measured with `git grep` over `examples`, `packages`, `skills`, `apps` and `content`: no flow text slot outside tests carries a `{{ $… }}` hole other than `{{ $error.… }}`. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `textSlotTemplateRefusal` in `automation/flow-text-slot-template.ts` reads one package-internal list of the `$` variables the engine binds. `@objectstack/service-automation`'s `text-slot-template.test.ts` scans that package's sources for every `$` variable they bind by name, and fails when the list misses one. + - **The ledger.** The D3 semantic entry `flow-text-slot-unbound-dollar-root-refused` (protocol 18). There is no D2 conversion: what the hole was meant to read is not in the flow. +- 5b12503: feat(spec): the protocol upgrade guide ships inside `@objectstack/spec`, and both ADR-0087 projections are generated and verified at publish + + Clause-②: no + + `@objectstack/spec` now ships `protocol-upgrade-guide.md` beside `spec-changes.json`. An upgrading project reads the guide for the exact version it installed, offline: + + ```bash + cat node_modules/@objectstack/spec/protocol-upgrade-guide.md + ``` + + The same file is attached to the package's GitHub Release, next to `spec-changes.json`. Its content is unchanged: one section per protocol major from the chain's support floor on, generated from the ADR-0087 conversion table and migration chain. + + Both projections are now generated by the release lane, from the registries, right before the tarball is packed. The lane refuses to publish an artifact whose copies differ from a fresh generation. This covers the per-major records and aggregate of `spec-changes.json`, and the whole guide. The rc lane takes the same step, so an rc tarball no longer carries a copy that was committed earlier. The per-release `release` section of `spec-changes.json` is generated and verified exactly as before. + + Nothing changes for an author, and nothing is renamed or removed. +- f89b5f5: feat(i18n): a screen field's option labels and a flow's terminal toasts have translation keys (`flows..screens..fields..options.`, `flows..successMessage` / `.errorMessage`) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. Two options of one screen field whose values read the same as text — `1` and `"1"`, `true` and `"true"`, or a repeated value — are now refused when the flow is parsed (`FlowSchema`, `objectstack validate`, the metadata save door, `registerFlow`), at the second option's `value`. Neither the option's label translation (keyed by the value as text) nor the console's select can tell such a pair apart. To fix a refused flow, give each option of the field a value that stays distinct as text, or drop the duplicate. Measured reach in this repository: 0 — no flow in its examples carries such a pair. + + - **What is new.** A fully translated screen flow no longer has to keep its select options and its completion or failure toast in the source language. Translate an option's label under `flows..screens..fields..options.`, and the flow's own toasts under `flows..successMessage` and `flows..errorMessage`. + - **How an option is addressed.** By its value read as text: `String(value)`, so the option `{ value: 1, label: 'Tier 1' }` is translated under `options.1` and `{ value: true, … }` under `options.true`. `flowScreenFieldOptionKey(value)` (`@objectstack/spec/automation`) spells the key. Not by position (reordering options would re-point every translation), and not by an object field's options (a screen field declares no such binding). + - **One new refusal**, the narrowing the BREAKING note above describes: option values of one screen field must stay distinct as text, because the label translation is keyed by them. + - **The toasts translate only where the flow authors one.** A bundle cannot add a toast to a flow that declares none; `objectstack validate` refuses such a key (`translation-target-unknown`, error). + - **Where it is read.** `translateFlow` (`@objectstack/spec/system`) overlays both, and `resolveFlowScreenFieldOptions(bundle, flowName, nodeId, field)` resolves a served screen field's options. The console's flow runner reads them in a following release; until then a translation is stored and the authored text is drawn. + - **`objectstack validate`** warns on an option key that names no declared option of the field, or names it by its label (`translation-option-key-unknown`). + - **`os i18n extract` and the coverage gate** now scaffold and demand each screen field's option labels, the failure toast of every flow that declares one, and the completion toast of a flow with a screen, in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` gets one missing-key issue per locale for each until it translates them. + - **A screen's `description`** is keyed too, in the same release, and translated by the engine rather than the console: see the entry for `flows..screens..description`. +- d25b187: feat(i18n): a screen's body text has a translation key, and a screen's heading and body text are translated by the engine in the run's locale (`flows..screens..description`) + + Clause-②: yes (widening) + + - **What is new.** A fully translated screen flow no longer has to keep a screen's body text in the source language. Translate it under `flows..screens..description`, beside the heading's `flows..screens..title`, whose address does not change. + - **A translation is a template.** A screen's `title` and `description` are `{{ }}` templates rendered for each run, so a translation keeps the holes of the text it translates: `description: '任务“{{ subject }}”创建成功!'` for `'Task "{{ subject }}" created successfully!'`. The schema judges a translated `description` with the same text-slot rule the source text gets, so a single-brace `{subject}` is refused with the `{{ subject }}` spelling. An empty string is accepted: it is the untranslated slot `os i18n extract` writes. + - **The body text translates only where the screen authors one.** A bundle cannot add body text to a screen that declares no `config.description`. + - **Who translates it.** The automation engine, not the console. It picks the translated template in the locale of the person who started the run and then fills the holes, so the screen a paused run serves is already translated. `ScreenSpec.title` and `ScreenSpec.description` now document that a client draws them as served and never overlays a translation on them. + - **`flowScreenCopyKey(flowName, nodeId, key)`** (`@objectstack/spec/system`) spells the key the engine reads, beside `flowRefusalMessageKey`. `FLOW_SCREEN_COPY_KEYS` is now `['title', 'description']`, and `translateFlow` overlays a translated `description` on a flow document where the screen authors one. + - **`resolveFlowScreenTitle` is not for a served screen.** A served `ScreenSpec.title` is already translated and filled, so overlaying the resolver's answer on it would draw the template's holes as literal text. The console stops calling it in a following release, and the function retires after that. +- dab6bf4: `record:approvals` and `record:attachments` are declared page component types — the record page's Approvals and Attachments panels — each with a `ComponentPropsMap` row that accepts no props + + Clause-②: yes (widening) + + - **Two new `PageComponentType` members:** `record:approvals` (the record's approval timeline: the step its approval sits at, who it waits on, what has been decided) and `record:attachments` (the record's attached files, with upload, download and delete). objectui already renders both, and its default record page places them: the Attachments tab on every record page of an `enable.files: true` object, the Approvals tab on a record that has approval requests. + - **The page Studio's page create seeds now passes `os validate`.** Before, `record` was a reserved namespace with neither type declared, so `os validate` / `os build` / `os lint` refused the seeded page of any `enable.files` object with `component-type-unknown` on its `record:attachments` node. The metadata save door did not refuse it and still does not. + - **Both rows are strict and accept no key.** A node with no `properties`, or `properties: {}`, passes. Any key inside `properties` is reported as `component-props-unknown-key`, naming the component and the key. Node-level keys (`id`, `className`, `visibleWhen`, …) stay on the node as for every component. + - **`record:approvals` refuses the runtime channel it reads, with the fix.** Its renderer reads `approvals` (the approval requests the default record page fetched) and `currentUserId` (the signed-in user) from the node, and both are filled by the host at runtime. An authored `approvals` would show a static approval history that never updates, and an authored `currentUserId` would decide for every viewer who counts as the submitter. Both are refused with a message that says to omit them: the block fetches the record's own approval requests and reads the signed-in user itself. + - **A misspelling is refused.** `record:attachment`, `record:aprovals` and other near spellings are `component-type-unknown` errors that offer the declared spelling. + - **Not printable.** Inside a page that declares `print`, each type is refused with its own reason, in place of the generic "not in the printable block subset" one. + - **Consumers.** A `kind: 'react'` page that names `` or `` is now refused by `react-block-needs-record-context`, as every `record:*` block is: these blocks read the record context a record page mounts, and a react page mounts none. A consumer that derives coverage from `PageComponentType.options` or the keys of `ComponentPropsMap` — a designer palette, a renderer registry — sees two more members to classify. +- a00cf99: feat(spec): `enable.approvalsVisibleToReaders`, the per-object opt-in for read-only approval visibility to a record's readers + + Clause-②: yes (widening) + + - **The key.** `ObjectCapabilities` (an object's `enable` block) gains `approvalsVisibleToReaders`, a boolean that defaults to `false`. An object that declares nothing is unchanged. + - **What `true` grants.** A caller who can read a record of the object sees that record's approval requests and their full action history, read-only. "Can read" is the object's own CRUD, sharing and RLS, asked as the caller. No new permission is granted. This holds on the approvals API and on the generic data API alike (`sys_approval_request`, `sys_approval_action` and `sys_approval_approver`), on a read that names the record. A list that names no record, such as the inbox, is not widened. No approval action is offered: approve, reject, reassign, recall and comment keep authorizing as before. + - **What becomes visible.** The request row, including its snapshot of the record at submission, and each action's actor, decision, time, comment text and attachments. Turn it on only for objects whose approval commentary the record's readers are meant to see. + - **Studio.** The object form's Capabilities section lists the new toggle. + - **Nothing to migrate.** To opt an object in, write `enable: { approvalsVisibleToReaders: true }` on it. +- 29a163f: `sys_comment_reaction`: an emoji reaction to a comment is now the reacting user's own record, one row per comment, emoji and user + + Clause-②: yes (widening) + + - **The object.** `@objectstack/plugin-audit` registers `sys_comment_reaction` beside `sys_comment`. A row holds `comment_id` (the reacted comment's id), `emoji` (the character sequence, at most 64 characters) and `user_id` (the user who reacted). The data API offers `get`, `list`, `create`, `delete` and `bulk` on it, so `createMany`, `deleteMany` and `/batch` creates and deletes work too. It offers no `update`, so `updateMany` stays refused: a reaction is removed and made again, not edited. Every row of a batch meets the same rules as a single write. + - **Who may read it.** A reaction is readable exactly when its comment is. Every read of the object (`find`, `findOne`, `count`, `aggregate`, and so the list total) is narrowed to the rows whose comment the caller can read, decided by a read of those comments under the caller's own context. A reaction whose comment was deleted is read by nobody. + - **Who may write it.** A caller reacts only to a comment it can read; on any other comment, including one that does not exist, the create is refused with `403 RECORD_NOT_ACCESSIBLE`. `user_id` is set from the signed-in user, and a value the client sends is replaced; a session with no user is refused. A member deletes their own reaction, and not another member's, by the platform's own-record rule (`owner_only_deletes`). The author-only edit rule on `sys_comment` is unchanged: reacting no longer writes the comment's row. + - **One row per reaction.** `(comment_id, emoji, user_id)` is unique within an organization. Two members reacting at once write two rows. The same reaction a second time is refused, and the data API answers `409 UNIQUE_VIOLATION`. + - **Reading reactions for a feed.** Read the reactions of the comments on screen in one request, `comment_id` `$in` those ids, and group the rows by comment and emoji. Nothing is stored or computed on the comment row. + - **Granting it.** The object's create, read and delete bits come from a permission set, as `sys_comment`'s do. The `everyone`-bound baseline (`member_default`) grants nothing on it. + - **`@objectstack/spec`: the platform object registry lists it.** `PLATFORM_OBJECTS_BY_PACKAGE['plugin-audit']` now includes `sys_comment_reaction`, and so do `PLATFORM_PROVIDED_OBJECT_NAMES` and `isPlatformProvidedObjectName`. A stack that references the object is no longer reported as naming an unknown platform object. This is the only change in the package. + - **`sys_comment.reactions` is unchanged in this release.** The column is still declared and still written by clients that write it. It is retired separately, and `os migrate comment-reactions` converts the reactions stored in it into records. +- e223152: feat(spec)!: `PageSchema` refuses a page that authors both `slots.details` and `slots.tabs`; the `sys_user` record page carries its details grid as its first tab + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** The slot map read as seven independent slots, and two of them are not. On a slotted record page `details` replaces the body of the Details tab, that tab lives inside the synthesized `page:tabs` strip, and `tabs` replaces the whole strip. The console's default-page synthesizer therefore reads `tabs` and never reads `details` when both are authored. A page authoring both passed `PageSchema.parse`, `objectstack validate` and the metadata save door, and its details body (its `sections`, its `hideFields`) silently never applied. The platform's own `sys_user` record page was one: its Identity and Audit sections never showed, and the ban columns it hides were never hidden by it. + + **What is refused.** A page whose `slots` map carries both a `details` and a `tabs` key, at `slots.details`, with a `custom` issue that names both slots and the fix. Either slot may be one component or an array; an empty `details: []` is refused like a full one, and the page's `kind` does not matter. That covers `PageSchema`, `definePage`, `defineStack` (`STACK_SCHEMA_INVALID`, 422), `os validate` and the metadata save door (`422 INVALID_METADATA`). The check is exported as `checkPageSlotPair`, for a mirror built from `PageSchema.shape` to re-attach. + + **What is still accepted, byte for byte.** A page authoring only `details`, only `tabs`, or neither. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `slots: { details: { type: 'record:details', … }, tabs: { type: 'page:tabs', properties: { items: [ … ] } } }` | `slots: { tabs: { type: 'page:tabs', properties: { items: [ { label: 'Details', children: [ { type: 'record:details', … } ] }, … ] } } }` | + | both slots, wanting the synthesized tabs with your details body | `slots: { details: { type: 'record:details', … } }`, with no `tabs` slot | + + **The one-line fix: move the `record:details` component into the `tabs` items (the first item's `children`, by convention) and delete `slots.details`.** Its `sections` and `hideFields` move unchanged. The page then shows the details body it always declared, which is a visible change on every page that authored the pair. + + **Who is affected, measured.** In this repository, only the `sys_user` record page (`sys_user_detail` in `@objectstack/platform-objects`) authored both slots; no example app page does. hotcrm's one slotted page authors `header` and `discussion` only. Deployed metadata was not measured. A page row already stored with the pair is replayed unchanged at load, so it renders as before (the authored tabs, without the details body); its read diagnostics name the pair, and saving it again is refused until the details body moves. + + ### The kit + + - **The refusal.** `checkPageSlotPair`, attached to `PageSchema` by identifier beside its three other object-level checks. No new error code. + - **The producer.** `sys_user_detail` moves its `record:details`, with its `sections` and `hideFields` unchanged, in as the first `tabs` item. + - **The ledger.** The D3 semantic entry `page-slots-details-beside-tabs-refused` (protocol 18) and its step-18 rationale fragment. No key is removed, so there is no tombstone, and there is no D2 conversion: which tab item carries the details body, and under which label, is the author's decision. +- 9963322: feat(plugin-audit)!: `sys_comment.reactions` retires; an emoji reaction lives only in `sys_comment_reaction`, and reactions stored in the old column are converted into `sys_comment_reaction` records by `os migrate comment-reactions` + + Clause-②: no (narrowing) + + + + **BREAKING**, shipped as `minor` under the repo's convention for breaking changes on this line. + + **Existing emoji reactions stored in `sys_comment.reactions` survive the upgrade.** They stay in the column, which no screen or API returns, until `os migrate comment-reactions` converts them into `sys_comment_reaction` records, and `os migrate apply --allow-destructive` drops the column only after that conversion is verified (see *Existing databases* below). Reactions now live in `sys_comment_reaction`, one row per comment, emoji and user, the reacting user's own record. Where the entry that introduces `sys_comment_reaction` says the column is unchanged, this entry supersedes it: the column retires. + + - **`sys_comment`** (`@objectstack/plugin-audit`) no longer declares `reactions`. Nothing takes its place on the comment row: no stored aggregate, and no block computed on read. Its label leaves the four generated translation bundles. + - **The data door refuses the field.** A create or an update of a comment that still carries `reactions`, and a list filter on it, answer `400 INVALID_FIELD` naming the field, and nothing is written. The rest of the payload is not applied either. A read of a comment no longer returns `reactions`. + - **`@objectstack/spec`**: the protocol-18 step of the ADR-0087 migration chain gains the semantic entry `sys-comment-reactions-column-retired`, so `os migrate meta` and the upgrade guide carry the prescription below. + + **What moves for consumers.** + + | You wrote | Write instead | + |:---|:---| + | `sys_comment.reactions` read as a map of emoji to user ids | one read of `sys_comment_reaction` for the comments on screen (`comment_id` `$in` their ids), grouped by comment and emoji | + | an update of `sys_comment` that rewrites `reactions` to add or remove a user | create the user's own `sys_comment_reaction` row (`comment_id`, `emoji`) to react; delete that row to un-react | + | `reactions` in a seed row, filter or list-view column on `sys_comment` | delete it | + + An application that shows reactions grants `sys_comment_reaction` read, create and delete beside its `sys_comment` grant; without it a member cannot react. The console's record chatter already reads and writes `sys_comment_reaction` rows wherever the deployment declares that object, as this framework does. + + **Existing databases — nothing moves automatically.** Schema sync is additive: the physical `reactions` column stays with the values it holds, the boot drift report names it orphaned (`sys_comment.reactions: column exists in the database but not in metadata`), and the boot is otherwise clean. The data door neither returns nor accepts the column. Convert first, then drop. `os migrate comment-reactions` previews the conversion and writes nothing; `os migrate comment-reactions --apply` turns each stored (comment, emoji, user) into that user's own `sys_comment_reaction` record, in the comment's organization, lists every stored value it cannot read instead of guessing at it, and records the run verified once every stored value has read and converted. A re-run converts nothing twice. Only then does `os migrate apply --allow-destructive`, run with the audit plugin composed (an auth secret resolves), drop the column; before a verified run is recorded it refuses the drop and exits `1`, because the drop would delete the reactions. +- b27b894: feat(spec): the approvals service contract declares an optional, transport-neutral action-page member (`IApprovalService.handleActionPage`) + + Clause-②: yes (widening) + + - **What is new.** `IApprovalService` in `@objectstack/spec/contracts` gains one optional member, `handleActionPage?(request: Request): Promise`. It serves the ADR-0043 action page, the session-less page an approver reaches from the approve or reject link in an e-mail or IM message. `GET` renders the confirm page from the `token` query parameter and never decides. `POST` redeems the `token` form field and answers the result page. + - **The token is the only credential.** The member takes no `ExecutionContext` and reads no session, cookie or `Authorization` header. The decision is audited as the approver the token binds. + - **Transport-neutral.** A web-standard `Request` in, a `Response` out: no Hono context, no raw app and no `http.server` type. A hosted tenant kernel has no raw app to mount pages on, and the runtime dispatcher's `/approvals/act` domain is the member's one caller. + - **Absence.** The member is optional, so an approvals implementation without action pages still satisfies the contract. Its caller answers a typed 404 or 501 when the `approvals` service or this member is absent, never `ROUTE_NOT_FOUND`. + - **Nothing changes for existing implementers.** No existing member moved. Nothing implements or calls the member yet: the plugin implementation and the dispatcher domain land separately, and the plugin's self-hosted pages stay as they are. +- 02d9f69: A file field whose `sys_file` read was refused to the reader reads as refused, not as "no file" + + Clause-②: yes + + The engine expands a stored file id (`file` / `image` / `avatar` / `video` / `audio`) by reading `sys_file` as the caller. A reader who may read the record but not `sys_file` (the default for any member whose application grants no `sys_file` read) is refused that read, and the field used to come back as the bare id: the same value an id with no committed file row produces. A refused file and an absent one were one answer, and the server logged a `warn` per read telling the operator to check storage availability. + + - **From.** `{ "attachment": "f_7cQx2Lm90a" }` for a reader refused `sys_file`, plus `warn: sys_file lookup failed; file fields keep their raw ids and will render as "no file"`. + - **To.** `{ "attachment": { "id": "f_7cQx2Lm90a", "metadataRefused": true } }`. Multi-value fields mark each id. Nothing else is served, because nothing else was read: no `name`, `size`, `mimeType` or `url`. A consumer that needs the download link derives the stable `/api/v1/storage/files/:fileId` endpoint from the id, as it does for a bare id; the download door judges access by the record that owns the file. The refusal is logged at `debug` only, because the reader is told in the value. + - **Unchanged.** A reader allowed to read `sys_file` gets the hydrated `{ id, name, size, mimeType, url }`. An empty field reads empty. A missing `sys_file` table still leaves the bare id, silently. Any other read failure (connection, timeout, a database-level ACL fault such as `42501`) still leaves the bare id with the `warn`. Only the security layer's `PERMISSION_DENIED` refusal takes the new form. + + `@objectstack/spec` declares the shape: `FileRefusedValueSchema` / `FileRefusedValue` (`{ id, metadataRefused: true }`, closed), and `valueSchemaFor(def, 'expanded')` for the file types admits it beside the bare id and `FileValueSchema`. It is a read form only. The stored form stays the bare id. + + **For consumers.** Code that reads a file field's expanded value as `string | FileValue` should handle the third form: an object with `metadataRefused: true` and no `url` is a file this reader may not see the details of, not an empty field. +- f53f14b: `ListView.userActions.editInline` defaults to `true` on the v18 line: a list view is editable in place by default, under the permission gate that already exists, and `userActions: { editInline: false }` is the opt-out. + + Clause-②: no + + + + **BREAKING** — a declared default moves, shipped as `minor` under the launch-window convention. The author who relied on the documented default (`false`, "the list is read-only unless the author opts in") for a list that is read-only by nature — a log, an audit trail, a history, a report roll-up — now gets an inline-edit toggle on it, for every user who may `update` the object. The remedy is one key on that view: `userActions: { editInline: false }`. + + **The ruling.** Maintainer, 2026-10-10, verbatim: 「乙 v18 把 spec 默认翻成 true,editInline: false 变成关法。」 The rule of objectui#5144 stands — one vocabulary (`editInline`), a boolean view-level `inlineEdit` folds into it, an explicit `editInline` wins, declared = enforced; only the default's value changes. Not taken: declaring `editInline: true` on every existing view, and an app-level default between the spec default and the view. + + **What moves.** `UserActionsConfigSchema.editInline` is `z.boolean().default(true)` where it was `.default(false)`, with the `describe` rewritten: on by default; a user who may update the object edits a cell in place with the field's type-aware widget; declare `editInline: false` to make the list read-only in place. A list view's `userActions` block, and a page's `interfaceConfig.userActions` block (the same schema), that omits `editInline` now parses to `true` where it parsed to `false`; the published JSON Schema and the reference pages (`content/docs/references/ui/view.mdx`, `page.mdx`) say so. A view with no `userActions` block at all parses with none on either side; the renderer reads an absent key as this default. The accept set is unchanged: `editInline` is still a boolean, and every authored value parses exactly as before. The permission gate is untouched: the renderer offers the toggle only where the object is editable in place and the current principal may `update` it. + + **The consumer half** is objectui's (objectstack-ai/objectui#12086): `ListView.tsx` and `InterfaceListPage.tsx` read an absent key as `editInline !== false`. Until that lands, the console at the pinned objectui still reads an absent key as off, so nothing regresses against today in the window. + + ## FROM → TO + + | you wrote | what it means now | write instead, to keep the old behaviour | + |:--|:--|:--| + | nothing (no `editInline`) on a list view or a page list | editable in place by a user who may `update` the object | `userActions: { editInline: false }` | + | `editInline: false` | unchanged: read-only in place | nothing | + | `editInline: true` | unchanged: editable in place | nothing | + | a view-level `inlineEdit: true` | unchanged: folds to on and opens the grid in edit mode | nothing | + + **The one-line fix: on a list that must stay read-only in place, write `userActions: { editInline: false }`.** An `os compile` artifact that serialised an earlier parse carries a written `editInline: false` and stays read-only in place: recompile it, or delete the key. + + **Who is affected, measured** at `e11ef3a7bd`. No metadata in this repository declares `editInline` at all (0 `editInline: false`, 0 `editInline: true` across `examples/**` and `packages/**`, tests and changelogs excluded). Every list view that declares nothing gains the toggle: 38 list-kind views declared with an explicit `type` across 10 example `*.view.ts` files (entries with no `type` are grid by default and count the same way), 12 `interfaceConfig` page lists across 5 showcase page files (11 declare a `userActions` block, none naming `editInline`), and 105 list-kind views across 40 platform `*.object.ts` files in `packages/**`. One list view declares the view-level `inlineEdit: true` (the showcase `task` grid saved view) and keeps opening in edit mode. The card's readings outside this repository: cloud's `sys-package.page.ts` already declares `editInline: false` explicitly; HotCRM's 41 undeclared views regain cell editing after both packages upgrade, with no metadata change there. + + ### The kit + + - **The default and its describe.** `packages/spec/src/ui/view.zod.ts`, `UserActionsConfigSchema.editInline`; the schema docblock records the ruling beside the group/hideFields/rowColor asymmetry it already explains. + - **The declared default change.** `DEFAULT_CHANGES_BY_MAJOR[17]` (`packages/spec/scripts/lib/default-changes.ts`) carries `ui/UserActionsConfig:editInline` `false` → `true`, which is what lets `check:authorable-surface` authorise the moved fingerprint in `authorable-defaults/ui.json`; `CURRENT_MAJOR` is the package major (17) until the version pass takes the group to 18. + - **The ledger.** The D3 semantic entry `list-view-edit-inline-default-on` (protocol 18), one file under `src/migrations/entries/semantic/`, concatenated into `registry.ts` by `gen:migration-registry`, projected into `spec-changes.json` and `docs/protocol-upgrade-guide.md`. No key is removed, so there is no tombstone and no `RETIRED_KEYS_BY_MAJOR` row; no D2 conversion exists, because a mechanical pass writing `editInline: false` into every silent view would preserve the old posture and defeat the ruling, and one writing `true` would add nothing the default does not already do. The same class as `view-pagination-page-size-default-50` and protocol 12's `rest-requireauth-default-flip`. + - **The pins.** `packages/spec/src/ui/view.test.ts`: an absent key parses to `true` (on `UserActionsConfigSchema` and through `ListViewSchema`), an explicit `false` stays `false`, an explicit `true` stays `true`, and the view-level `inlineEdit: true` still parses as before with no `userActions` block materialised. + - **The skill.** `skills/objectstack-ui/rules/list-views.md` gains its paragraph in a separate pull request, because `skills/**` is Tier H. +- a800912: feat(spec): a list view and a dashboard take `requiredPermissions`, the audience gate, declared now and not enforced yet + + Clause-②: yes (widening) + + - **What is new.** `ListViewSchema` and `DashboardSchema` each accept `requiredPermissions`: a list of capability names (the names permission sets grant through `systemPermissions`) a user must ALL hold. It is the same key, with the same shape (`string[]`, optional, no default) and the same meaning, as `requiredPermissions` on an app, a navigation item and an action. Before this release both schemas refused the key as an unrecognized key. Because a list view's shape is shared, the key reaches every list-view door: a view container's `list` and `listViews` entries, an object's `listViews`, and a view item's `config`. + - **Not enforced yet.** No server reads either key in this release. A gated list view or dashboard is still served to every user who can read it, so do not rely on the key to keep a view or a dashboard from an audience yet. The key's description says so, and `os lint` / `os validate` warn (`liveness-planned-property`) when a dashboard or a view container's `list` sets it. Enforcement on the `/meta` reads of `view` and `dashboard` (the list read and the by-name read) is the separate enforcing change (#22639). + - **"Or" is a capability, not a new shape.** There is no any-of form: an object such as `{ anyOf: [...] }` is refused. To serve one view or dashboard to two audiences, declare one capability (for example `clm_legal_workbench.view`), grant it to both permission sets, and list that one name. `content/docs/permissions/capabilities.mdx` shows the pattern. + - **Nothing to migrate.** Every document that parsed before parses the same way; only a document that sets the new key, which was refused before, now parses. +- d973c34: A translated flow screen heading (`flows..screens..title`) is judged by the same text-slot rule as the body text and the refusal message beside it. A single-brace `{token}` in it is refused when the bundle is parsed, with the `{{ }}` spelling of the token. An empty string is still accepted. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + **Why.** The flow engine picks three translated templates in the run's locale and renders each through the one text-slot renderer, the `{{ }}` dialect: a screen's heading (`title`), its body text (`description`) and a refusing `end` node's `message`. The body text and the message refused a single-brace token. The heading accepted it and drew it as literal text, so `title: 'Welcome, {name}'` showed `Welcome, {name}`, and nothing was reported when the bundle was validated, saved or parsed. + + **What is refused.** A translated screen heading that the text-slot judge (`textSlotTemplateRefusal`) refuses: a single-brace `{token}`, and a `{{ }}` hole over a `$` name the flow engine does not bind. `TranslationDataSchema` and the `translation` metadata item refuse it at the key's path, with the words the source heading gets from `ScreenConfigSchema`. + + **Unchanged.** A heading that keeps the `{{ }}` holes of the text it translates (`'欢迎,{{ record.name }}'`), plain text, and the empty string `os i18n extract` writes into a skeleton, which the engine still reads as no translation. The heading's address does not change, and it still covers the node label a screen with no `config.title` shows. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `title: 'Welcome, {name}'` | `title: 'Welcome, {{ name }}'` where the heading should show the value; `title: 'Welcome'` where the braces were never meant as a hole | + + **The one-line fix: write each single-brace token in a translated screen heading as the `{{ }}` hole the refusal names.** + + **Who is affected, measured.** In this repository at `86da194919`, four bundles carry a `screens` group (`examples/app-crm`, `examples/app-showcase`, and `examples/app-todo`'s `ja-JP` and `zh-CN`). They hold 8 translated screen headings, and the judge refuses none of them. The same instrument finds the single-brace `{count}` in `examples/app-crm`'s `crm.activity.due_today` message, which is its control. Deployed bundles and other repositories were not measured. + + **The ledger.** The D3 semantic entry `translation-flow-screen-title-text-slot-refused` (protocol 18). There is no D2 conversion: a single-brace token in a translated heading has always drawn as literal text, so only the translator can say whether it was meant as a hole. +- e04759d: fix(spec): the error-code ledger records `@objectstack/mcp` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22664) + + Clause-②: yes + + - **`ERROR_CODE_LEDGER` gains the `@objectstack/mcp` owner key.** The MCP stdio data bridge stamps both exposure codes onto the error it throws when an object's `enable` block refuses a data verb: `OBJECT_API_DISABLED` (404) for `apiEnabled: false`, and `OBJECT_API_METHOD_NOT_ALLOWED` (405) for an `apiMethods` whitelist that does not grant the verb. The published type of `ERROR_CODE_LEDGER` gains the key. `ErrorCode` and `REGISTERED_ERROR_CODES` are unchanged, because both codes were already registered under `@objectstack/rest`. + - **`PROVENANCE_WAIVERS` gains one entry:** `@objectstack/types` → `UNIQUE_SCOPE_CONFIRMATION_REQUIRED`, registered under `@objectstack/cloud-connection`. `@objectstack/types` defines the shared constant `GLOBAL_UNIQUE_CONFIRMATION_REQUIRED`. The one stamp is the marketplace install seam's 409 in `@objectstack/cloud-connection`, whose owner key already lists the code. + + No code is added, renamed or removed, and no schema moves. +- b32ee3e: feat(spec)!: an approver `position` and a decision-output `position` are catalog names, listed from the metadata registry (ADR-0131 D4) + + Clause-②: yes + + + + **BREAKING** for a renderer that derives its position picker from `APPROVER_VALUE_SOURCES`, shipped as `minor` under the repo's launch-window convention for breaking changes. Authored and stored metadata is untouched. + + - **`APPROVER_VALUE_BINDINGS.position`** is a registry binding, `{ source: 'registry', type: 'position' }`. It was a record binding on `sys_position` rows that committed the row's `name`. `ApproverValueBinding` gains the `registry` member: the value is the machine name of an item of metadata `type`, and a catalog item has no other reference, so the member carries no `valueField`. + - **`APPROVER_VALUE_SOURCES.position`**, the same binding on the wire (the `xRef.sources` of the approval node schema's `approvers[].value`), reads `{ source: 'registry', type: 'position' }`. It read `{ source: 'data', object: 'sys_position', valueField: 'name' }`, and its type gains the `{ source: 'registry'; type: string }` member. A picker for a `registry` source lists `GET /api/v1/meta/position`, which serves the code-declared and the environment-authored positions alike, and commits the item's `name`. `user`, `team` and `department` still read `data`. + - **A `position` decision output collects position machine names**, the value the approver type of the same name takes. `DecisionOutputDef` said every picker collected record ids. The one reader that interprets a position output, an `expression` approver with `resolveAs: 'position'`, filters `sys_user_position.position` by name, so a `sys_position` id there routed the request to nobody. The `type` and `multiple` descriptions now say what each picker commits. + - **The runtime does not change.** The approvals engine routes a position approver, an escalation target and a `resolveAs: 'position'` value by name, as it did. + + Why: positions are declared metadata (ADR-0131 D2), and every reference to one names it and resolves through the registry (D4). The `sys_position` table is a mirror that the ADR-0131 cutover retires, so a picker that lists its rows lists the mirror, not the catalog. +- 490cb6d: A write answer now carries the advisory validation-rule hits of that write as `warnings` + + Clause-②: yes (widening) + + A `severity: 'warning'` or `'info'` validation rule never blocks a write. Until now its hit was only logged on the server, so no client could show the person who saved what the rule says. A create, an update or a clone now answers the hits of that one write, and the validate-only preview reports the same entries. + + - **Write answers.** `CreateDataResponseSchema`, `UpdateDataResponseSchema` and `CloneDataResponseSchema` declare an optional `warnings`: one entry per advisory rule hit, carrying the rule's `name` as `rule`, its `severity`, the `field` it is about, the finding `code` and the author-written `message`. The key is absent when there is no hit, so an existing client's reading of the answer does not move. REST relays it in the response body unchanged. The write's status and record are unchanged, and an `error` rule still refuses the write. + - **One element, shared with the preview.** `ValidateDataIssueSchema` gains optional `rule` and `severity`, set only on an advisory rule hit. It is the element of the new `warnings`. `validateData` appends the advisory hits of the same evaluation to each accepted row's existing `results[].warnings`, after the value-shape findings the deployment admits. An import's dry run copies that array to the row verbatim, as before. + - **Engine.** `WriteObservabilityOptions` gains `onValidationAdvisory`, an in-process listener beside `onFieldsDropped`, with its event schema `ValidationAdvisoryEventSchema`. The engine calls it once per advisory hit on the write it was passed to: per row on `insert`, and on `update` by id and per matched row of a `multi` update. A nested write a hook or a flow makes inside that write carries its own options, so its hits never reach the outer write's listener or answer. `evaluateValidationRules` returns its advisory hits (an empty array when there are none) instead of `void`. + - **Unchanged.** Rule semantics, the per-write `warn` log line and the seed and boot load's one summary line per rule are all as before. No authorable key is added: `ValidationRuleSchema` is untouched. A rule whose condition could not be evaluated is not a hit. Its fault text is for the operator, so it stays in the server log only. + - **Client.** `CreateDataResult`, `UpdateDataResult` and `CloneDataResult` declare `warnings?: ValidateDataIssue[]`. + + **For authors.** Nothing to change in metadata. Write each advisory rule's `message` for the person saving the record, because a client can now show it to them. +- e865300: A formula field can declare a currency result: `returnType: 'currency'`, with its currency in its own `currencyConfig` + + Clause-②: yes (widening) + + - **What is new in `@objectstack/spec`.** `FieldSchema.returnType` accepts `'currency'` beside `'number'`, `'text'`, `'boolean'` and `'date'`. A `currency` result is an amount of money. Its value is a bare number, as a currency field's is. Its currency is the formula field's own `currencyConfig`, the same `CurrencyConfigSchema` a currency field carries, with the same defaults and the same refusals: no `currencyConfig` means dynamic mode (the tenant default currency), and `{ currencyMode: 'fixed', defaultCurrency: 'USD' }` fixes it. Nothing is read at display time from the fields the expression references: the currency is a declaration on the formula. + - **Readers.** The filter doors judge a formula returning `currency` as the currency field it reads like: a text operator on it is refused (`INVALID_FILTER`), and the number door judges its comparands. A formula is still not title-eligible unless it returns `text`, and is still refused for `sum` / `avg` / `min` / `max` (it has no stored column). + - **Designer forms.** The field designer and the object designer's field grid both offer Currency as a return type. The field designer shows its Currency Config row for a formula whose return type is Currency, as it does for a currency field. + - **What is new in `@objectstack/formula`.** `inferFormulaReturn(expression, fields)` returns the declaration authoring stamps onto a formula field, judged over the host object's declared field map: `{ returnType: 'currency' }` when the expression is provably an amount of money, plus `currencyConfig: { currencyMode: 'fixed', defaultCurrency }` when its source amounts are in one fixed currency. "Provably money" is unit checking over the expression: amounts in one currency added, subtracted, scaled by a number or a percent, divided by a number, rounded, or picked by `min` / `max` / `coalesce` / a ternary. Two different currencies, an amount multiplied by an amount, or an amount plus a plain quantity are not money. For everything it cannot prove to be money it answers exactly what `inferExpressionType` answers, so a numeric formula stays `number`. Its types `FormulaReturnDeclaration` and `FormulaSourceField` are exported with it. `inferExpressionType` is unchanged. + - **`@objectstack/platform-objects`.** The designer help text for the return type and the currency config rows names the currency result, in all four locales. + - **Nothing to migrate.** Every value accepted before is accepted now. To declare a formula over money as money, declare `returnType: 'currency'` and copy the source currency field's `currencyConfig` onto the formula, or run `inferFormulaReturn` and write what it returns. +- 85edd61: feat(spec): the stack definition admits `appName`, the non-secret members of `email`, and `sms.retries`; credentials and the SMS provider are refused by name + + Clause-②: yes (widening: the stack definition admits `appName`, the non-secret members of `email`, and `sms` carrying `retries` alone, which it refused as unrecognized keys) + + - **What you can write now.** `defineStack()` accepts three top-level keys it used to refuse as unrecognized. The mail and SMS readers already read them, and `objectstack serve` and `bootStack` construct the email and SMS providers through those readers: + - `appName`: the deployment's product name. It is the last configured rung of the name chain (`OS_APP_NAME` > `email.appName` > `email.defaultTemplateContext.appName` > `appName` > `'ObjectStack'`), and it reaches the email templates and AuthPlugin. + - `email` (`StackEmailConfigSchema`): every member of `EmailServiceConfigSchema` except `apiKey`. That is `provider`, `defaultFrom`, `retries`, `persist`, `queueDelivery`, `appName` and `defaultTemplateContext`, plus `options` narrowed to `host` / `port` / `secure` / `user` / `messageStream`. `OS_EMAIL_*` still overrides it per key. + - `sms` (`StackSmsConfigSchema`): `retries`, the SMS service's attempt budget. It applies to whichever transport the `sms` settings namespace selects. + - **What stays elsewhere, and is refused by name.** Each refusal names the key and the setting that replaces it: + - `email.apiKey` and `email.options.apiKey`: a credential, and a stack definition is compiled into the published artifact. Set `OS_EMAIL_API_KEY`. + - `email.options.password`: a credential. Set `OS_EMAIL_SMTP_PASSWORD`; the reader layers it over the host / port / secure / user the file carries. + - `sms.provider`, `sms.providerOptions` (and a flat `authToken` / `accessKeySecret`): the delivering SMS transport is built by the `sms` settings namespace from its own provider and credentials, never from a stack definition. Configure them in Setup → Settings → SMS Delivery, or through that namespace's overrides (`OS_SMS_PROVIDER`, `OS_SMS_TWILIO_*`, `OS_SMS_ALIYUN_*`). + - **Composition.** All three keys compose as single values. `composeStacks` passes an identical value through and refuses two different ones by key. + - **Nothing to migrate.** No stack that was accepted before is refused now. A configuration that set these keys only in an object `defineStack` did not build (the one `objectstack serve` boots) reads the same as before. To build it, move any credential, and the SMS provider, into the environment or Settings. + - New exports from `@objectstack/spec/system`: `StackEmailConfigSchema`, `StackEmailProviderOptionsSchema`, `StackSmsConfigSchema`, and their `z.input` / `z.infer` types. +- e84aeb3: feat(spec): a new webhooks service contract declares an optional, transport-neutral redeliver member (`IWebhookService.handleRedeliver`) + + Clause-②: yes (widening) + + - **What is new.** `@objectstack/spec/contracts` exports a new contract, `IWebhookService`, for the `webhooks` service slot that `@objectstack/plugin-webhooks` fills. It has one optional member, `handleRedeliver?(request: Request): Promise`. It serves the webhook redeliver door, `POST /api/v1/webhooks/redeliver`, the operator button that sends a finished delivery again. + - **What it serves.** The same answers as the plugin's self-hosted route. It authenticates the caller from the request's own credentials (`401 UNAUTHENTICATED` when nobody is signed in). It reads `{ "deliveryId": string }` from a JSON body (`400` otherwise). It replays the row through the messaging service, scoped to the caller's active organization (`404 RESOURCE_NOT_FOUND` for a row outside it). It answers `409` with `DELIVERY_NOT_ELIGIBLE` or `DELIVERY_NEVER_SENT` when the outbox refuses the replay, and `200` with the row's id and new status when it succeeds. + - **Transport-neutral.** A web-standard `Request` goes in and a `Response` comes out: there is no Hono context, no raw app and no `http.server` type. A hosted tenant kernel has no raw app to mount the route on, and the runtime dispatcher's `POST /webhooks/redeliver` domain is the member's one caller. + - **Why a webhooks slot, not the messaging service's.** The messaging service owns the replay itself. The door (who may press it, whose rows it reaches, what each refusal answers, and the veto over replaying a webhook whose subscription or secret is gone) belongs to the webhook plugin, so the door exists exactly where that plugin is composed. + - **Absence.** The member is optional, so a webhook service without the door still satisfies the contract. Its caller answers a typed 404 or 501 when the `webhooks` service or this member is absent, never `ROUTE_NOT_FOUND`. + - **Nothing changes for existing code.** No existing contract or member moved. Nothing registers the `webhooks` slot or calls the member yet: the plugin implementation and the dispatcher domain land separately, and the plugin's self-hosted route stays as it is. +- 5fc57b3: feat(spec): a new trigger-api service contract declares an optional, transport-neutral inbound-hook member (`ITriggerApiService.handleInboundHook`) + + Clause-②: yes (widening) + + - **What is new.** `@objectstack/spec/contracts` exports a new contract, `ITriggerApiService`, for the `trigger-api` service slot that `@objectstack/trigger-api` fills. It has one optional member, `handleInboundHook?(request: Request, hook: { flowName, hookId }): Promise`. It serves the inbound hook, `POST /api/v1/automation/hooks/:flowName/:hookId`, the door a third-party sender posts a signed event through to start an `api` flow. + - **What it serves.** The same answers as the trigger's self-hosted route, in the same order. An unknown flow or a wrong hook id answers `404 RESOURCE_NOT_FOUND`, identically. A hook secret that cannot be read answers `503 SERVICE_UNAVAILABLE`. A missing or wrong `x-objectstack-signature` answers `401 INVALID_SIGNATURE`. Only a verified body is read as JSON: one that is not valid JSON, or not a JSON object, answers `400 INVALID_REQUEST`. An enqueued post answers `202` with `{ accepted: true, messageId }`, and the flow runs on the queue consumer, never in-band (ADR-0041 §5). No queue answers `503 SERVICE_UNAVAILABLE`, and a failed publish answers `503 ENQUEUE_FAILED`. + - **The member is the signature boundary.** The implementer verifies inside the member, with the hook's own secret, in constant time, and before anything is enqueued. It reads the raw body from the request itself. The caller forwards the request with its body unread, and never parses, re-serialises or pre-checks it. + - **Transport-neutral.** A web-standard `Request` goes in and a `Response` comes out: there is no Hono context, no raw app and no `http.server` type, and no execution context, because the signature is the only credential. The two path parameters arrive as the second argument, as the caller's route matched them, so the member never re-parses the URL. A hosted tenant kernel has no raw app to mount the route on, and the runtime dispatcher's exact `POST /automation/hooks/:flowName/:hookId` domain is the member's one caller. + - **Why a trigger-api slot, not the automation service's.** The automation service holds the trigger registry that the `api` trigger registers into. The door (the hook lookup, the secret, the signature check, the body parse, the enqueue and every answer) belongs to the trigger, so the door exists exactly where `@objectstack/trigger-api` is composed. + - **Absence.** The member is optional, so a trigger-api service without the door still satisfies the contract. Its caller answers a typed 404 or 501 when the `trigger-api` service or this member is absent, never `ROUTE_NOT_FOUND`. + - **Nothing changes for existing code.** No existing contract or member moved. Nothing registers the `trigger-api` slot or calls the member yet: the trigger's implementation and the dispatcher domain land separately, and the trigger's self-hosted route stays as it is. +- 9814035: feat(spec): `ReadableIds`, the branded `$in` comparand leaf meaning "the ids of object X this caller may read" (ADR-0139 D1) + + Clause-②: yes (widening) + + - **What is new in `@objectstack/spec/data`.** `ReadableIds.of(object)` builds a frozen leaf, `{ object }`, placed as the comparand of `$in` on a pointer field: `{ record_id: { $in: ReadableIds.of('cpz_doc') } }`. `isReadableIds(value)` answers whether a value is one. `READABLE_IDS_BRAND` is the `Symbol.for('objectstack.filter.readableIds')` key the brand lives under. The `ReadableIds` type carries the brand, so an object literal does not type-check as one. `ReadableIds.of()` throws a `TypeError` for an object name that is not a non-empty string. + - **No request can spell it.** No filter operator is added. The brand is a non-enumerable symbol-keyed property, so JSON, a structured clone or a spread produces an unbranded `{ object }`, and a plain object as the comparand of `$in` is refused `INVALID_FILTER` / 400, as it was before. + - **The comparand-shape door admits it only for a caller that composes it.** `assertListComparandShapes` takes an optional fourth parameter, `ListComparandShapeOptions`. With `{ admitReadableIds: true }` a branded leaf is admitted as the comparand of `$in`, and nowhere else. Without it, which is every existing caller (`parseFilterAST`, the save door, the analytics doors, the RLS compiler), the leaf is refused as a non-list comparand, unchanged. + - **`@objectstack/objectql` does not compose the leaf yet.** The engine's comparand door passes the option, then refuses a branded leaf found anywhere in a `where`, an aggregation's `filter` or a `having` with `INTERNAL_ERROR` / 500, before any driver is asked. Do not emit the leaf until the engine composes it. Until then, read the parent object as the caller and match the ids that read returns with a list. + - Nothing emits the leaf yet, and no read that ran before this change answers differently. +- 55382dc: A paged list under a search now reports the real number of matching records as `total`, and `hasMore` agrees with it on every page. + + Clause-②: yes (widening) — `EngineCountOptionsSchema` gains two OPTIONAL keys, `search` and `searchFields`, so the accept set of the count options grows. Nothing previously admitted is refused, no key is renamed or retired, and no producer is required to write them. + + Until now `GET /api/v1/data/:object?$top=25&$search=Line` over 75 matching records answered `total: 26` (offset + page + 1, a page-local estimate), and the last page claimed `hasMore: true`; `POST /api/v1/data/:object/query` with a `search` did the same. Nothing on the response marked the number as an estimate, so the Console rendered it as the record count and offered the wrong number of pages. + + - **`@objectstack/spec`** — `EngineCountOptionsSchema` declares `search` (the bare string, or the structured `FullTextSearchSchema` form) and `searchFields`, identically to `EngineQueryOptionsSchema`. FROM `count(object, { where })` only → TO `count(object, { where, search, searchFields })`. The structured `search` form carries flag defaults, so the parsed state is named `EngineCountOptionsParsed` (ADR-0122). `HookCountQuery` (`ctx.api.object(name).count()`) derives from it and gains the same two keys. + - **`@objectstack/objectql`** — `engine.count()` (and `ctx.api.object(name).count()`) accepts the two keys it used to refuse as unknown options, and expands them through the same ADR-0061 expansion `find()` and `aggregate()` use: the same server-resolved searchable fields, the same `internal: true` exclusion, the same `searchFields` narrowing, AND-ed with `where` before the security middlewares run. There is one expander, not two, so a count under a search is the number of rows `find()` returns for the same bag. + - **`@objectstack/metadata-protocol`** — `findData`'s paged branch passes `search` / `searchFields` to `engine.count()`, and the page-local estimate branch is gone. `$count=false` still skips the count and omits `total`. + + Nothing to migrate. A client that worked around the estimate, for example by labelling the count as page-local under a search, can read `total` as the match count again. +- efcbac7: fix(trigger-api,spec): every inbound hook post that does not verify gets one answer, `401 INVALID_SIGNATURE`, whatever its flow and hook id + + Clause-②: yes + + - **FROM → TO.** On `POST /api/v1/automation/hooks/:flowName/:hookId`, a post that named no armed flow, or the wrong hook id, answered `404 RESOURCE_NOT_FOUND`, while a post naming an armed flow and its hook id with an absent or bad `x-objectstack-signature` answered `401 INVALID_SIGNATURE`. Now all four answer `401 INVALID_SIGNATURE` with the same body, so the answer no longer tells an armed flow name from an unarmed one, whichever hook id a flow is armed with. + - **Who sees a difference.** A correctly configured sender (the flow's URL, its hook id, its secret) sees none: it still gets `202`. A sender posting to a flow that is not armed, or with a hook id that was rotated away, now gets `401` where it got `404`. A sender with a wrong secret or signing code still gets `401`, as before. + - **The cause is in the server log.** The trigger logs one `warn` line per refused post, with the cause and the flow name the post carried as structured fields: `cause` is one of `unknown-flow`, `wrong-hook-id`, `signature-absent`, `signature-malformed`, `signature-mismatch` or `signature-outside-window`. The line never carries the secret, the signature value or the body. + - **Unchanged: the `503` residual.** A post that names an armed flow and its hook id while that hook's secret cannot be read still answers `503 SERVICE_UNAVAILABLE`, signed or not, without verifying. It stays a `503` on purpose: it is the deployment's fault, a sender retries it, and it lasts only while the secret cannot be read. A wrong hook id never reaches the secret read, so it gets the uniform `401` even then. + - **`@objectstack/spec`.** The `ITriggerApiService.handleInboundHook` docblock states the one refusal and names the `503` as its residual. The type is unchanged. + + **To adopt it.** A sender or monitor that read `404` from this route as "wrong URL" can no longer tell that from a bad signature by the answer: read the trigger's `warn` line for the post instead, whose `cause` names which one it was. +- 9432bac: feat(spec): `ServiceSlotContracts` types the `webhooks` and `trigger-api` slots + + Clause-②: yes (widening) + + `ServiceSlotContracts` (from `@objectstack/spec/contracts`) gains two entries: + + - `webhooks: IWebhookService`. `@objectstack/plugin-webhooks` registers this slot with a service typed as `IWebhookService`. + - `'trigger-api': ITriggerApiService`. `@objectstack/trigger-api` registers this slot with a service typed as `ITriggerApiService`. + + FROM → TO: + + - `ServiceSlotContract<'webhooks'>` goes from `unknown` to `IWebhookService`, and `ServiceSlotContract<'trigger-api'>` goes from `unknown` to `ITriggerApiService`. + - A lookup keyed by `keyof ServiceSlotContracts` now accepts both names. One example is the runtime dispatcher's `resolveService` / `getRequestKernelService`, which used to answer `any` for them. It now answers the contract or `undefined`. So `resolveService(ctx, 'webhooks')` resolves to `IWebhookService | undefined`, and `?.handleRedeliver` has the declared member's type. The same holds for `'trigger-api'` and `?.handleInboundHook`. + + Every member of both contracts is optional, so a reader still checks that the member it calls is present. No other slot's entry changes, and nothing changes at runtime: this is type information only. + + Nobody needs to migrate. Existing casts at those lookups still compile. If you build an object typed as the whole `ServiceSlotContracts` map, it must now carry the two new keys. This repository and the pinned objectui checkout build no such object. +- e5687cb: An `email` field can declare that it identifies a correspondent for inbound mail: `mailAssociation` + + Clause-②: yes (widening) + + - **`mailAssociation: boolean`** (optional, no default) on a field definition. TRUE marks this email field as identifying a message correspondent: when a mailbox service ingests a message, it associates the message with every record of the object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Absent or FALSE: the field takes no part in matching. + - **The platform learns which fields carry a correspondent's address, never what a contact is.** Every object that flags a field is treated alike; no app code and no resolver service is involved. + - **The door that judges it.** `FieldSchema` refuses the key on any type other than `email`, naming the field's type and the fix (change the type to `email`, or drop the key). Every metadata that parsed before parses unchanged. + - **Who reads it.** Nothing yet: the mailbox service's ingest is the reader, and it ships separately. Until then `objectstack validate` and `objectstack lint` warn an author who sets it (`liveness-planned-property`) that no mailbox service reads it yet. + - **Where to author it.** The field designer offers it on email fields as "Match Inbound Mail". + - **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies `mailAssociation` as `presentation`: the mailbox service reads it, never `createColumn`, so a declaration on a builtin column name is not reported as discarded storage. No behaviour changes. + - **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` classifies `mailAssociation` as `keep`: the FLS scrub of a served object definition passes the flag through unchanged, because it names no field. + - **`@objectstack/platform-objects`.** The field designer's new row carries its label and help text in the metadata-form translation bundles for en, zh-CN, ja-JP and es-ES. +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. +- ec7f7f8: feat(spec)!: retire an email template's `category` and `isSystem` — the grouping and the built-in flag nothing read (#22876) + + **BREAKING** — `category` and `isSystem` on an email template (`EmailTemplateDefinitionSchema`, the `email_template` metadata type) are now refused at parse. Nothing read either key once templates stopped being copied into `sys_email_template` rows: the Studio listing never offered the filter facet `category` promised, and no door refused deleting a template that set `isSystem`. Delete both keys. A template is found by its `name`, so group templates with a dotted namespace prefix (`auth.password_reset`, `crm.welcome`). A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. + + Clause-②: no (narrowing) + + Measured before removal: no reader of either key in `@objectstack/plugin-email`, `@objectstack/service-messaging`, `@objectstack/plugin-auth`, the metadata protocol, ObjectQL, the runtime or the REST layer, and none in the Studio console at its pinned commit beyond a fallback form input, which the console's own companion change removes. Both keys carried a schema default (`category: 'custom'`, `isSystem: false`), so every parsed template carried them and the liveness advisory fired on every template, with no way for an author to clear it. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `category: 'auth'` (or `'notification'`, `'workflow'`, `'marketing'`, `'custom'`) | nothing — delete the key; group by the `name` prefix | + | `isSystem: true` | nothing — delete the key | + | `isSystem: false` | nothing — delete the key | + | `import { EmailTemplateDefinitionCategorySchema } from '@objectstack/spec/system'` | nothing — the enum is removed with its key | + | `type EmailTemplateDefinitionCategory` | nothing — the type is removed with its key | + + **The one-line fix:** delete `category` and `isSystem` from every email template. + + **What an author who still writes either sees.** `tsc` fails at the authoring site (`EmailTemplateDefinition` types both keys as retired), and the parse — `defineEmailTemplateDefinition()`, `defineStack({ emailTemplates })`, `PUT /api/v1/meta/email_template/:name` — refuses each at its own path with the prescription: + + > `email_template.category` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). 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. + + > `email_template.isSystem` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. 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 + + - **Two `retiredKey()` tombstones** on `EmailTemplateDefinitionSchema`, a `strictObject` — so the refusal carries the prescription rather than a bare unknown-key report, and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `system/EmailTemplateDefinition:category`, `system/EmailTemplateDefinition:isSystem`. The `category` enum, `EmailTemplateDefinitionCategorySchema`, had no other consumer and leaves with it: `RETIRED_DEFS_BY_MAJOR[18]`: `system/EmailTemplateDefinitionCategory`. + - **The D2 conversion `email-template-category-is-system-removed`** (protocol 18, retired from the load path) deletes both keys from every `emailTemplates[]` entry, one notice per key, as a lossless delete. Because both keys carried a default, every built artifact and every stored `email_template` row written under 17.x carries them; both load through the rehydration seams, which replay this conversion. The retired-default residue stage is not adopted: the replayed conversion heals those populations, and an authored source that still writes either key meets the prescription. + - **The D3 entry `email-template-category-is-system-retired`** asks the author whether anything they built read either key — a report or integration that filtered templates by `category`, or one that treated `isSystem: true` as protection against deletion. Nothing ever enforced that. + - **Studio:** the email-template form drops the `category` select and the `isSystem` checkbox. + - **Ledger:** both rows stay `dead`, re-noted as tombstones (a tombstone keeps the key in the walked shape). + - **No deprecation window**, per the project's startup-stage posture. + + **`@objectstack/plugin-email`:** the six built-in auth templates (in every locale) no longer write `category: 'auth'` or `isSystem: true`. They render and send exactly as before. + + **`@objectstack/platform-objects`:** the metadata-form translation bundles drop the two retired form inputs' labels (`email_template.fields.category`, `email_template.fields.isSystem`). The `sys_email_template` object and its `category` / `is_system` columns are unchanged. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + +- 038d4c9: feat(metadata-protocol): one language of an email template is read, reset, published, viewed in history and reverted by name — the by-name `/meta` doors accept a bundle member selector (#22877) + + Clause-②: yes + + **What an administrator can now do.** An email template kept in several languages is one item per language in the store. Each language can now be addressed by name on every by-name door, with `?member=` naming the language: + + - `GET /api/v1/meta/email_template/:name?member=zh-CN` reads the `zh-CN` wording: the stored customization when there is one, else the package's `zh-CN` template. A language that nothing stores or ships answers as an absent item (404). + - `DELETE /api/v1/meta/email_template/:name?member=zh-CN` resets the `zh-CN` customization alone; the other languages keep theirs. + - `POST /api/v1/meta/email_template/:name/publish?member=zh-CN` publishes the `zh-CN` draft. + - `GET /api/v1/meta/email_template/:name/history?member=zh-CN` lists the `zh-CN` saves. All languages of one template share one version counter, so the version numbers of one language are not consecutive. + - `POST /api/v1/meta/email_template/:name/rollback?member=zh-CN` reverts the `zh-CN` customization to one of its own versions. A version saved for another language answers `404 VERSION_NOT_FOUND`. + + With no `?member=`, every door addresses the canonical member (`en-US`), exactly as before; naming `en-US` is the same request. This closes the window the previous release's changeset stated: a non-`en-US` language could not yet be reset, published, listed or reverted by name. + + **The selector.** It is named `member`, after what the store keys a row by, and not `locale`: on `/meta`, `?locale=` already chooses the language the response is translated into. The protocol requests gain one optional field, `member`, on `GetMetaItemRequest`, `DeleteMetaItemRequest`, `PublishMetaItemRequest` and `HistoryMetaItemRequest`. A wrong value is refused with `400 VALIDATION_ERROR`, naming the parameter: the selector on a type whose items have no languages (every type except `email_template` today), a blank value, or a value longer than a stored member can hold (100 characters). + + **Each of the five routes now declares its query parameters.** `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` refuse a query parameter they do not read with `400 VALIDATION_ERROR`, naming the parameter and listing the supported ones, instead of ignoring it. A misspelled `?memeber=zh-CN` is therefore refused, not answered with the `en-US` wording. The supported names are the ones each route has always read, plus `member`: + + - `GET /meta/:type/:name`: `layers`, `state`, `preview`, `package`, `locale`, `member`. + - `DELETE /meta/:type/:name`: `state`, `dropStorage`, `member`. + - `GET /meta/:type/:name/history`: `sinceSeq`, `limit`, `member`. + - `POST /meta/:type/:name/publish`: `package`, `member`. + - `POST /meta/:type/:name/rollback`: `toVersion`, `member`. + + A client that sent any other parameter to these routes was having it ignored; it now gets a 400 and can drop it. The deprecated `?layers=` view reads no language, so `?layers=` together with `?member=` is refused rather than answered with the `en-US` layers. A read naming a member skips the shared read cache. + + **What else now follows the language** (`@objectstack/objectql`, `@objectstack/metadata-protocol`): + + - A by-name read that names no language answers the `en-US` template even when only another language is customized. Before, the registry's read of the name could answer the other language's customization. + - Resetting one language keeps the other languages' customizations served at once, not only after the next restart. + - Publishing, discarding and reverting a package's changes (`POST /packages/:id/publish-drafts`, discard, and commit revert) address each language's draft or change as its own item. Before, a package whose only pending draft was a second language failed to publish (`NO_DRAFT`), and a discard reported that draft discarded while it stayed. The draft list and the commit timeline keep the fields they declare. + - In a cluster, a save, reset or revert of one language converges the other servers on that language. + + **For code that uses these packages directly.** Every addition below is optional; code that does not pass it behaves as before, except the bare-name read noted in the first item. + + - `SchemaRegistry` (`@objectstack/objectql`) takes an optional last argument, `member`, on `getItem`, `getArtifactItem`, `removeRuntimeShadow` and `removeOverlayEntry`. It is the discriminator value of one bundle member, such as `'zh-CN'`. + - `getItem(type, name, packageId, member)` answers that member, in the usual order (customization, then the requested package, then any package), or `undefined`. With no `member`, `getItem` now answers the canonical (`en-US`) member of a bundle. A bundle with no `en-US` member still answers its first member. + - `getArtifactItem(type, name, packageId, member)` answers that member's packaged definition. + - `removeRuntimeShadow(type, name, member)` and `removeOverlayEntry(type, name, member)` remove that member's customization entry only. With no `member`, they remove the whole bundle's customization entries, as before. + - A type whose items have no languages ignores the argument. + - `MetadataMutationEvent` (`@objectstack/metadata-protocol`) has an optional `member`: the language the mutated row belongs to. It is present only for a non-`en-US` language. Listeners registered with `onMetadataMutation` receive it. It also rides `ClusterMetadataMutationPayload`, and a server that receives it re-reads that language's row. + - The entries that `SysMetadataRepository.listDrafts` returns have an optional `member`, present for a draft of a non-`en-US` language. The protocol's draft list (`GET /meta/_drafts`) and a package's commit timeline serve it too, on every entry of a template, with the canonical `en-US` spelled out. +- 147a0d1: feat(spec)!: `defineStack` refuses two entries of one stack's `functions` array that author the same `name`, naming the name and every index (#22902) + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + + Functions are one flat namespace resolved by name: a hook's `handler`, an action's `target` and a flow `script` node's `config.function` all look a function up by its name. The `functions` **array** form could author one name twice, and nothing refused it. Measured on `main` at this change's base with `functions: [{ name: 'kc_a', handler: first }, { name: 'kc_a', handler: second }]`: + + - `objectstack validate` exited 0 (`valid: true`); + - `objectstack build` exited 0 and shipped `[{ name: 'kc_a' }, { name: 'kc_a__2' }]`: the second entry went out under a suffixed internal ref the author never wrote and nothing references, and `kc_a` ran `first`; + - a source boot (`objectstack serve`, no build) registered `kc_a` as `second` and dropped `first`, with no notice. + + So the same stack ran different code under `kc_a` depending on whether it was built. `composeStacks` already refused one name defined by two stacks (`STACK_COMPOSE_FUNCTION_CONFLICT`); this is the one-stack case. + + **What is refused.** `defineStack`'s cross-reference pass refuses the stack with its existing envelope (`STACK_CROSS_REFERENCE_INVALID`, status 422, `defineStack cross-reference validation failed (N issues):`), one finding per colliding name, naming the name and every index: + + ```text + ✗ Function 'kc_a' is declared twice in the `functions` array: stack.functions[0] and stack.functions[1]. … + ``` + + It runs in an object-less stack too, and every entry counts, byte-identical twins included. `objectstack validate`, `objectstack build` and a source boot all load the config through `defineStack`, so all three refuse it at load, printing that line: `objectstack validate --json` answers `valid: false` with `code: 'STACK_CROSS_REFERENCE_INVALID'` (exit 1), `objectstack build` exits 2, and `objectstack serve` exits 1 before the kernel boots. Like every cross-reference check it is skipped under `defineStack(…, { strict: false })`. + + **Unchanged.** The map form, which cannot author one name twice; an array whose names are distinct; the suffixed refs a build emits for an internal slot (`kc_a__2` beside `kc_a` is two names); and the cross-stack refusal in `composeStacks`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `functions: [{ name: 'sync', handler: a }, { name: 'sync', handler: b }]` | `functions: [{ name: 'sync', handler: a }, { name: 'sync_backup', handler: b }]`, and point each hook, action or script node that meant `b` at `sync_backup` | + | two entries that are the same function under one name | one entry: delete the other | + + **The one-line fix: give each `functions` array entry its own `name`, or merge the duplicates into one entry.** + + No D2 conversion rewrites it: which entry keeps the name, and what every caller of the other one should name instead, is the author's call. Its D3 record is the semantic entry `stack-functions-array-duplicate-name-refused` (protocol 18). + + **Who is affected, measured.** On `main` at this change's base, no example app, fixture or test authors one name twice in a `functions` array: the four example apps author the map form or none, and every array-form fixture under `packages/` names distinct entries. `objectstack validate --json` on `examples/app-crm`, `app-todo`, `app-showcase` and `app-multi-package` answers byte-for-byte as it did without the check, apart from the `duration` value. Deployed metadata and other repositories were not measured. +- 1959aac: feat(spec)!: `defineStack` refuses two entries of one named collection under one identity, naming the collection, the identity and every index — for 27 collections (#22934) + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + + The runtime registers the entries of these collections under one identity each, and a stack could author two entries under one identity with nothing refusing it: `defineStack` and `objectstack validate` accepted the pair (exit 0, `valid: true`, no finding naming it). Measured on a source boot (`objectstack serve`, no build) and on `objectstack build` followed by an artifact-backed boot: + + - `objects`, `pages`, `dashboards`, `reports`, `datasets`, `flows`, `jobs`, `emailTemplates`, `books`, `positions`, `permissions`, `capabilities`, `sharingRules`, `webhooks`, `mappings`, `analyticsCubes`, `connectors`, `picklists`, `tools`, `skills`, `views`, `apis`, `apps` and `objectExtensions` kept only the LAST entry on both paths, with no notice at the default log level (for `apis`, the first entry's path answered 404); + - `datasources`: the metadata described the last entry while the driver connected with the first one's configuration; + - `hooks`: the metadata listed one hook while both handlers were bound and ran; + - `docs`: `objectstack validate` and `objectstack build` refused the pair (`docs/duplicate-name`), while a source boot kept the last. + + **What is refused.** `defineStack`'s cross-reference pass refuses the stack with its existing envelope (`STACK_CROSS_REFERENCE_INVALID`, status 422, `defineStack cross-reference validation failed (N issues):`), one finding per collision, naming the collection, the identity and every index: + + ```text + ✗ Datasource 'crm_external' is declared twice in `datasources`: stack.datasources[0] and stack.datasources[1]. … + ``` + + The identity is what the runtime keys an entry by: its `name`; the `(name, locale)` pair for `emailTemplates` (one name across several locales is an i18n bundle, and an entry with no `locale` counts as `en-US`); the extended object (`extend`) for `objectExtensions`; and for `views` the object a container binds to (`object`, else `list.data.object`, else `form.data.object`, else its `name`). The check runs in an object-less stack too, and every entry counts, byte-identical twins included. `objectstack validate`, `objectstack build` and a source boot all load the config through `defineStack`, so all three refuse it at load. Like every cross-reference check it is skipped under `defineStack(…, { strict: false })`. On an `app` package, two apps under one name now get this refusal, which runs before the single-app count. + + **Unchanged.** Distinct identities; an email-template i18n bundle (one name, one entry per locale); the map form under distinct keys (two keys whose entries spell one explicit `name` are one identity, and are refused); the same name reused by another package of a multi-package project (`composeStacks(…, { manifest: 'preserve' })` keeps each package's collections in its own `packages[i]` body, which this check does not read); `plugins` (last-one-wins with a warning is the kernel's declared contract), `agents` (withdrawn for app packages) and `picklistExtensions` (merged by option value); and the existing `actions` and `functions` refusals. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `pages: [{ name: 'home', … }, { name: 'home', … }]` (any of the 24 name-keyed collections) | one entry per name: rename one (`home_admin`) and every reference that meant it, or merge the two into one entry | + | `emailTemplates: [{ name: 'acme.welcome', … }, { name: 'acme.welcome', locale: 'en-US', … }]` | one entry per name and locale: give the second its own `locale` (`zh-CN`), or merge the two | + | `datasources: [{ name: 'ext', config: a }, { name: 'ext', config: b }]` | one datasource per name — the driver used to connect `a` while the metadata described `b` | + | `hooks: [{ name: 'audit', object: 'a', … }, { name: 'audit', object: 'b', … }]` | one hook with `object: ['a', 'b']`, or two hooks with two names | + | `apis: [{ name: 'feed', path: '/api/v1/apps/x/a' }, { name: 'feed', path: '/api/v1/apps/x/b' }]` | give each endpoint its own name | + | `views: [defineView({ object: 'task', list }), defineView({ object: 'task', form })]` | one container per object: `defineView({ object: 'task', list, form })` (named views go in `listViews` / `formViews`) | + | `objectExtensions: [{ extend: 'task', fields: { a } }, { extend: 'task', fields: { b } }]` | one extension per target: `{ extend: 'task', fields: { a, b } }` | + + **The one-line fix: give each entry of a collection its own identity, or merge the duplicates into one entry.** + + No D2 conversion rewrites it: which entry keeps the identity, and what the other one becomes, is the author's call. Its D3 record is the semantic entry `stack-collection-duplicate-entry-refused` (protocol 18). + + **`@objectstack/metadata`.** `deriveViewContainerObject` — the "which object does a `views` container bind to" derivation the registrars file containers by — moved into `@objectstack/spec` (exported from its root and `./ui`), because the stack contract now judges the same slot. `@objectstack/metadata` re-exports that one declaration under the same name, from its root and from `@objectstack/metadata/view-container`; behaviour is unchanged. That subpath used to import nothing and now loads the `@objectstack/spec` root entry, which every in-repo importer of it already loads. + + **Who is affected, measured.** The four example apps (`examples/app-crm`, `app-todo`, `app-showcase`, `app-multi-package`) carry no duplicate under these identities. Deployed metadata and other repositories were not measured. +- 55828c5: feat(spec): an empty state names its one next step — `emptyState.link: { label, url }` — and a list page carries its own `interfaceConfig.emptyState` + + Clause-②: yes (widening: two new accepted keys) + + - **`EmptyStateSchema.link`.** An empty state takes one optional link beside `title`, `message` and `icon`: `{ label, url }`, both required and nothing else, for example `link: { label: 'How to publish a package', url: '/docs/publish-a-package' }`. It opens a page or an address and runs no action, flow or script. The shape is closed: a missing `label` or `url`, any other key and a non-object are refused at parse. + - **The targets a `type: 'url'` action opens, and no others.** `link.url` admits an in-app path that starts with `/` or `./`, an address that opens with `http://`, `https://` or `mailto:`, and a template that opens with `${…}` (such as `${ctx.origin}/docs`), which is judged when it is clicked. Every other scheme (`javascript:`, `data:`, `tel:`, …), a protocol-relative `//host`, `/` followed by a backslash, a tab, line feed or carriage return, and a bare relative target such as `docs/x` are refused at `link.url` with the reason and the fix, in the same sentences a url action's `target` is refused with. An empty `url` is refused as empty. + - **Every door that carries the empty state takes the link:** a list view's `emptyState` (and so an object's `listViews` and a view document's), the `object-grid` page block's `emptyState`, and a list page's new `interfaceConfig.emptyState`. + - **`interfaceConfig.emptyState`.** A `type: 'list'` page takes its own empty state, the list view's shape itself: `{ title, message, icon, link }`, refused at parse exactly as the list view's is. + - **Guidance.** `action` or `button` written on an empty state is now answered with the link, and still with the list view's `addRecord` block for an add-record entry point; a `url` or `href` written flat on the empty state is pointed into `link`. + - **Not rendered yet.** No console renderer draws the link or a list page's own empty state today. Both parse and are not drawn, and their schema descriptions say so (`[PLANNED — not yet rendered]`). Until the console reads it, a list page still draws its source view's empty state, if any. + - **Published JSON Schema.** `link.url` publishes as a string with `minLength: 1`. The target rule is a parse-time refinement the published file does not express; the file names its position under `x-dropped-refinements`. +- 7856110: The RLS deny sentinel and the composed `ReadableIds` leaf are declared once in `@objectstack/spec/data`, and every place in `@objectstack/plugin-security` that handed out a copy of the sentinel now returns the object itself (ADR-0139 D2) + + Clause-②: yes (widening: new exported members of the published spec `data` entry) + + - **`RLS_DENY_FILTER` moves into the spec.** FROM `@objectstack/plugin-security`'s own frozen literal TO `RLS_DENY_FILTER` exported by `@objectstack/spec/data`. The value is unchanged: `{ id: '__rls_deny__:00000000-0000-0000-0000-000000000000' }`, an `id` equality no record satisfies, so a consumer that never heard of it still reads zero rows. `@objectstack/plugin-security` keeps exporting `RLS_DENY_FILTER` under the same name and type; it is now the spec's object, not a second copy. Nothing to change in code that imports it from either package. + - **One object, compared by identity.** `getReadFilter` (`ISecurityService`) now returns the sentinel itself, not a copy, for a principal-less caller, a sharing scope that cannot be resolved, an on-behalf-of caller, a caller with no permission set and a resolution failure. It also returns the sentinel itself when the only layer that constrains is the tenant wall's deny, the RLS compiler's deny (every applicable policy dropped), or the deny of a `controlled_by_parent` object whose master cannot be resolved. Before this release most of those returned a spread copy, which matched the same zero rows but was a different object. Not every zero-row answer is the sentinel: a deny ANDed with another layer is returned as the composed `$and`, with the sentinel inside it, and the record-sharing layer's own deny, `{ id: '__deny_all__' }`, is returned unchanged when sharing is the only layer that constrains. ADR-0139 D2 step 2 has the engine's `ReadableIds` composition recognise the deny by `scope === RLS_DENY_FILTER` and answer it `$in: []`; any other zero-row answer composes like any scope and still matches no row. The object is frozen: code that edited the filter `getReadFilter` returned now gets a `TypeError` instead of silently changing a deny. + - **The composed leaf.** `ReadableIds.compose(leaf, scope)` builds the frozen value `{ object, scope }` the engine hands a driver in place of a `ReadableIds` leaf (D2 step 3). It is branded under `COMPOSED_READABLE_IDS_BRAND` (`Symbol.for('objectstack.filter.readableIds.composed')`), so a JSON round-trip, a structured clone or a spread drops the brand. `isComposedReadableIds(value)` recognises it. The type `ComposedReadableIds` is exported. `scope` is `undefined` for "no row restriction"; `compose` throws a `TypeError` for anything but a `ReadableIds.of` leaf, for a scope that is neither `undefined` nor a non-null, non-array object, and for `RLS_DENY_FILTER` itself, which D2 step 2 answers `$in: []`. + - **`isReadableIds` answers as before.** It answers `false` for a composed leaf, so it still means "an uncomposed leaf", and a driver refuses on it. The comparand-shape door's `admitReadableIds` admits the uncomposed leaf only: a composed leaf handed to the engine from outside is refused `INVALID_FILTER` / 400 as a non-list comparand. +- 9b37edb: feat(spec)!: a `type: 'url'` action's `target` declares its closed scheme set — `http://`, `https://`, `mailto:` and in-app paths — and refuses every other target at parse + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings. + + `target` was a free string on every action type, so a url action whose target the console's action runner refuses — `javascript:`, `data:`, `tel:`, a protocol-relative `//host` — parsed clean, published, and failed only at the click. The scheme set is now stated once in the contract and the runtime opens exactly that set. On both url-action doors, `ActionSchema` (a registered action) and `InlineActionSchema` (an inline action, such as `element:button`'s `action`, including the legacy `type: 'navigation'` + `to` spelling), a `type: 'url'` target must be one of: + + - an `http://` or `https://` URL (the scheme compared case-insensitively); + - a `mailto:` address; + - an in-app path that starts with `/` or `./`; + - a template that opens with `${` (`${ctx.origin}/x`, `${param.url}`), judged by the runner after interpolation, at the click. + + Every other target is refused at `target`, with a message that names the reason and the admitted set. `target` on every other action type (`script`, `flow`, `modal`, `api`, `form`) names a handler, flow, page, endpoint or form and is untouched, and so are interpolation and `openIn`. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `target: 'javascript:…'` or `target: 'data:…'` | no url target runs or renders content. Move the work to a `type: 'script'` action (an inline `body`), or link to an `https://` page. | + | `target: 'tel:+1…'`, `target: 'ftp://…'` or any other scheme | an `https://` page that offers it, or a `mailto:` address. `tel:` is not admitted. | + | `target: 'https:example.com'` (no `//`) | `target: 'https://example.com'` | + | `target: '//cdn.example.com/x'` (protocol-relative) | `target: 'https://cdn.example.com/x'` for an external link, or `target: '/x'` for an in-app path | + | `target: '/\example.com'` (`/` then a backslash) | `target: 'https://example.com'`, or an in-app path written with forward slashes | + | a target holding a tab, line feed or carriage return | the same target without them; percent-encode one that belongs in the address (`%09`, `%0A`, `%0D`) | + | `target: 'thanks'`, `'../thanks'`, `'?tab=2'`, `'#top'` | the rooted in-app path, `target: '/thanks'`, or a `./` path | + + **The one-line fix: give every `type: 'url'` action an `http(s)://` URL, a `mailto:` address, or an in-app path that starts with `/` or `./`.** `os migrate meta --from 17` lists the D3 entry `ui-action-url-target-scheme-set-closed`; the rewrite is by hand, because which address a refused target meant is the author's call. + + **What an author now sees.** `os validate`, `os build`, `defineAction` and `defineStack` refuse such a target at `target`. For example, `javascript:alert(1)` gets: ``A `type: 'url'` action's `target` opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. Every admitted target parses unchanged. + + **Measured producers: none outside tests.** Every url-action target written outside tests is admitted: 9 sites in this repository at `df7a137923` (the showcase's `https://objectstack.ai` among them), and 8 in objectui at both its pin `9646e2ed2bf3` and its main `7578323`. One test fixture in `@objectstack/spec` gave the url arm of an all-types loop a bare `test_handler`; it now writes `/test_handler`. +- a22e67b: A `record:details` section heading can show how many values a multi-value field holds on the current record: `sections[].count: { field }`, with the section's `label` writing the `{{ count }}` hole where the number goes. + + Clause-②: yes (widening) + + ```ts + { + name: 'plan_gap', + label: '{{ count }} features need a higher plan', + fields: ['capabilities_not_on_plan'], + count: { field: 'capabilities_not_on_plan' }, + } + ``` + + - **`@objectstack/spec`.** `RecordDetailsProps.sections[]` accepts `count`, a closed object whose one key, `field`, is a bare machine name (snake_case, no dotted path). A section that declares `count` must write the `{{ count }}` hole in its `label`, in every locale of an inline label map. It is refused when it has no `label`, when the label has no hole, when the label writes any other `{{ … }}` hole (another variable, or the count with a formatter), when it writes the single-brace `{count}` / `${count}`, and beside `group` (a group-referencing section's heading is the group's own label). A translated `objects.._sections..label` writes the same `{{ count }}` hole, and `os i18n extract` seeds it from the authored label. `SECTION_HEADING_COUNT_HOLE` exports the hole's grammar (inner whitespace is free: `{{count}}` is the same hole). + - **`@objectstack/lint`.** The page field-binding pass resolves `count.field` against the bound object, at error severity, on `os validate`, `os lint` and `os build`: a field the object does not declare is `page-field-unknown`, and a declared field that holds one value is the new `page-section-count-not-multi-value`. A multi-value field is `multiselect`, `checkboxes` or `tags`, or a `select`, `lookup`, `user`, `file` or `image` field with `multiple: true`. A section's `fields[]` miss stays a warning. + - **Not rendered yet.** The key is declared ahead of its renderer and its description carries the `[EXPERIMENTAL — not enforced]` marker: until the record detail renderer reads it, the hole renders as literal text. + - **Nothing to migrate.** No stack that parsed before is refused: every new refusal needs the new key. A `{{ count }}` hole in a section that declares no `count` is still accepted. +- 5f37d36: feat(spec)!: at most one `isDefault: true` record page per object per deployment — a second is refused at `defineStack` / `os validate`, at package registration and at metadata save, naming both pages (#23015) + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + + `PageSchema.isDefault` now states the rule (maintainer ruling A on #23015): at most one `isDefault: true` record page may exist for an object in a deployment. The record view opens that page with no tie-break, so with two defaults for one object the page that opened used to be whichever plugin initialised first — an accident no author wrote. A record page is `type: 'record'` (the default when `type` is left out) bound to an `object`; `isDefault` on any other page is outside the rule. + + **What is refused, and where.** Every door that can add a second default refuses it, naming both pages: + + - **`defineStack`** (and so `objectstack validate`, `objectstack build` and a source boot) — two default record pages for one object inside one stack, `STACK_CROSS_REFERENCE_INVALID` (422), one finding per object naming the object, every page and its position (`stack.pages[0]`). **`composeStacks`** refuses the pair across the stacks it composes, in the same envelope; under `manifest: 'preserve'` one page name in two stacks is two packages' pages. + - **Package registration** (`@objectstack/objectql`'s `SchemaRegistry`, at `installPackage` ahead of every mutation and at the `registerItem` seam) — a package whose default record page meets another package's default for the same object, an environment's stored default, or a second default in its own manifest, `DEFAULT_RECORD_PAGE_CONFLICT` (422, exported as `DEFAULT_RECORD_PAGE_CONFLICT_CODE`). Two packages' pages under one name are two pages and are refused too. `OS_METADATA_COLLISION=warn` does not soften it. + - **Metadata save** (`@objectstack/metadata-protocol`'s `saveMetaItem`, so `PUT /api/v1/meta/page/:name`) — a page saved as the default record page of an object a stored page or a packaged page already holds, `DEFAULT_RECORD_PAGE_CONFLICT` (422), in draft and publish mode, before anything is written. + + **Unchanged.** A save under the existing default's own name is that page (an ADR-0005 overlay by name), not a second default. One default per object, a second record page that is not a default, and two objects with one default each are accepted. The same package registering again — a reload, or an upgrade that moves its default to another of its pages — is one holder. A save under the name of a page a package ships is still answered by the packaged-base refusal (`NOT_OVERRIDABLE`, 403), as before. A stored page reaching the registry from `sys_metadata` (hydration, write-through) is not refused again. + + ## FROM → TO + + | door | FROM | TO | + |:--|:--|:--| + | `defineStack` / `objectstack validate` | `pages: [{ name: 'account_a', type: 'record', object: 'crm_account', isDefault: true }, { name: 'account_b', type: 'record', object: 'crm_account', isDefault: true }]` accepted; the boot kept both and the record view opened whichever registered first | refused, `STACK_CROSS_REFERENCE_INVALID` 422 — set `isDefault: false` on all but one | + | `composeStacks` | two stacks each shipping a default record page for one object composed clean | refused in the cross-reference envelope, naming both stacks' pages | + | package registration | a second package (or a plugin's `manifest.register`) shipping a default record page for an object another package holds registered; init order decided the page | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, naming both pages; keep one default, or compose the deployment without the package that ships the other | + | metadata save | `PUT /api/v1/meta/page/my_page` with `isDefault: true` for an object that already has a default record page stored or shipped saved | refused, `DEFAULT_RECORD_PAGE_CONFLICT` 422, naming both pages; save it with `isDefault: false`, or first demote the stored default | + + **The one-line fix: keep `isDefault: true` on the one record page per object that should open the record view, and set `isDefault: false` on every other.** To replace a page a package ships, compose the deployment without that package's page; shipping a second default is not an override path. + + No D2 conversion rewrites it: which page stays the default is the author's call. Its D3 record is the semantic entry `page-default-record-page-one-per-object-refused` (protocol 18). + + **`@objectstack/spec`.** `defaultRecordPageObject(page)` (exported from `@objectstack/spec/ui`) is the one reading of "which object is this page the default record page of" that the three doors share. `PageSchema.isDefault` gains its `.describe()`. `DEFAULT_RECORD_PAGE_CONFLICT` joins the error-code ledger under `@objectstack/objectql` and `@objectstack/metadata-protocol`. + + **`@objectstack/objectql`.** The registration refusal, `SchemaRegistry.defaultRecordPageHoldersOf(object)` (every page holding the object's default — the reading the save door meets with the stored pages) and the `DEFAULT_RECORD_PAGE_CONFLICT_CODE` constant. + + **`@objectstack/metadata-protocol`.** The save-door refusal. + + **`@objectstack/platform-objects`.** The page form's `isDefault` help text, which said "Set as default page for this page type" — a rule nothing enforced — now states the one-default-per-object rule, in all four locales. + + **Who is affected, measured.** In this repository every object with a default record page has exactly one: `sys_organization` and `sys_user` (`@objectstack/plugin-auth`), `sys_position` (`@objectstack/plugin-security`), `sys_approval_request` (`@objectstack/plugin-approvals`), and `showcase_project`, `showcase_task` and `showcase_preference` (`examples/app-showcase`). A deployment that also composes a package shipping its own default record page for one of those objects is refused at boot until one of the two pages stops being a default. +- 62ce45d: feat(spec): a page element's `dataSource` can name an API endpoint — `{ provider: 'api', read: { url }, path? }` + + Clause-②: yes (widening: a new accepted shape) + + - **What is new.** `PageComponentSchema.dataSource` takes a second shape beside the object binding: the API binding, exported as `ElementApiDataSourceSchema` (type `ElementApiDataSource`). It reads one endpoint on the deployment's own origin and binds its answer, so a product page composes from a summary that is not an object — `{ provider: 'api', read: { url: '/api/v1/cloud/home' }, path: 'data.environments' }`. `path` is an optional dotted path into the `{ success, data }` response; omitted, the element binds the whole response. + - **Same-origin only, refused at parse.** `read.url` must open with a single `/`. Five classes are refused with the reason and the fix, at `dataSource.read.url`: an absolute URL of any scheme (`https:`, `http:`, `mailto:`, `javascript:`, `data:`, …), a protocol-relative `//host`, a backslash the browser reads as a slash (`/` then a backslash, or a leading backslash), a space or ASCII control character (a tab, line feed or carriage return anywhere is deleted by the URL parser, so `/`, a tab, `/host` is `//host`), and a relative path such as `./home` or `api/v1/x`, which resolves against the page, never the API. The rule is a `pattern` in the published JSON Schema too, so a validator reading `json-schema/` refuses what the parse refuses. + - **No credentials in metadata.** The console reads the endpoint with its own signed-in fetch. The read block declares `url` and `method` only; `headers`, `credentials`, `auth`, `authorization`, `token` and `apiKey` are refused with that prescription, and so are `params` and `body`. + - **Reads only.** `read.method` is optional and accepts `GET` alone, the method the console's API adapter sends for every read. There is no `write` block. + - **The object binding is unchanged.** `ElementDataSourceSchema` accepts exactly what it did and gains no `provider` key; the two shapes meet as a `z.union` at `PageComponentSchema.dataSource`. One visible difference: a refused object binding parsed through `PageComponentSchema` now arrives as one `invalid_union` issue at `dataSource`, whose object branch carries the same issue as before. `os validate` and the API error envelope print one `Invalid input` line for the union at `dataSource`, then the object branch's issue at the same path as before (for example `dataSource.filter`). + - **Not rendered yet.** No console block reads an API binding today: the generic stat, usage-bar and checklist blocks that will are a console release still to come. Until then the binding parses and no block reads it; its schema description says so (`[PLANNED — not yet rendered]`). +- 8464380: fix(spec, objectql): a `tree` field with no `reference` points at its own object, so its `deleteBehavior` takes effect when a parent is deleted + + Clause-②: yes (widening) + + A `tree` field is a record's parent in a hierarchy on its own object. `reference` is optional on it, and when you write one it must be the object's own name — the object schema refuses any other target and tells you to drop the key. But a `tree` with no `reference` behaved as if it pointed at nothing. Deleting a parent answered success and left every child holding the deleted id, whatever `deleteBehavior` said (`set_null`, `restrict` or `cascade`). The same field also could not be expanded (`expand=parent` answered "declares no target object"), could not be filtered through (`{ parent: { title: … } }` was refused), and accepted a parent id that does not exist. + + Now a `tree` with no `reference` is a reference to the object that declares it, exactly as if you had written the object's name: + + - **`@objectstack/spec`.** `ObjectSchema` and `ObjectExtensionSchema` write the object's own name (an extension's `extend`) into such a field's `reference` when they parse it. `defineStack` and `ObjectSchema.create()` output, and built artifacts, now carry the object's name in `reference` on those fields, byte-identical to a field that wrote it. Nothing you could write before is refused now. A new export, `materializeTreeSelfReferences(objectName, fields)`, is the one helper both the parse and the engine use. + - **`@objectstack/objectql`.** The object registry writes the same self-reference when an object is registered. That covers an object saved through the metadata API (`PUT /meta/object/:name` stores the body as written) and a direct `registerObject` call, neither of which goes through the parse. + + What changes on a parent delete: + + - `set_null` (the default) clears the children's parent field and keeps the children. + - `restrict` refuses the delete with `DELETE_RESTRICTED` (409), naming the tree's own object as the dependent. + - `cascade` deletes the whole subtree. + + `expand`, nested relation filters, the write-time check that a parent id exists, the dangling-reference audit and `os verify --delete-behaviors` now treat the field as a self-reference too. A `tree` that already wrote its own name, and every `lookup` and `master_detail`, behave exactly as before. +- 252a582: feat(spec, metadata-protocol): the pending-drafts list and a package's commit timeline tell two languages of one email template apart — each entry of a template carries `member` (#23073) + + Clause-②: yes (widening: a new declared response key) + + **What changed for a reader of the two lists.** An email template kept in several languages is one item per language: two languages of `acme.welcome` are two drafts, and two items of one commit. Until now `GET /api/v1/meta/_drafts` and `GET /api/v1/packages/:id/commits` answered them as two entries with the same `type` and `name`, so a reader could not tell which entry was which language, and could not address the second one. + + - Each entry of `GET /meta/_drafts` (`ListDraftsResponseSchema`) and each item of a commit in `GET /packages/:id/commits` (`ListPackageCommitsResponseSchema`) now carries an optional `member` for a type whose identity is a pair (today only `email_template`, identified by `(name, locale)`). It is the language the entry is: `zh-CN`, and `en-US` for the canonical language, spelled out rather than left blank. Two languages of one template are therefore two entries with different `member` values. + - `member` is present on every entry of such a type and absent on every other type, so the lists are unchanged for every type whose identity is its name alone. + - The value is the one the by-name doors take back as their `member` selector: `GET /api/v1/meta/email_template/acme.welcome?state=draft&member=zh-CN` reads the `zh-CN` draft a list entry names, and `?member=` on publish, history, revert and reset addresses it the same way. For a commit item, `member` is the language the stored revert plan records, the one a revert of that commit acts on. + + **For code that uses these packages directly** (`@objectstack/metadata-protocol`): `ObjectStackProtocolImplementation.listDrafts` entries and `listCommits` items have the optional `member` described above. The types `ListDraftsResponse` and `ListPackageCommitsResponse` (`@objectstack/spec/api`, which `@objectstack/client`'s `meta.listDrafts` and `packages.listCommits` return) gain the same optional field. Code that does not read it behaves as before. +- f931e76: feat(spec)!: the page action rows' url `target`, a dashboard header action's `actionUrl` and an action's `onSuccess.navigate` declare the closed url scheme set — `http://`, `https://`, `mailto:` and in-app paths — and refuse every other target at parse + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on three published authoring surfaces, shipped as `minor` under the launch-window convention for accept-set narrowings. + + All three were free strings, so a target the console's action runner refuses — `javascript:`, `data:`, `tel:`, a protocol-relative `//host`, a bare relative path — parsed clean, published, and failed only at the click or after the action succeeded. A `type: 'url'` action's `target` already closed this set; the scheme set is stated once in the contract and the runtime opens exactly that set, so these doors now apply the same grammar, with no second one: + + - **The page action rows' `target`** on the url executor: an `action:button` or `action:icon` with `actionType: 'url'`, and an `action:group` / `action:menu` member with `type: 'url'`. On every other executor `target` names a script, flow, page, endpoint or form and is untouched, and a row with no `target` is not judged. A page's `properties` bag is outside the stack parse; `os validate` judges it through its props gate, which parses the bag against these same rows and so reports the refusal at the row's `target` with the same text, as a `component-props-invalid` finding (that gate's one tier, a warning). + - **`dashboards[].header.actions[].actionUrl`**, when `actionType` is `'url'` or omitted. An omitted `actionType` is judged as the url route, because the two readers that give it a meaning (the console's dashboard renderer and `os validate`'s dashboard action check) both read it as `'url'`. On `'flow'`, `'script'`, `'modal'`, `'api'` and `'form'`, `actionUrl` names the flow, script, page, endpoint or form and is untouched. + - **`actions[].onSuccess.navigate`**, on every value. + + The admitted set, at both doors: + + - an `http://` or `https://` URL (the scheme compared case-insensitively); + - a `mailto:` address; + - an in-app path that starts with `/` or `./` (`/apps/crm/tasks/${result.id}`); + - a template that opens with `${` (`${result.redirectUrl}`), judged by the runner after interpolation. + + Every other value is refused at the key, with a message that names the reason and the admitted set: `javascript:`, `data:`, `tel:` and every other scheme; `http:` or `https:` without the `//`; a protocol-relative `//host`; `/` followed by a backslash; a tab, line feed or carriage return anywhere; a bare relative value such as `forecast`, `../forecast` or `tasks/${result.id}`; and the empty string. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `target: 'javascript:…'` / `actionUrl: 'javascript:…'` / `navigate: 'javascript:…'`, or `data:…` | no url target runs or renders content. For a page action row or a header action, declare the executor that does the work (`'script'`, `'flow'`, `'api'`), or link to an `https://` page; for `onSuccess`, drop it or navigate to an `https://` page. | + | `'tel:+1…'`, `'ftp://…'` or any other scheme | an `https://` page that offers it, or a `mailto:` address. `tel:` is not admitted. | + | `'https:example.com'` (no `//`) | `'https://example.com'` | + | `'//cdn.example.com/x'` (protocol-relative) | `'https://cdn.example.com/x'` for an external link, or `'/x'` for an in-app path | + | `'/\example.com'` (`/` then a backslash) | `'https://example.com'`, or an in-app path written with forward slashes | + | a value holding a tab, line feed or carriage return | the same value without them; percent-encode one that belongs in the address (`%09`, `%0A`, `%0D`) | + | `actionUrl: 'forecast'` with `actionType` omitted, meant as a flow or script | `actionType: 'flow'` (or `'script'`, …) beside it — the declared type takes `actionUrl` off the url route | + | `'forecast'`, `'../forecast'`, `'?tab=2'`, `'#top'`, `navigate: 'tasks/${result.id}'` | the rooted in-app path, `'/forecast'` / `'/tasks/${result.id}'`, or a `./` path | + | `target: ''` / `actionUrl: ''` / `navigate: ''` | the address the button or the hop was meant to open; drop an `onSuccess` that navigates nowhere | + + **The one-line fix: give every url page action row, every url header action (`actionType: 'url'` or omitted) and every `onSuccess.navigate` an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` — or, for a row or a header action meant to run something, the executor that names it.** `os migrate meta --from 17` lists the D3 entries `ui-page-action-row-url-target-scheme-set-closed`, `ui-dashboard-header-action-url-scheme-set-closed` and `ui-action-on-success-navigate-scheme-set-closed`; the rewrite is by hand, because which address a refused value meant is the author's call. + + **What an author now sees.** `os validate`, `os build`, `defineStack` and `defineAction` refuse such a value at `header.actions[].actionUrl` or `onSuccess.navigate`; on a page action row, `os validate`'s props gate reports it at the row's `target` (`properties.target`, or `properties.actions.N.target` for a member). For example, `javascript:alert(1)` on a header action gets: ``A dashboard header action's `actionUrl`, a url target when `actionType` is `'url'` or omitted, opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. Every admitted value parses unchanged. + + **Measured producers: none outside tests.** Every url-executor page action row and every header action outside tests in this repository and in objectui (at its pin `7578323` and its main `7af0ab37`) names an admitted target or declares a non-url `actionType`, and the only `onSuccess.navigate` outside tests is the documented in-app route template `/apps/crm/tasks/${result.id}`. +- 1912af4: feat(spec)!: retire the view-level `api` data source — a view binds its object or static rows, and a metadata-declared API call is the page element's binding (#23082) + + **BREAKING** — `provider: 'api'` on a view's `data` (`ViewDataSchema`, with its `read` / `write` request blocks) is now refused at parse. The arm took any `url` and any `headers`, so a credential or a cross-origin call written into a view was stored as metadata, served to every client that read the view and sent by the console as written. The page element's API binding (`ElementApiDataSourceSchema`, `dataSource` on a page component) is the one door for a metadata-declared API call: same-origin, no credential key, read with the console's own signed-in fetch. One operation with two implementations that behave differently keeps the governed one — the view-level source is re-homed on the page element and removed, not aligned. + + Clause-②: no (narrowing) + + Measured before removal: no producer wrote `provider: 'api'` on a view outside documentation in objectstack (`bcebd8409`), objectui (`origin/main` `f228d710` — the console's approval-requests source builds `ApiDataSource` in code from an `ApiDataSourceConfig`, never a `ViewData`) or hotcrm (`0f84c9c`), each with a lit `provider: 'object'` control. A renderer reader existed in objectui, measured at `7578323a544e` (`ListView` routed the block to `ApiDataSource`; `ObjectGantt` built one from `read` / `write`), and is now unreachable from authored metadata. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `data: { provider: 'api', read: { url, method?, params?, headers? } }` on a list or form view | `data: { provider: 'object', object: '' }` (or `{ provider: 'value', items: [...] }`), and the read as a page component's `dataSource: { provider: 'api', read: { url: '/api/...' }, path? }` | + | `data: { provider: 'api', write: { url, method, body? } }` on a form view | nothing — a form submits to its object; a submit-to-endpoint is not a view concern | + | `read.headers: { Authorization: '...' }` | nothing — a credential never lives in metadata; the page element binding refuses a credential key and the console sends its signed-in fetch | + + **The one-line fix:** bind every `provider: 'api'` view to its object or to static rows, and move the endpoint read onto a page component's `dataSource`. + + **What an author who still writes it sees.** `tsc` fails at the authoring site (`ViewData` no longer has the arm), and the parse — `defineView()`, `defineStack({ views })`, `os validate`, `PUT /api/v1/meta/view/:name` — refuses it at `data.provider` with the prescription: + + > `view.data.provider: 'api'` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — a view-level API source took any `url` and any `headers`, so a credential or a cross-origin call written there was stored with the view, served to every client that read it and sent by the console as written; a metadata-declared API call now has one door, the page element's binding, which is same-origin and credential-free. Bind the view to its object — `data: { provider: 'object', object: '' }` — or to static rows — `data: { provider: 'value', items: [...] }` — and move the API read onto a page component: `dataSource: { provider: 'api', read: { url: '/api/...' } }` (with an optional `path` into the response) under a page's `regions[].components[]`, which the console reads with its own signed-in fetch. + + ## The retirement kit + + - **A value retirement, not a key**: `data` survives one arm lighter, so there is no `retiredKey()` tombstone and no `RETIRED_KEYS_BY_MAJOR` row. The prescription rides the discriminated union's own `error` map, keyed on the input's `provider === 'api'` — the retired bare-string `sort` clause's shape — so every other unknown provider keeps zod's own report, which lists the live arms (`object`, `value`, `schema`). + - **No D2 conversion, deliberately.** The replacement lives on a different metadata type (a page element), and the only mechanical edit a conversion could make — deleting the view's `data` so the view falls back to querying its bound object — would change what the view shows without telling anyone. An authored source or a stored `sys_metadata` row still carrying the arm meets the prescription at parse instead. + - **The D3 entry `view-data-api-provider-retired`** asks the author, per such view, which page composes the endpoint it read and what the view itself should now bind. + - **`HttpRequestSchema` / `HttpMethodSubsetSchema` stay exported** from `@objectstack/spec/shared` and `@objectstack/spec/ui`, unchanged. After this change no schema in the package composes `HttpRequestSchema`; that orphan is recorded, not acted on here. + - **Ledger:** `view.list.data` and `view.form.data` stay `live` (their surviving arms are read) and carry the arm's retirement record. + - **Docs and the published skill** no longer teach a view-level `api` source; both point at the page element binding. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, and `objectstack-ai/cloud`'s own view metadata was not readable from this change's session; a cloud view carrying an `api` source is refused at parse with the prescription above. + + +- 76815b9: feat(spec)!: a `controlled_by_parent` object derives its master from exactly one required `master_detail` — the parse refuses anything else, and the runtime's two fallback tiers retire (#23100) + + Clause-②: no (narrowing) + + ADR-0055 §1 says a `controlled_by_parent` object "must declare exactly one required `master_detail` field … Validation error otherwise (fail closed)". Until now neither the parse nor the runtime kept it. `ObjectSchema` accepted such an object with no master at all. The security runtime derived a master through a three-tier fallback the record never stated: a required `master_detail`, then any `master_detail`, then a required `lookup`, taking the first match by field order within a tier. The maintainer ruling (letter B) enforces the record, under ADR-0049 (enforce-or-remove). + + **BREAKING — what moves for consumers.** + + - **`@objectstack/spec`.** `ObjectSchema` (raw `.parse()` / `.safeParse()`, `ObjectSchema.create()`, `defineStack`, `os validate` and the metadata save door) refuses an object declaring `sharingModel: 'controlled_by_parent'` unless exactly one of its fields is a `master_detail` declared `required: true`. With none, the issue sits at `sharingModel` and names each field that relied on a retired tier. With two or more, it sits at `fields` and names them. A plain object literal is judged like a raw parse: an omitted `required` on its `master_detail` is refused, while `ObjectSchema.create()` keeps forcing an omitted `required` to `true`. The step-18 semantic migration entry `cbp-master-relation-tiers-retired` carries the prescription for `os migrate meta`. The step-18 conversion `cbp-master-detail-required-defaulted` covers the one lossless shape: a `controlled_by_parent` object whose one `master_detail` omits `required`, which Studio's field designer writes. It gets `required: true`, exactly what `ObjectSchema.create()` does. It runs on stored rows, built artifacts and `os migrate meta --from 17`. A live authoring parse still refuses the shape and names the fix. + - **`@objectstack/plugin-security`.** The relation resolver derives a master from that one field and nothing else. An object that declares a non-required `master_detail` only, a required `lookup` only, or two required `master_detail` fields resolves no master. Every read of it is denied, and every write and insert is refused with `422 INVALID_METADATA`. The plugin reports such an object once per object (`[security/controlled-by-parent] … resolves no master relation`), naming the shape it relied on. The `422` message now says "not exactly one required master_detail field". + - **`@objectstack/lint`.** `security-controlled-by-parent-no-relation` now refuses the non-required `master_detail` and required `lookup` shapes as well. `security-controlled-by-parent-ambiguous-relation` reports two or more required `master_detail` fields as "no master is derived", since the runtime no longer picks one by field order. Two required `lookup` fields are now a no-relation object, not a tie. `security-master-detail-ungranted` no longer treats a lookup-bound `controlled_by_parent` object as a detail. + + **FROM → TO — HotCRM's three objects.** These are the only producers measured on the retired third tier: the read-only HotCRM clone at `0f84c9c`, 8 `controlled_by_parent` objects, 5 of them already on one required `master_detail`. HotCRM's pinned engine is unaffected until it bumps past the release that carries this change. + + | object | FROM | TO | + |---|---|---| + | `crm_campaign_member` | `crm_campaign: Field.lookup('crm_campaign', { required: true })` | `crm_campaign: Field.masterDetail('crm_campaign', { required: true, deleteBehavior: 'restrict' })` | + | `crm_event_attendee` | `crm_event: Field.lookup('crm_event', { required: true })` | `crm_event: Field.masterDetail('crm_event', { required: true, deleteBehavior: 'restrict' })` | + | `crm_article_feedback` | `crm_knowledge_article: Field.lookup('crm_knowledge_article', { required: true, deleteBehavior: 'cascade' })` | `crm_knowledge_article: Field.masterDetail('crm_knowledge_article', { required: true, deleteBehavior: 'cascade' })` | + + `deleteBehavior: 'restrict'` keeps what the two lookups do today: a campaign or an event with members or attendees cannot be deleted. Without it a `master_detail` cascades, and a detail's rows are deleted with their master. An object whose rows must outlive their parent is not a detail. It leaves `controlled_by_parent` for `private`, `public_read` or `public_read_write`. + + **Remedy, in one line.** On every object with `sharingModel: 'controlled_by_parent'`, declare exactly one `master_detail` field with `required: true`, pointing at the master. Every other parent is a `lookup`. + + **Unchanged.** + + - An object with one required `master_detail` resolves and derives exactly as before, chain walk included. So does a required `master_detail` beside other `lookup` fields, or beside a `master_detail` that is not required. Every in-tree `controlled_by_parent` object is in that set: `examples/app-crm` (1), `examples/app-showcase` (2), the CLI's eval corpus (4) and the dogfood fixtures. + - Stored metadata whose one `master_detail` merely omits `required` keeps serving, converted as above. A stored row in any other retired shape is not rewritten: it still registers, under its `[metadata_spec_invalid]` diagnostic, and is denied until it is corrected and saved. Those shapes are an explicit `required: false`, two masters, or a required `lookup`. None of them is converted: turning a `lookup` into a `master_detail` changes what deleting the master does to every row, choosing between two masters is the pick ADR-0055 §1 refuses, and leaving `controlled_by_parent` is the author's decision. + - No export, signature or error code is added or removed. + + +- 4d1e4a8: feat(spec)!: a page action row's `onSuccess` is `ActionSchema`'s own post-success block, so its `navigate` is judged by the url scheme set, and the row's executor key refuses `'navigation'`, the runner's alias of `'url'` + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on two keys of 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 its action keys to the console's action runner. Two of those keys still let a row carry a target the runner refuses at the click: + + - **`onSuccess`** was any value (`z.unknown()`). The runner reads only `ActionSchema`'s post-success block, `{ navigate, openIn }`, and classifies `navigate` with the url scheme set. So `onSuccess: { navigate: 'javascript:alert(1)' }` parsed clean, and so did a misspelled key or a callback shape, which the runner gives no reading at all. The rows now hold `ActionSchema.onSuccess`'s own block, the same instance by reference, so a row's verdict on any `onSuccess` value is the action's: the scheme set on `navigate`, the rename prescriptions for `url` / `redirectUrl` / `to` / `route` / `path` / `target` / `navigateTo` / `href`, and the closed `openIn` with its `'self'` default. + - **The executor key** (`actionType` on a node, `type` on a member) is a free string, and the rows judged a url `target` only when it read `'url'`. The runner also dispatches `'navigation'`, its local alias of `'url'`, to the same navigator, so `actionType: 'navigation'` with `target: 'javascript:…'` parsed clean. The url executor now has one spelling on the rows: `'navigation'` is refused at the executor key, with the rename onto `'url'`. The key otherwise stays open: a handler a host registers under another name (`navigate_create`, `navigate_edit`, …) is still dispatched by name. + + A page's `properties` bag is outside the stack parse. `os validate` judges it through its props gate, which parses the bag against these same rows, so it reports each refusal at the row's own path with the same text, as a `component-props-invalid` finding (an error, like every finding of that gate). A stored page still loads; the metadata save door runs the same gate, so the next save of a page carrying a refused row is refused with the same text. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `onSuccess: { navigate: 'javascript:…' }` (or `data:`, `tel:`, any other scheme) | an `https://` page or an in-app route, or drop the `onSuccess` — no hop runs a script or renders content | + | `onSuccess: { navigate: '//cdn.example.com/x' }` | `'https://cdn.example.com/x'`, or `'/x'` for an in-app path | + | `onSuccess: { navigate: 'tasks/${result.id}' }` (a bare relative template) | the rooted route, `'/tasks/${result.id}'`, or a `./` path | + | `onSuccess: { navigate: '' }`, or `onSuccess: {}` | the address the hop was meant to open, or drop the `onSuccess` | + | `onSuccess: { url: '/x' }` (or `redirectUrl`, `to`, `route`, `path`, `target`, `navigateTo`, `href`) | `onSuccess: { navigate: '/x' }` | + | `onSuccess: { navigate: '/x', openIn: 'new-tab' }` | `openIn: 'newTab'` — the block's spelling, not the top-level url switch's | + | `onSuccess: { type: 'notify', … }`, an array of callbacks, or a bare string | drop it: the runner reads only the navigation block, so these never ran | + | `actionType: 'navigation'` on an `action:button` / `action:icon` | `actionType: 'url'`, with the same `target` and `openIn` — `os migrate meta --write` rewrites it, and a stored row is rewritten when it is read | + | `type: 'navigation'` on an `action:group` / `action:menu` member | `type: 'url'`, with the same `target` and `openIn` — `os migrate meta --write` rewrites it, and a stored row is rewritten when it is read | + + **The one-line fix: give every page action row's `onSuccess` the action's block, `{ navigate, openIn? }`, with a `navigate` that is an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` — and spell the url executor `'url'`.** The rename from `'navigation'` is lossless on a row: the runner sends both spellings to one navigator, and the alias's own keys (`to`, `redirect`, `external`, `newTab`, `replace`) are not row keys. The executor rename is mechanical: `os migrate meta --from 17 --write` performs it through the ADR-0087 D2 conversion `page-action-row-navigation-executor-to-url`, which is retired from the load path, so an author meets the refusal while stored rows and migrated sources are rewritten. It lists the D3 entries `ui-page-action-row-navigation-executor-refused` (which names that conversion) and `ui-page-action-row-on-success-shape-closed`. An `onSuccess` rewrite stays by hand, because which address a refused `navigate` meant is the author's call. + + **What an author now sees.** `os validate`'s props gate reports a refused `navigate` at `properties.onSuccess.navigate` (or `properties.actions.N.onSuccess.navigate` for a member) with the action's own refusal: ``An action's `onSuccess.navigate` opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. The alias is reported at `properties.actionType` (or `properties.actions.N.type`): ``An `action:button`'s `actionType: 'navigation'` is the console runner's local alias of `'url'`, refused on page action rows since @objectstack/spec 18, and the url executor has one spelling.``, followed by the rename. A row's parsed `onSuccess` now carries `openIn: 'self'` when it was omitted, as an action's does; the runner reads an omitted `openIn` the same way. + + **Measured producers: none refused.** No page action row in this repository or in objectui (at its pin `1fb1ced3`) writes an `onSuccess` outside the block or the executor `'navigation'`. objectui's row tests write `{ navigate: '/app/records/${result.id}' }`, admitted, and every `type: 'navigation'` there is an `element:button` inline action or a runner test, not a page action row. Host-registered executor names are kept: this package's own released guidance prescribes `actionType: 'navigate_edit'` on an `action:button`. +- 4cc3ff9: feat(spec): `record:related_list` declares its parent record with a binding of its own, `parentDataSource`, beside the child-row `dataSource` (#23134) + + Clause-②: yes (widening) + + - **What is new.** `ComponentPropsMap['record:related_list']` (`RecordRelatedListProps`) accepts `parentDataSource`: the PARENT record the list hangs off, bound by a query. Its shape is the element data-source object binding `{ object, view?, filter?, sort?, limit? }` (`ElementDataSourceSchema`, referenced, not copied), the same binding `dataSource` takes on every page component. A binding the data-source shape refuses (no `object`, an unknown key, the MongoDB-style `filter` record, the `api` arm) is refused here too, at `parentDataSource`. + - **The single-record rule.** The parent is the FIRST row of the bound set by the binding's `sort`; declare `limit: 1` to say one row is meant. When no row answers, the block renders a loud absence, never an empty list. `relationshipValueField` is read on that row. This is the rule `record:details`, `record:highlights` and `record:quick_actions` already take for their own `dataSource`. + - **The fallback.** Without `parentDataSource`, the record page's record context remains the parent, as before. Every related list that parsed before parses unchanged. + - **`dataSource` keeps its meaning.** The node-level `dataSource` on `record:related_list` is still the CHILD rows the list draws (it fills `objectName`, `columns`, `filter`, `sort` and `limit`) and is never read as the parent. + - **Planned, not yet rendered.** The key's describe opens with `[PLANNED — not yet rendered]`: no objectui renderer reads it yet. objectui reads it once a release carrying this key is installable there; until then a related list on an `app` page still finds no parent. + - **Refused elsewhere.** Every other `ComponentPropsMap` row refuses `parentDataSource` as an unknown key, and `os validate` reports it as `component-props-unknown-key`. + + ```ts + { + type: 'record:related_list', + properties: { + objectName: 'contact', + relationshipField: 'account_id', + parentDataSource: { + object: 'account', + filter: [{ field: 'is_primary', operator: 'equals', value: true }], + limit: 1, + }, + }, + } + ``` +- 107853c: `IAIConversationService.get` / `list` and `IAIService.listPendingActions` take the caller's execution context as an optional trailing parameter (ADR-0096 D5) + + Clause-②: yes (widening: an optional parameter on three published store-contract methods) + + - **FROM → TO.** + - `get(conversationId)` → `get(conversationId, context?: ExecutionContext)` + - `list(options?)` → `list(options?, context?: ExecutionContext)` + - `listPendingActions?(filter?)` → `listPendingActions?(filter?, context?: ExecutionContext)` + - **What an implementation does with it.** When a caller supplies `context`, the implementation passes it unchanged as the `context` of every engine read the method makes (`BaseEngineOptions.context`), so the read runs as that caller. Under ADR-0096 D5 strict mode an engine read with no principal is refused with 403, so without a caller an implementation can only read as system. The type and the position are the ones `ISharingService`, `IApprovalService`, `IShareLinkService` and `IAnalyticsService` already use. + - **Nothing to migrate.** The parameter is optional. A caller that omits it gets the read the implementation made before, and an implementation written against the shorter signatures still satisfies both interfaces. To read as the caller, pass the request's `ExecutionContext` and forward it to your engine reads. + - `list`'s `options.userId` stays a filter on the conversation's owner. It is not the caller's identity. + - Whether an ordinary member is admitted to their own rows depends on the stored objects' access and sharing, which these contracts do not declare. +- 543b33d: fix(metadata-protocol)!: a metadata save or delete that names no package addresses the row of the one package the item belongs to, and a name two or more packages hold is refused `409 RESOURCE_CONFLICT`; the delete door can name its 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 one item can hold one row per package. A save that names a package (`?package=` on the REST door, `packageId` in process) writes that package's row. A save that named none always wrote the package-unbound row, even for an item a package owns, and a delete named no package at all: it removed whichever row of the item the store returned first. The draft save already resolved this: a draft that names no package is stored in the package of the item's active row. + + **FROM.** Save an item into a package (`PUT /api/v1/meta/app/my_app?package=com.acme.app`), then save it again without `?package=`: the second save answered `200` "Saved app 'my_app' (env-wide, state=active)" and wrote a second, package-less row beside the package's row. The item read kept serving the package's row, so nothing the save carried took effect, and a console "Disable" reported success while the app stayed enabled. The same held for the API, the CLI and agents, and for an overlay row bound to a code package. A `DELETE /api/v1/meta/app/my_app` of an item two packages hold removed one of the two rows, the other was then served, and the receipt said "it no longer exists". Uninstalling a package, or discarding its drafts, deleted each of its items by name the same way, so it could remove another package's row of the same name and leave its own. + + **TO.** A direct save, or a delete, that names no package resolves its address from the item's rows (the active rows for a save and a delete, the draft rows for a draft discard): + + - **Bound to one package.** The save writes that package's row, exactly as a save naming the package does, and the item read serves it next; the delete removes that row. The receipts name the package: "Saved app 'my_app' (env-wide, package=com.acme.app, state=active)" and "Deleted app 'my_app' (package=com.acme.app) — it no longer exists." Every check on the row the save writes reads that package: its stored lock, a stored credential carried forward, the optimistic lock, and the writable-base rule. The item read that names no package serves that row's `version`, so a read followed by a save or a delete with the served `If-Match` is accepted. + - **Bound to two or more packages.** The save or delete is refused `409 RESOURCE_CONFLICT` and changes nothing. The message names the packages and the remedy: name one with `?package=`. + - **Bound to none.** The save writes, and the delete removes, the package-unbound row, as before, and the receipts read as before. + + The delete door gains the address a save has: `DELETE /api/v1/meta/:type/:name?package=` (`packageId` in process, `DeleteMetaItemRequestSchema.packageId`) removes that package's row and no other; `all` and an empty value name no package, as on the save door. A repeated `?package=` is refused `400`. A delete receipt now says when another row of the item is still stored, naming it, instead of claiming the item is gone or back at its artifact default. Uninstalling a package and discarding its drafts name the package, so each removes only its own rows. + + **Also refused now**, each as the same save naming the package is refused: a package-less save of an item held by a read-only package (a code package's runtime-only item, or an installed `system`/`cloud` package's) answers `422 WRITABLE_PACKAGE_REQUIRED`; and a package-less save asserting a create (`If-None-Match: *`) of an item a package holds answers `409 METADATA_CONFLICT`. + + `packageId: null` in process names no package too, as the request contracts state. The server-side rewrites keep the row they name: `os migrate meta --stored --apply` and the stored flow credential move pass `null` for a package-unbound row they rewrite, and that row is still the one written; package duplication names its target package. + + **Unchanged.** A save or a delete that names a package exactly. A draft save, which keeps its own inheritance. An item a code package ships with no stored row: the package-less save still writes the package-unbound overlay, and the read serves it. Package-less rows already stored beside a package's row stay as they are; none is converted. A package-less delete of such an item removes the package's row and its receipt says the package-less row remains. + + **Remedy.** For an item two packages hold, send the save or the delete with `?package=` naming the package whose row it edits or removes. A receipt now names the package when the write addressed a package's row; a client that compares receipt text should expect `, package=` after `env-wide` on a save and ` (package=)` after the item on a delete. + + `@objectstack/spec`: the `SaveMetaItemRequestSchema.packageId` description now states what an `active` save that names no package writes, where it said the env-local overlay; `DeleteMetaItemRequestSchema` declares the optional `packageId`. `@objectstack/rest`: `package` joins the reset door's closed query set (`META_ITEM_RESET_PARAMS`). +- b529a5c: feat(spec)!: a page action row's url `target` is judged on the executor the console's action runner actually runs, so a row with no executor key whose `name` is `url` or `navigation` has its `target` judged by the url scheme set + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on one key of 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[]`). The console's action runner dispatches a row on `type || actionType || name`, and every row forwards its executor key (`actionType` on a node, `type` on a member) as `type` beside its `name`. So a row with no executor key runs the handler its `name` names, and `name: 'url'` or `name: 'navigation'` runs the url navigator. The rows judged `target` by the url scheme set only when the executor KEY read `'url'`, so such a row parsed with a target the click refuses. + + The rows now judge `target` against the row's effective executor: the executor key, or, when that key is omitted or empty, the `name`, exactly as the runner resolves it. When it resolves to the url navigator, `target` is judged by the same url scheme set as an `actionType: 'url'` row, with no second grammar. A declared executor key still outranks the `name`. `name` is not refused as an executor spelling, and a row whose `name` names any other handler (`navigate_edit`, `convert_lead`, …) is untouched. An `actionType: 'url'` row's refusal is unchanged. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `{ name: 'url', target: 'javascript:…' }` with no `actionType` (or no `type` on a member), or `data:…`, `tel:…` or any other scheme | an `https://` page, a `mailto:` address, or an in-app path that starts with `/` or `./`; or, for a button meant to run something, the executor that does the work (`actionType: 'script'`, `'flow'`, `'api'`) | + | the same with `name: 'navigation'` | the same; the `name` is the runner's url alias, so the target is judged as a url | + | `{ name: 'url', target: 'forecast' }`, `'../forecast'`, `'?tab=2'` | the rooted in-app path, `'/forecast'`, or a `./` path; or declare the executor the row was meant to run | + | `{ name: 'url', target: '//cdn.example.com/x' }` | `'https://cdn.example.com/x'`, or `'/x'` for an in-app path | + | `{ name: 'url', target: '' }` | the address the button was meant to open | + + **The one-line fix: give every page action row that has no executor key and is named `url` or `navigation` an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` as its `target`, or declare the executor key it was meant to run.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-name-leg-url-target-judged`; the rewrite is by hand, because which address a refused target meant is the author's call. + + **What an author now sees.** `os validate`'s props gate reports the refusal at the row's `target` (`properties.target`, or `properties.actions.N.target` for a member). For example, `{ name: 'url', target: 'javascript:alert(1)' }` on an `action:button` gets: ``An `action:button`'s `target` (`name: 'url'` and no `actionType`, so the runner runs the url navigator) opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. Every admitted target parses unchanged. + + **Measured producers: none.** No page action row in this repository, in objectui (at its pin `1fb1ced36243` and its main `866763bd`) or in hotcrm (at `0f84c9c`) is named `url` or `navigation`. +- 1623faf: feat(spec)!: a page action row's `onSuccess` is refused beside a console runner built-in other than `api` / `script`, and beside a row with no executor; `ActionSchema`'s `onSuccess` refusal now states its true reason + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on one key of 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 `onSuccess` to the console's action runner, which performs the post-success hop after every executor that succeeds. `ActionSchema` refuses `onSuccess` beside any `type` but `api` and `script`; the rows judged it beside no executor at all. They now carry the action's rule on the vocabulary the action's closed `ActionType` can see, read against the row's effective executor (the executor key, or the row's `name` when that key is omitted or empty, as the runner resolves it): + + - refused beside the runner built-ins `url`, `navigation`, `modal`, `flow` and `form`; + - refused on a row with no effective executor (no executor key, or an empty one, and no `name`); + - accepted beside `api` and `script`, and beside any other name: that is a handler a host registers, the runner hops after it too, and only the host knows whether its handler navigates. + + `ActionSchema`'s own reach is unchanged (`url`, `modal`, `flow` and `form` are refused, as before). Its message used to say that on those types the key "would parse clean and never run" and that "no renderer reads a post-success hop today". Both were false: the runner hops after every executor. The action and the rows now print one refusal text with the true reason for each executor. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `{ actionType: 'url', target: '/tasks', onSuccess: { navigate: '/done' } }` (or `type: 'url'` on a member, or `name: 'url'` with no executor key) | the destination in `target`, the tab in `openIn`, and no `onSuccess`: the url action is itself the navigation, so the hop navigated a second time | + | the same with `navigation` | the same, with `actionType: 'url'` (the alias is refused at the executor key as well) | + | `{ actionType: 'form', target: 'task_form', onSuccess: … }` | no `onSuccess`; declare where a submitted form goes in the form view's `submitBehavior` | + | `{ actionType: 'modal', … , onSuccess: … }` or `{ actionType: 'flow', … , onSuccess: … }` | no `onSuccess`, or move the hop onto an `api` or `script` action, whose result it reads | + | `{ label: 'Go', onSuccess: { navigate: '/tasks' } }` (no executor key and no `name`) | declare the executor whose success the hop follows, `actionType: 'api'` or `actionType: 'script'` (`type` on a member), or drop `onSuccess` | + + **The one-line fix: keep `onSuccess` only on a page action row whose executor is `api`, `script` or a host-registered handler, and declare that executor.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-on-success-beside-executor-refused`; the rewrite is by hand, because where the hop belongs is the author's call. + + **What an author now sees.** `os validate`'s props gate reports the refusal at the row's `onSuccess` (`properties.onSuccess`, or `properties.actions.N.onSuccess` for a member). For example, `{ actionType: 'url', target: '/tasks', onSuccess: { navigate: '/done' } }` on an `action:button` gets: ``An `action:button`'s `onSuccess` (`actionType: 'url'`) would navigate a second time: a `url` action is itself the navigation, and the console's action runner performs the post-success hop after every executor that succeeds.``, followed by what the hop is declared beside and the prescription. The same row without `onSuccess` parses unchanged. + + **Measured producers.** None in this repository or in hotcrm (at `0f84c9c`). In objectui (at its pin `1fb1ced36243` and its main `72594f29`), one test row with no executor and no `name` carries `onSuccess` and is asserted accepted; its remedy is to declare `actionType: 'api'` on that row. A host-handler row (`actionType: 'run'`) with `onSuccess` stays accepted. +- 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. +- c857a93: feat(spec)!: publish refuses an anonymous flow endpoint whose target flow runs as `system`, in both directions, with the prescription (ADR-0138 D2b) + + Clause-②: yes (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0138 D2b decides that no anonymous door triggers an elevated flow directly. An `authRequired: false` endpoint of `type: 'flow'` whose `target` flow declares `runAs: 'system'` ran every anonymous request's data steps as the system principal, past row-level security, with the endpoint's rate limit as the only brake. The runtime's own elevated-start refusal covers only self-triggered flow types (`autolaunched`, `record_change`, `schedule`), so a `system` flow of type `api` or `screen` behind such an endpoint executed elevated. Publish now refuses the pair, in both directions, because either item can be the last one edited: the endpoint's publish reads its target's `runAs`, and a flow's publish that sets `runAs: 'system'` reads the anonymous endpoints that target it. Each refusal names the endpoint and the flow and carries the prescription. + + **What is refused, and where.** Every publish door that holds the pair calls one judge, `anonymousElevatedFlowPairs` in `@objectstack/spec/api`: + + - **The stack parse** (`defineStack`, `objectstack validate`, `objectstack build`, the artifact load): a stack whose anonymous flow endpoint names one of its own flows declaring `runAs: 'system'`. One zod issue per pair, `code: 'custom'` at `apis[i].authRequired`; `defineStack` throws its schema refusal, `STACK_SCHEMA_INVALID` (422), carrying the issue. + - **The package publish** (`MetadataManager.publishPackage`, `POST /api/v1/packages/:id/publish`): an `api` item of the package naming a `system` flow anywhere in the manager's index, reported at the endpoint; and a `flow` item of the package declaring `runAs: 'system'` that an anonymous endpoint outside the package names, reported at the flow. The package publishes nothing (`success: false`, the pair in `validationErrors`), and `validate: false` does not turn it off. + - **A flow published through the metadata API** (`saveMetaItem` in publish mode, so `PUT /api/v1/meta/flow/:name`; a draft's publish, `POST /api/v1/meta/flow/:name/publish`; and `POST /api/v1/packages/:id/publish-drafts`): a flow going `active` with `runAs: 'system'` while a declared anonymous flow endpoint names it. `INVALID_METADATA` (422), `issues[0].path: 'runAs'`, nothing written; a publish-drafts batch aborts on it. A draft save is not judged (a draft does not execute; its publish is), and neither is a `migrate-stored` re-save of a row that already exists. + + **Unchanged.** Each item alone publishes as before: an authenticated endpoint on a `system` flow, an anonymous endpoint on a `user` flow (or one that leaves `runAs` at its default), and an anonymous `object_operation` endpoint are all accepted. A stored flow row that already pairs this way keeps its bytes and keeps executing as published; its next publish is refused. A guest-written row that fires a record-change flow is outside the refusal: the record leaves that indirect path undecided, because its trigger is the write, not the door. + + ## FROM → TO + + | door | FROM | TO | + |:--|:--|:--| + | `defineStack` / `objectstack validate` | `apis: [{ name: 'acme_intake_api', type: 'flow', target: 'acme_intake', authRequired: false, rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, … }]` beside `flows: [{ name: 'acme_intake', runAs: 'system', … }]` accepted; every anonymous request ran `acme_intake` as the system principal | refused, `STACK_SCHEMA_INVALID` 422, the issue at `apis[0].authRequired` naming both items and the prescription | + | package publish | a package carrying either half of the pair, with the other half anywhere in the manager's index, published | refused, `success: false`, the pair reported at the package's own item | + | metadata API flow publish | `PUT /api/v1/meta/flow/acme_intake` with `runAs: 'system'` while an anonymous endpoint names it saved and armed it | refused, `INVALID_METADATA` 422, the issue at `runAs`, nothing written | + + **The one-line fix: serve the caller another way — an authenticated endpoint (`authRequired: true`) for a partner that can hold a credential, the flow's signed inbound hook (ADR-0041: a start node with `triggerType: 'api'`, a `hookId` and a `secret`, which may keep `runAs: 'system'`) for a partner webhook, or a public form (a form view whose `sharing` sets `allowAnonymous: true`) for anonymous intake.** `runAs: 'automation'` joins that list when ADR-0073 M2 adds the value. + + No D2 conversion rewrites it: which channel replaces the anonymous endpoint depends on who the caller is, and whether the flow needs elevation is a judgment about its data steps, so no conversion can choose a posture for the author. Its D3 record is the semantic entry `api-endpoint-anonymous-elevated-flow-refused` (protocol 18). + + **`@objectstack/spec`.** The judge and its two refusal sentences join `@objectstack/spec/api`: `anonymousElevatedFlowPairs`, `anonymousFlowDoorTarget`, `anonymousEndpointElevatedFlowMessage` (the endpoint direction), `elevatedFlowAnonymousEndpointsMessage` (the flow direction) and `validateAnonymousElevatedFlows` (the stack's issues), with the types `AnonymousFlowDoorCandidate`, `ElevatedFlowCandidate` and `AnonymousElevatedFlowPair`. `ObjectStackDefinitionSchema` runs the judge beside the per-endpoint gates. The `runAs` description on the flow schema, which prescribed `system` for every user-less trigger, `api` included, now names the exception and what serves that caller. + + **`@objectstack/metadata`.** `MetadataManager.publishPackage` runs the judge over the package's `api` and `flow` items against the index the endpoint matcher serves from. + + **`@objectstack/metadata-protocol`.** The flow publish doors run the judge against the declared endpoints: the engine registry's `api` items and the `metadata` service's. + + **Who is affected, measured.** No in-repository stack pairs an anonymous flow endpoint with a `system` flow: `examples/` declares no `authRequired: false` endpoint, and every anonymous flow endpoint in the test fixtures names a `runAs: 'user'` flow. The partner-webhook example, `showcase_inbound_task_webhook`, takes the signed inbound hook with `runAs: 'system'` and is untouched. +- 345d1be: fix(spec,plugin-sharing)!: `sys_record_share` no longer offers a `guest` recipient, which no grant ever honoured (ADR-0138 D8) + + Clause-②: no (narrowing: a declared recipient value no grant honours is refused) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + A record share to the anonymous principal was declared in two places and honoured in none. Both declarations are gone: + + - `RecordShareRecipientType` (`@objectstack/spec/contracts`) is now `'user' | 'group' | 'position' | 'unit_and_subordinates'`. Passing `recipientType: 'guest'` to `ISharingService.grant`, or typing a `RecordShare.recipient_type` as `'guest'`, is a compile error. + - The `recipient_type` select on `sys_record_share` (`@objectstack/plugin-sharing`) offers those same four options, and its `guest` label is gone from the four translation bundles. + + Why: the grant refused `guest` (ADR-0078), and no gate reads such a row, because the read and write gates match `recipient_type: 'user'` only. ADR-0090 D11 routes guest access through the guest position and share links, so a record share to the guest had nothing to gain. The sharing-rule vocabulary retired the same recipient in v17. + + What a caller sees now: + + - **The grant door** (`POST /data/:object/:id/shares`, or `grant()` from untyped code). A body naming `recipientType: 'guest'` is still refused with `VALIDATION_FAILED` (`400`), as before. The sentence now says the value was removed and that guest access flows through the guest position and share links. It used to say that sharing rules deliver non-`user` recipients, which was never true of a guest. To let a guest reach a record, use a public form or a share link. + - **The row door** (a system-context engine write: a seed, a plugin, a migration script). A row carrying `recipient_type: 'guest'` is now refused with `VALIDATION_FAILED`, an `invalid_option` error on `recipient_type` that lists the four values a row may carry. Before this change such a write stored a row that looked granted and enforced nothing. + - **Unchanged:** every `user` grant, the other three row values, the sharing-rule service, and the gates. A `guest` row that a system write stored before this release is read by no gate, both before and after. +- 430e5f5: fix(security)!: under a walled posture every environment-metadata write is the platform operator's, whatever its type (ADR-0131 D12 items 7–8, D11) + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. ADR-0131 D12 item 7: a walled deployment is "one environment, one schema, one catalog … The metadata-authoring capability (`manage_metadata` / `studio.access`) is what headquarters IT holds and plant admins do not." Item 8: a plant "cannot create a position, a permission set, a capability, a template or any metadata … A plant that needs its own catalog is given its own environment." D11: the rules are identical in every posture; only whether the wall is enforced differs. + + **Why.** The environment definition ledger carries no organization column, so a write lands for every organization. Measured on booted `isolated` and `group` stacks before this change: an organization owner granted `manage_metadata` inside their own organization created an object, a view and a flow at `/api/v1/meta`, edited, published, rolled back and reset the operator's view, ran `POST /api/v1/meta/_migrate-stored`, passed all thirteen `/api/v1/packages` write doors, installed, upgraded and uninstalled packages through `install-local`, and created a flow at `/api/v1/automation`, each with `2xx`; another organization's owner read every result. The security catalog was refused at these doors already; every other type was not. + + **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + + - `PUT`, `DELETE`, `POST …/publish` and `POST …/rollback` of `/api/v1/meta/:type/:name` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for a type outside the security catalog (`object`, `view`, `flow`, `app`, `dashboard`, `translation` and every other type): **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher answers `403 PERMISSION_DENIED`). + - Every `/api/v1/packages` write on the runtime dispatcher — create, the manifest `PATCH`, enable, disable, publish, publish-drafts, discard-drafts, revert, rollback, `commits/:commitId/revert`, duplicate, adopt-orphans and delete — for a package carrying no position, permission set or capability: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is read or written. The refusal no longer reads the package's items. + - `POST /api/v1/marketplace/install-local` (install and upgrade) and `DELETE /api/v1/marketplace/install-local/:id` (uninstall) on `@objectstack/cloud-connection`, for a package carrying no catalog item: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`. The refusal's `error.details` no longer carries `catalogItems`; it carries `manifestId`. + - `POST /api/v1/automation`, `PUT /api/v1/automation/:name`, `DELETE /api/v1/automation/:name` and `POST /api/v1/automation/:name/clone`: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the automation service is touched. + - `POST /api/v1/meta/_migrate-stored`, the install-wide rewrite of the stored metadata, preview and apply alike: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` on `@objectstack/rest` and `403 PERMISSION_DENIED` on the runtime dispatcher, before a row is read. + - Every one of these refusals carries one sentence: it names the written type (`a package` at the package doors, `the environment's stored metadata` at the rewrite), the posture and "ADR-0131 D3, D12 items 7–8, D11", and points at an environment of the organization's own. The catalog refusals that shipped before keep their envelopes and now read the same sentence. + - The activation refusals under a wall (`POST /api/v1/automation/:name/toggle`, `POST /api/v1/actions/_activation/…`, `POST /api/v1/security/_activation/permission/…`) no longer recommend cloning a flow or a permission set, or authoring a sibling action, since those writes are the operator's under a wall too; they name the platform operator. + + **Unchanged.** Reads. Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows. The platform operator and the engine's own writes (seeders, installers, migrations) write as before. Under `single` (and with no `tenancy` service), a `manage_metadata` holder writes exactly as before. + + **The remedy.** The platform operator authors the environment's metadata, in Studio or through a managed package; an organization that needs metadata of its own is given an environment of its own. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-environment-metadata-writes-retired`. `walledCatalogWriteVerdict` (`@objectstack/metadata-core`) now judges every type alike: a custom door that writes environment metadata asks it after its capability gate, with no `isSecurityCatalogType` pre-filter. +- 14a13bc: fix(metadata-protocol)!: the metadata save, restore and publish doors refuse a permission set whose body `packageId` disagrees with the package it is saved into, and the audience-binding suggestion reader takes a set's owner from its binding only (ADR-0086 D3) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0086 D3 declares `PermissionSetSchema.packageId` as the owning package of a package-shipped set, absent for an environment-authored one. The owner every reader uses is the package the set is saved into: the stored row's `package_id`, served as `_packageId`. A body `packageId` that named another package was accepted and then silently overridden, so an author, an AI build above all, never learned that the owner it wrote was not the owner it got. + + **FROM.** A permission set saved and published into package `app.crm` with `packageId: 'app.other'` in its body answered `200` and `published`, and was owned by `app.crm` everywhere it was read. A set saved into no package with `packageId: 'app.other'` was stored as an environment-authored set, while the audience-binding suggestion reader offered it, accepted it and derived it into `everyone` as `app.other`'s. + + **TO.** + + - **`@objectstack/metadata-protocol`.** The save door (`PUT /api/v1/meta/permission/:name`, draft and publish mode, and every in-process caller) the restore doors (`POST /api/v1/meta/permission/:name/rollback` and a commit revert's restore limb) and the draft promotion (`POST /api/v1/meta/permission/:name/publish` and the package publish) refuse a permission set whose body `packageId` is not the package the row is written into, with `400 VALIDATION_ERROR`, the code and status the doors already answer for a body `name` that disagrees with its row. `issues[]` carries the key at `packageId`. A body `packageId` on a set saved into no package is refused too, the empty string included: a present key declares a package-shipped set. The message names both values and both remedies. The package judged is the one the write lands in: a draft saved without naming a package lands in the package of the item's active row, and is judged against it. A package publish that meets such a draft (one stored before this change) aborts the whole batch, `outcome: 'refused'`, with the draft in `failed[]` under `VALIDATION_ERROR`. A rollback to a stored version that carries such a body (one written before this change) is refused, and a commit revert reports it in `failed[]` under the same code; the remedy there is to save the set again with `packageId` dropped or set to its package, instead of restoring that version. Nothing is written, published or restored. Package duplication copies a set whose body names the source package as naming the target package. `os migrate meta --stored --apply` records a stored set it rewrites, and the door refuses, as that row's failure. + - **`@objectstack/plugin-security`.** The audience-binding suggestion reader takes a set's owner from its binding only, never from its body's `packageId`. A set bound to no package has no owner there, so no suggestion is created for it. An acceptance recorded under the package its body named is withheld from `everyone` at the next derivation. The withholding is reported at `warn`, naming the set and its reason. Accepting a pending suggestion of that kind is refused. + - **`@objectstack/spec`.** The `PermissionSetSchema.packageId` description states the refusal. The step-18 semantic migration entry `permission-set-package-id-binding-mismatch-refused` carries the upgrade sweep. + + **Unchanged.** A body `packageId` equal to the package the set is saved into is accepted, and an absent one is accepted everywhere. Stored rows are not rewritten. A stored set whose body disagrees with its binding keeps loading and keeps reading as its binding, and is refused on its next save or publish, as is a restore of a stored version that carries one. An installed package's own sets are owned by the package that ships them, as before. + + **Remedy, in one line.** Drop `packageId` from the permission set's body, or save the set into the package it names (`?package=` on the REST door, `packageId` in process). +- ca0bdbc: A builtin flow node's `config` value that carries a `{token}` is judged for its type at the build doors like any other value: `get_record` with `limit: '{x}'` is refused at `nodes.N.config.limit` ("expected number, received string") by `FlowSchema.parse`, `defineStack`, `objectstack validate`, `objectstack compile`, the metadata save door and `AutomationEngine.registerFlow`, where it used to register and then fail every run that reached the node. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for accept-set narrowings (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** The build doors' value arm (`flowNodeConfigRefusals`) never refused a value carrying a `{token}` for its type, because the flow interpolator decided what the run would parse there. The interpolator and every call site it had are deleted on this line, and every builtin executor parses its config as authored (an `http` node after its credential channel's `signingSecret`), so the run's contract sees the token the build's sees and refuses it at every run. The value arm passed exactly those values. + + **What is refused now.** A token-carrying value at a builtin key its executor contract refuses it at: a number or boolean key (`get_record` `limit`, `loop` `maxIterations`, `update_record` and `delete_record` `multi`, a screen field's `min`, `max` or `required`, a `try_catch` `retry.maxRetries`), a key with a declared set (`notify` `severity`, `screen` `mode`), a record key written as one token (a CRUD `filter: '{f}'`), and a rule finding the token used to hide (a `notify` `templateData` with no `template`). Each is `node-config-refused-by-contract`, `params: { nodeType, key }`, anchored at the key, in the contract's own words — `defineStack` throws `STACK_SCHEMA_INVALID` (422) at `flows.N.nodes.M.config.`. + + **What does not change.** + + - A token at a plain string key (`outputVariable`, `flowName`, `objectName`) parses and registers as before: the contract takes a string there. + - A token at a value slot (a CRUD `fields` value, a callee `input`, a screen's `defaults`, `defaultValue` or `recordId`, a `loop` or `map` `collection`, a `notify` `recipients`, `templateData` or `sourceId`, an `http` `body`, a filter operand) or at a text slot (a `notify` `title`, `message` or `actionUrl`, a screen `title` or `description`, an `http` `url` or header value) is refused, as before, by that slot's own judge at `registerFlow` and `objectstack validate`, once, with the slot's label and remedy: the value arm exempts each finding that judge owns, so no token is reported twice. + - At a key its executor reads as written (a `notify` `sourceObject`, `actorId` or `payload`; an `http` `method`, `durable`, `timeoutMs` or whole `headers` map) the key is refused once, in the literal's own words, as before. + - An `http` `signingSecret` is still never judged at the build doors: the credential channel may supply what the run signs with. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `get_record` `limit: '{page.size}'` | `limit: 25` | refused at `config.limit` at every build door, where it registered and failed every run | + | `loop` `maxIterations: '{cap}'` | `maxIterations: 50` | the same, at `config.maxIterations` | + | `update_record` `multi: '{bulk}'` | `multi: true` | the same, at `config.multi` | + | a screen field `min: '{m}'` | `min: 1` | the same, at `config.fields.N.min` | + | `try_catch` `retry: { maxRetries: '{n}' }` | `retry: { maxRetries: 3 }` | the same, at `config.retry.maxRetries` | + | `notify` `severity: '{level}'` | `severity: 'warning'` | the same, at `config.severity` | + + **The one-line fix: write the literal the key declares.** None of these keys is evaluated per run, so no envelope replaces the token there. + + **Who is affected, measured.** A census of every builtin node literal whose `config` evaluates statically — 1010 nodes, 244 of them carrying a `{…}` token, across `examples/`, `packages/` and `apps/` in this repository at the base and hotcrm at `0f84c9c` — finds no verdict this change moves (a control node with `limit: '{x}'` moves, so the census can see one). No published docs page and no `@objectstack/lint` rule says a token at a typed key passes the build. + + ### The kit + + - **The judge.** `flowNodeConfigRefusals`'s builtin value arm (`flow-node-config-refusals.ts`): the token carve-out (`carriesInterpolationToken`) is deleted. In its place the arm asks the value-slot judge (`flowNodeValueTemplateRefusals`) and the text-slot judge (`textSlotTemplateRefusal`) which paths they refuse and exempts those, and reports a key whose contract marks its refusal `READ_AS_WRITTEN_PARAM` in that refusal's words alone. + - **ADR-0087.** No new entry: the step-18 D3 entry `flow-builtin-node-config-values-refused` and its rationale fragment now state the class this change judges. +- 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. + + +- 572ecfb: fix: `os migrate meta --stored` lists every stored row that protocol 18 refuses and no conversion rewrites. Before, the preview counted each such row as already on protocol. + + Clause-②: yes (widening: `IAutomationService.canonicalizeStoredFlow(...).todos?` in `@objectstack/spec`, `AutomationEngine.canonicalizeStoredFlow(...).todos` in `@objectstack/service-automation` and `StoredFlowCanonicalization.todos?` in `@objectstack/metadata-protocol`, each a new output member; nothing narrows, and no door accepts or refuses anything it did not before) + + Three step-18 retirements refuse a stored shape that has no lossless rewrite. Upgrading a v17 database left those rows refused at load or at boot, and the stored preview read them as `canonical`. Each shape now has exactly one fate: **refused and listed**. The preview and `--apply` print one `TODO` line per site under its row. The line names the step-18 entry that refuses the shape, the site, and the choice the owner has to make. Nothing is rewritten, and the run's verdict does not change, because no run of the pass could clear these rows. + + **#23269, a flow value slot carrying a `{…}` token, including a `get_record` / `update_record` / `delete_record` filter operand: refuse and list.** A whole-path token maps mechanically to `{ dialect: 'cel', source: '' }`, but the rewrite is not lossless. A token the template resolved to nothing (an erased condition, so the node was refused) is a CEL fault on an absent root, and a flow variable named like a query placeholder used to shadow it. ADR-0087 D2 conversions run on the load path and must be lossless, so this one is not a D2. The chain also has no operator-applied rewrite of stored rows separate from the load path: `--stored` writes back the read path's own pass, and `--from 17` rewrites authored sources, not rows. So the flow is listed by flow, node and path (`flow-value-slot-template-dialect-refused`), and `registerFlow` goes on refusing it until the value is rewritten as its CEL envelope. A flow already in the CEL form, or using a query placeholder such as `{current_user_id}`, is not listed. + + **#23270, a `controlled_by_parent` object without exactly one required `master_detail`: refuse and list, by object (`cbp-master-relation-tiers-retired`).** No shape here converts losslessly. The upgrade never picks between two masters, either both required or neither. An explicit `required: false` is a contradiction the builder refuses. Turning a required `lookup` into a `master_detail` changes what deleting the master does. An object with no master has nothing to convert. Leaving `controlled_by_parent` changes who can read the rows, so that is the owner's choice and never a silent rewrite. The listing reads the same predicate `ObjectSchema` refuses with, so the preview and the parse refuse one set. The one lossless shape, a single `master_detail` with `required` omitted, is still converted by `cbp-master-detail-required-defaulted` and is not listed. + + **#23271, a view whose `data` binds the retired view-level API source: refuse and list, by view (`view-data-api-provider-retired`).** There is no mechanical move. The replacement is a page component's `dataSource`, so a page must exist to carry it. Deleting the view's `data` would make the view query its object instead. Views in every stored body spelling are covered, and so are an object's own `listViews`. The line shows the request URL and method and withholds every other request key, so a credential stored in a view's headers never reaches the report. A view on the `object`, `value` or `schema` provider is not listed. + + How it is wired: `applyConversions` reports these shapes through its existing `onTodo` sink, after the conversions run. Each TODO's `conversionId` is the id of the D3 entry that refuses the shape, so the published `ConversionTodoNotice.conversionId` now names either a conversion or a D3 entry. The published flow-canonicalization result gains one member, a widening in three packages: + + - `@objectstack/spec`: the contract `IAutomationService.canonicalizeStoredFlow(...)` declares an optional `todos?: ConversionTodoNotice[]` beside `notices` and `conflicts`. An implementation that reports none still conforms. + - `@objectstack/service-automation`: `AutomationEngine.canonicalizeStoredFlow(...)` returns `todos`. `registerFlow` ignores them, because its own refusal names the same sites. + - `@objectstack/metadata-protocol`: `StoredFlowCanonicalization` gains the matching optional `todos?`, and the stored pass lists a flow row's TODOs the way it lists any other row's. + + The authoring funnel and the read seams pass no sink, so nothing changes for them. +- 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. +- 707b3cd: feat(cli,spec): `os migrate organization-ownership --fate-supplement FILE`. A host declares the ADR-0131 D10 fates of the objects it provides, and the ceremony plans and applies them as it does the platform's own rows + + Clause-②: yes (widening) + + The v18 organization-ownership ceremony refuses any platform-prefixed table that carries `organization_id` when its inventory gives that table no fate. That is right for the platform's own objects, but a host's objects have no row in the inventory, so every database a host runs was refused. The host now declares those fates itself, in one JSON file, and passes the file to the command. **Adoption: a host passes its supplement through the flag; with no flag, the ceremony behaves as before.** + + - **The file is declared in the spec.** `OrganizationOwnershipSupplementSchema` (`@objectstack/spec/system`) is a JSON array of `OrganizationOwnershipSupplementEntrySchema` rows. Each row gives `object`, `fate` (`column-drop`, `mirror-deletion`, `attribution` or `report`), `citation`, and only the fields its fate reads: `anchors` (required for `attribution`), `mirror`, `departures`, `categories`, `notNullExempt` and `note`. Every shape refuses an unknown key, and a key written on the wrong fate is refused rather than ignored. + - **Both modes read it.** The plan plans each row on the same terms as a platform row, and `--apply` performs it the same way. A row whose table is not on the database being planned is listed `absent`, so one supplement serves both a control-plane database and a tenant database. + - **It is refused, never half-read.** The command refuses a missing file, a file that is not JSON, a row the schema refuses, two rows for one object, or a row naming an object the platform inventory already gives a fate. The refusal comes before the database is opened, inside the `--json` envelope (`plan_refused` / `apply_refused`), and names the file, the row index and the object. A row naming one of the cloud-provided objects that the inventory lists with no fate (`cloudCarried`) replaces that placeholder row. + - **Nothing is read without the flag:** no environment variable and no conventional path. A host table with no declared fate is still refused by name, and the refusal now names `--fate-supplement FILE` as the remedy. + - The supplement's rows are part of the plan's `inventoryDigest`, so the apply's checkpoint is keyed on the supplement as well. + + ```bash + os migrate organization-ownership --fate-supplement host-fates.json --out plan.json + os migrate organization-ownership --fate-supplement host-fates.json --apply --backup-taken + ``` +- 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. +- 5e725b8: A server-side producer can store a file and attach it to a record: `IFileAttachmentService.attachFile`, registered by `StorageServicePlugin` as the kernel service `file-attachments` (`FILE_ATTACHMENT_SERVICE`), writes the bytes, one `sys_file` row and one `sys_attachment` row linking it to the parent, under the checks every other attachment of that parent meets + + Clause-②: yes (widening: `@objectstack/spec/contracts` gains `IFileAttachmentService`, `AttachFileInput`, `AttachedFile` and `FILE_ATTACHMENT_SERVICE`, and `StorageServicePlugin` registers and declares one more service) + + - **The call.** `attachFile({ parentObject, parentId, organizationId, filename, mimeType, data })` answers `{ fileId, attachmentId, size }`. `organizationId` is required: the organization the caller acts in, or `null` for none. It acts as the system, for a producer with no browser upload in front of it (inbound mail first). Reach it with `ctx.getService(FILE_ATTACHMENT_SERVICE)` once the kernel has bootstrapped. + - **What it runs.** Each check is the one the rest of the platform already runs, not a copy: + - The `max_upload_mb` storage setting, through the same verdict the REST upload doors ask. Over it, the call is refused `413 PAYLOAD_TOO_LARGE` before anything is stored. + - The parent's organization. A parent walled into another organization than `organizationId` is refused `404 RECORD_NOT_FOUND`, as a missing parent is. Both rows are written in the parent's organization, or in the caller's for a global parent. + - Every gate on the `sys_attachment` insert, among them the `enable.files` opt-in: a parent object without it is refused `403 FILES_DISABLED`. + - Before the kernel has bootstrapped, or with no data engine, the call is refused `503 SERVICE_UNAVAILABLE`. A malformed input is refused `400 VALIDATION_ERROR`. + - **No refusal leaves a committed `sys_file`.** The row is written `pending` before the bytes, and turns `committed` only in the same transaction as the `sys_attachment` insert. A refused call removes the bytes and the row before it throws. If the process dies mid-call, the pending-upload retention reclaims both. + - `@objectstack/service-storage`: the upload doors' size refusal moved from the route module into `storage-limits.ts`, beside the limit it enforces, so the doors and the verb read one status, one code and one sentence. The doors answer exactly as before. +- eeb95f7: fix(spec,objectql)!: a permission set whose body `packageId` disagrees with the package that ships it is refused at `defineStack` / `os validate` / `os build` and at package registration, and every reader takes a set's owner from its binding only (ADR-0086 D3) + + Clause-②: yes (narrowing) + + + + **BREAKING** (an accept-set narrowing on a published authoring surface), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0086 D3 declares `PermissionSetSchema.packageId` as the owning package of a package-shipped set, absent for an environment-authored one. The owner every reader uses is the binding: the package that ships the set or that it is saved into, served as `_packageId`. The metadata save, restore and publish doors already refuse a body `packageId` that disagrees with it. Package source and package registration did not: a set whose body named another package was registered under the package that shipped it, with no refusal and no warning, so an author, an AI build above all, never learned that the owner it wrote was not the owner it got. + + **What is refused, and where.** One judge, `judgePermissionSetOwnerBinding` (new export of `@objectstack/spec/security`), serves every door: + + - **The stack schema** (`ObjectStackDefinitionSchema`), so `defineStack`, `objectstack validate`, `objectstack build`, the lint scorer and an artifact load all refuse a set whose body `packageId` is not the id of the package that ships it: the stack's `manifest.id`, each `packages[]` body's own `id`, and `manifest.id` for a top-level set no body declares. A stack with no `manifest` ships its sets from no package, so any key there is refused, the empty string included. The issue sits at the set's `packageId` (`permissions.0.packageId`); `defineStack` throws `STACK_SCHEMA_INVALID` (422) carrying it. + - **Package registration** (`ObjectQL.registerApp`, the manifest and a nested plugin, for a manifest no schema parse judged) throws `400 VALIDATION_ERROR` with `issues[]` at `packageId` before the set is filed, the envelope the same seam throws for a view container whose `name` disagrees with its key. + + The message prescribes first, names both values, and offers both remedies: drop the key, or ship the set from the package it names. No error code is minted. + + **Readers.** `@objectstack/plugin-security`'s packaged-set lock (`classifyPackagedPermissionSet`) and `@objectstack/cli`'s permission-set name-collision report take a set's package from its binding only. A set filed into no package whose body named one is no longer locked as that package's, and a stack with no `manifest` declares no owner for the collision report. + + **Unchanged.** A body `packageId` equal to the package that ships the set is accepted, and an absent one is accepted everywhere. A top-level copy of a set a `packages[]` body declares (an artifact built with both halves) is judged on its body, not against the artifact's own id. The metadata save, restore and publish doors answer in the same words as before (`@objectstack/metadata-protocol` now calls the shared judge). Stored `sys_metadata` rows are not rewritten. + + ## FROM → TO + + | door | FROM | TO | + |:--|:--|:--| + | `defineStack` / `objectstack validate` / `objectstack build` | `defineStack({ manifest: { id: 'com.acme.crm', … }, permissions: [{ name: 'crm_member', packageId: 'com.acme.other', … }] })` accepted; the set was owned by `com.acme.crm` | refused at `permissions.0.packageId`, `STACK_SCHEMA_INVALID` 422 | + | `ObjectQL.registerApp` | the same manifest registered the set under `com.acme.crm`, silently | `400 VALIDATION_ERROR`, `issues[]` at `packageId`, nothing filed | + | an artifact built before this change | loaded, the set owned by the shipping package | refused at load; rebuild it without the key | + + **Remedy, in one line.** Drop `packageId` from the permission set in your stack source (the package that ships a set is its owner), or move the set into the stack of the package it names, then build again. +- d89f491: A stored flow the automation engine refuses is no longer served as running: the flow meta read serves the engine's verdict beside the item as `registration`, an envelope sibling of `lock`, and the flow list serves it as `registrations` keyed by flow name, so a refused flow reads `{ state: 'refused', reason: { code?, message } }` beside its stored `status`, which stays as authored. + + Clause-②: yes (widening) + + - **The shape** (`@objectstack/spec`). `FlowRegistrationSchema` is a closed set: `registered` (the engine holds the flow), `refused` (it refused the stored flow and holds nothing under the name; `reason` carries the engine's own `message` and, when the thrown error set one, its `code`, verbatim), and `unregistered` (it holds nothing and refused nothing: a read during boot, between a save and the engine following it, or after a deliberate unregister). `GetMetaItemResponseSchema` gains `registration` and `GetMetaItemsResponseSchema` gains `registrations`, both optional. New exports: `FlowRegistrationSchema`, `FlowRegistrationRefusalSchema` and the types `FlowRegistration` and `FlowRegistrationRefusal`. `IAutomationService` gains the optional member `getFlowRegistration(name)`. + - **Where the verdict comes from** (`@objectstack/service-automation`). The `kernel:ready` cold-boot bind records each flow it refuses, with what the engine threw, after it withdraws the flow; the `metadata:reloaded` re-sync and the metadata-change sync record a refusal that leaves the name unregistered. A later successful registration of the name, or its withdrawal, ends the record. `AutomationEngine` answers `getFlowRegistration(name)` and takes `recordFlowRefusal(name, error)`. The refusal's warn line, its level and the withdrawal are unchanged. + - **Where it is served** (`@objectstack/metadata-protocol`). The protocol reads the engine through the kernel's service table by name, as it already reads the engine's stored-flow canonicalizer; it does not import the automation service. The field is absent where no automation service declares the member, on every type other than `flow`, on a draft read and on the execution face the engine binds from: absence means no engine judged the flow, never `registered`. The verdict is the engine of the process that answered the read. + - **The default door** (`@objectstack/rest`, `@objectstack/metadata-protocol`, `@objectstack/spec`). `GET /api/v1/meta/flow/:name` on a default deployment (`metadata.enableCache: true`) is answered by the cached item branch, which now carries `registration` too. `getMetaItemCached` returns it beside `data`, read live from its delegate read on every call, and `GetMetaItemCachedResponseSchema` declares it. It is never part of the ETag, so a cached read that carries it is never answered `304 Not Modified`: a judged flow is always served in full. The cached branch's other envelope keys are unchanged. +- dc39326: fix: `os migrate meta --stored` lists the two families of stored rows it still read as on protocol: a page component whose `data` prop binds the retired view-level API source, and every other site `registerFlow` refuses in a stored flow the parse accepts. + + Clause-②: yes (widening: `ConversionTodoNotice.conversionId` in `@objectstack/spec` may now name `registerFlow`, the refusing door; `IAutomationService.canonicalizeStoredFlow(...).todos?` in `@objectstack/spec` and `AutomationEngine.canonicalizeStoredFlow(...).todos` in `@objectstack/service-automation` now also carry each site flow registration refuses; the `onTodo` sink of `applyConversions` now also receives a page component bound to the retired API source; nothing narrows, and no door accepts or refuses anything it did not before) + + **A page component bound to the retired view-level API source: refuse and list, by page and component (`view-data-api-provider-retired`).** `object-grid`, `object-map`, `object-gantt` and `object-tree` take a view data source in their `data` prop, so the step-18 retirement of the `provider: 'api'` arm reaches them through the same schema. The listing judges each stored page component with its own props row, the one the component-props gate parses, and lists it only when that row refuses the binding with the retirement's prescription; a component whose `data` is a row array, a custom type and a component's own `dataSource` binding are not listed. The line names the page, the component and its position, shows the request URL and method, and withholds every other request key. Nothing is rewritten: deleting `data` would make the component query its object instead. Left as stored, the page still loads and saves, because the page parse does not judge a component's properties, while `os validate`, `os build` and `os lint` refuse the binding in source. The D3 entry `view-data-api-provider-retired` now names these components in its scope. + + **A stored flow refused by `registerFlow` after the parse: refuse and list, by site (`registerFlow`).** The stored pass ran only one of registration's judges, the value-slot one. `canonicalizeStoredFlow` now runs registration's whole validation without registering: every check `registerFlow` runs once the parse has accepted the flow, through the one list `registerFlow` itself throws from. Each refused site is a `TODO registerFlow` line under its row, in the check's own words: a text slot still on the single-brace dialect or with a hole that does not compile, a decision's `mode`, a malformed node or edge predicate, a plugin node's undeclared config key, an `api` flow with no secret. A check added to registration later is listed the same way, with no second change. A site the chain already lists under a step-18 entry is not repeated. What the parse itself refuses, the builtin config contracts' value arm among it, is not a TODO: `canonicalizeStoredFlow` throws it, and the preview reports the row among those that could not be rewritten, in the parse's words. + + `registerFlow` refuses exactly what it refused before, with the same message: its checks now return their refusal instead of throwing it, and `registerFlow` throws the first one. The doors that canonicalize a flow without registering it (the metadata save door, a package duplication) read no `todos`, and the stored pass's run of the checks logs nothing, so they behave as before. +- 775ed65: feat(spec)!: a page action row declares `ActionSchema`'s own `operation` enum and refuses `onSuccess` beside `operation: 'update'`, with the action's text + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on two keys of 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` to the console's action runner, which reads it before any executor: `operation: 'update'` performs the declarative write through the platform action route and runs no executor of its own. The rows typed `operation` as an open value and never carried `ActionSchema`'s refusal of `onSuccess` beside `operation: 'update'`, so a row with an `api` or `script` executor, the write and a hop parsed while the same action was refused. Now: + + - a row's `operation` is `ActionSchema`'s own enum (one member, `'update'`); `'delete'` and `'custom'` get the action's "bulk-def operation with no row-level form" refusal, and any other value is refused at `operation`; + - a row's `onSuccess` beside `operation: 'update'` is refused whatever executor the row names (or none), with ONE text shared with `ActionSchema`: the hop would follow the write, whose success carries no result `${result.*}` could read. + + `ActionSchema`'s reach is unchanged. Its message for the pairing used to open "`onSuccess` is not defined for an `operation: 'update'` action yet"; it now states what the runner does (reads `operation` before any executor, writes, then hops) and keeps the prescription "Drop `onSuccess`". + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `{ actionType: 'api', operation: 'update', patch: { status: 'done' }, onSuccess: { navigate: '/done' } }` (or `type` on a member, or no executor) | the same row without `onSuccess`, with no executor key or `'script'` (beside `operation: 'update'` any other executor is refused at that key, D3 `ui-page-action-row-declarative-update-rules`): the runner performs the write before any executor, so the hop followed a write whose success declares no result | + | `{ operation: 'delete', … }` / `{ operation: 'custom', … }` on a row | no `operation`: a row delete is the object's own delete affordance, and a row is already the action a bulk def's `'custom'` would name | + | any other `operation` value | `operation: 'update'` with a `patch`, or no `operation` (the runner ran nothing for it) | + + **The one-line fix: on a page action row, write `operation` only as `'update'`, and never beside `onSuccess`.** `os migrate meta --from 17` lists the D3 entry `ui-page-action-row-operation-declared`; the rewrite is by hand. + + **What an author now sees.** `os validate`'s props gate reports the refusal at the row's `operation` or `onSuccess` (`properties.…`, or `properties.actions.N.…` for a member). For example, `{ actionType: 'api', operation: 'update', patch: { status: 'done' }, onSuccess: { navigate: '/done' } }` on an `action:button` gets: ``An `action:button`'s `onSuccess` (`operation: 'update'`) would follow a result the contract does not declare: the console's action runner reads `operation` before any executor, …``. That row is also refused at `actionType`, since beside `operation: 'update'` an executor other than `'script'` is refused (D3 `ui-page-action-row-declarative-update-rules`); without `onSuccess`, and with no `actionType` or `actionType: 'script'`, it parses. + + **Measured producers.** None in this repository. In objectui (at its pin `1fb1ced36243` and its main `b9814b3`), the action renderers' own tests carry `operation: 'update'` rows without `onSuccess`, which stay accepted; no row carries another `operation` value or the pairing. objectui's metadata-admin form already clears `onSuccess` when an author switches an action into an update. +- 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)!: a page component's `data` prop that names the retired view-level `api` data source is refused at the Studio, REST and MCP save door as `os validate` refuses it (#23357) + + **BREAKING** — a page whose `object-grid`, `object-map`, `object-gantt` or `object-tree` component carries `properties.data` with `provider: 'api'` is refused at the component's `properties.data.provider`, with the retirement's own prescription, by the component-props gate (`validateComponentProps`, `@objectstack/lint`, rule `component-props-invalid`) at `os validate`, `os build`, `os lint` and the metadata save door. 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. The page parse leaves a component's `properties` an open record, and before 18 the component-props gate ran at the CLI doors only, so a page saved through the Studio, REST or MCP kept the binding, with its request headers, and the console kept sending it. + + Clause-②: yes (narrowing) + + The judge is the retirement's own: the component's `ComponentPropsMap` row parses its `data` with `ViewDataSchema`, and the finding carries that schema's prescription unchanged, one text for a view and a page. It reaches every component under `regions` and `slots`, at any depth, a component nested in a `page:tabs` item or a container included. Only those four types meet this prescription: a component whose `data` is a row array (`object-kanban`, `object-calendar`, `object-timeline`) is judged by its own row and refused for its shape, a custom or registered type with no props row is not judged, and a component's own `dataSource` binding is untouched. + + ## FROM → TO + + | you wrote (saved by the Studio, REST and MCP door before 18) | 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.** `os validate`, `os build`, `os lint` and `PUT /api/v1/meta/page/:name` refuse the page at `pages[i].regions[j].components[k].properties.data.provider` (or the nested or slot position), rule `component-props-invalid`, with the prescription. At the save door that is `422 INVALID_METADATA`, nothing persisted. `definePage()` and `defineStack({ pages })` run no authoring rule and accept the page. + + **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 as stored, with no read diagnostic for the binding, and its next save is refused with 422 by the component-props gate. The console no longer reads the retired arm (objectstack-ai/objectui#12325), so a stored page does not send the request. + + +- 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. +- a35f7a6: feat(spec)!: `checkPageComponentViewData` is deleted; a page component's `data` prop naming the retired view-level `api` source is refused by the component-props rule alone, at the CLI doors and the metadata save door (#23447) + + **BREAKING** — `checkPageComponentViewData` is no longer exported from `@objectstack/spec/ui`, and `PageSchema` no longer carries it. Since the component-props rule (`validateComponentProps`, `@objectstack/lint`) runs on a `page` write at the metadata save door, it and the page-parse check judged the same binding at the same door: one operation with two implementations. The governed side stays. The props rule parses the component's `properties` against the type's `ComponentPropsMap` row, and that row's `data` is `ViewDataSchema`, so it raises the retirement's own prescription on the same four types (`object-grid`, `object-map`, `object-gantt`, `object-tree`), at the same positions: regions, `page:tabs` items, nested containers and slots. + + Clause-②: yes (narrowing) + + What narrows: the published export leaves `@objectstack/spec/ui`. What `PageSchema` accepts widens by this one shape, a component `data` naming the retired provider, which the props rule then refuses at every door that runs it. + + ## FROM → TO + + | you wrote | write instead | + | --- | --- | + | `import { checkPageComponentViewData } from '@objectstack/spec/ui'`, and `.superRefine(checkPageComponentViewData)` on a mirror built from `PageSchema.shape` | nothing on the mirror: a page component's `properties` is judged by `validateComponentProps` from `@objectstack/lint` (or `runAuthoringRules`), against the stack that carries the page | + | a page component's `properties.data` with `provider: 'api'` | unchanged from the step-18 retirement: `provider: 'object'` with `object`, or `provider: 'value'` with `items`; an endpoint read moves onto the component's `dataSource: { provider: 'api', read: { url: '/api/...' } }` | + + **The one-line fix:** drop the import and the re-attachment; run the props rule where you need the verdict. + + **Which doors refuse the binding now, measured:** + + | door | before | after | + | --- | --- | --- | + | `definePage()`, `defineStack({ pages })`, `PageSchema.safeParse` | refused, `custom` at `…properties.data.provider` | accepted (these doors run no lint rule) | + | `os validate`, `os build` | refused inside `defineStack` | refused by the props rule: `component-props-invalid` at `pages[0].regions[0].components[0].properties.data.provider`, with the prescription | + | metadata save door (`PUT /api/v1/meta/page/:name`, the Studio, MCP) | `422 INVALID_METADATA`, spec-parse issue `custom` | `422 INVALID_METADATA`, rule `component-props-invalid` at the same component, with the prescription; nothing stored | + | boot (`loadMetaFromDb`) | `invalid: 1`, registered under `[metadata_spec_invalid]` | `invalid: 0`, registered, no warning | + | read (`getMetaItem`) | served, `_diagnostics.valid: false` with the refusal | served as stored, `_diagnostics.valid: true` | + + **A page already stored with such a component** still loads and is served as stored, and no longer carries a read diagnostic for the binding; its next save is refused with 422 by the props rule. `os migrate meta --stored` still lists it under the step-18 entry `view-data-api-provider-retired`, and that entry's fate text now says so. + + +- 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. + + +- f606ba2: feat(spec)!: a job `retryPolicy` block whose `maxRetries` is omitted or 0 is refused, and `retry-policy-converged` leaves the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** `maxRetries` has defaulted to 0 since 17, so a `job.retryPolicy` block that omits it, or writes 0, buys no retry: the job runs once. Through 17 the loader hid that by writing the pre-17 `maxRetries: 3` and `backoffMultiplier: 2` into such a block with a notice. At 18 the ADR-0087 D2 conversion `retry-policy-converged` is retired from the load path (`retiredAfter: '17.7.0'`), and the job parse refuses the block at `retryPolicy.maxRetries`, at every door that parses an authored job (`defineStack`, `os validate`, `os build`, and `os lint` when the config calls it): "A retry block with no attempts is no retry block: state `maxRetries` ≥ 1, for example 3, or delete the block." The rule is the job's, the same one `flow.errorHandling` applies to `strategy: 'retry'`; the shared `RetryPolicySchema` is unchanged, so a `try_catch` node's `retry` region is judged as before. + + Migration: + + - `retryPolicy: {}` or `retryPolicy: { backoffMs: 5000 }` → `retryPolicy: { maxRetries: 3, backoffMs: 5000 }` (3 is the count such a block ran with before 17), or delete the block if the job should run once. + - `retryPolicy: { maxRetries: 0, … }` → delete the block: a job with no `retryPolicy` is a single attempt. + + What an author who still writes it sees: `defineStack` throws `STACK_SCHEMA_INVALID` (422) with one issue at `jobs.N.retryPolicy.maxRetries` carrying the prescription above, and `os validate` prints the same issue. Nothing is rewritten silently any more. + + Not refused: a block that states `maxRetries` and omits `backoffMultiplier`. It keeps the declared default of 1, a flat delay, where the loader used to write 2. State `backoffMultiplier: 2` to keep the old exponential curve. + + 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. For such a block the edit writes both pre-17 numbers (`maxRetries: 3`, `backoffMultiplier: 2`). The D3 entry `job-retry-policy-without-attempts-refused` states the decision left to you: keep the written count, or delete the block. + + The flow half of the conversion (`retryDelayMs` → `backoffMs` on `flow.errorHandling` and a `try_catch` node's `retry`) needed nothing more: both surfaces already refuse `retryDelayMs` with the rename to `backoffMs`. + + Stored metadata needs no edit. Stored-row rehydration, the automation engine's flow rehydration, the metadata save door's flow fallback and the artifact-ingestion door inside its window still replay the conversion, so a job row stored without a count keeps loading with the three retries it ran with. + + `@objectstack/service-job`: a comment-only change in `runWithPolicy`, whose `maxRetries` default is now reached only by a caller that builds the options by hand. + + +- 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. + + +- 105902f: feat(spec)!: retire the view form's `visibleOn` and the page component's `visibility` as keys; their ADR-0089 renames leave the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** ADR-0089 made `visibleWhen` the one conditional-visibility key, and through 17 the old spellings were declared `[DEPRECATED]` keys that the parse folded onto `visibleWhen` without a word: `visibleOn` on a form view's fields and sections, and `visibility` on a page component. At 18 all three positions carry a `retiredKey()` tombstone naming `visibleWhen`, the parse-time fold is gone, and the two ADR-0087 D2 conversions `view-visibleOn-to-visibleWhen` and `page-component-visibility-to-visibleWhen` (protocol 15) are retired from the load path (`retiredAfter: '17.7.0'`). + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `visibleOn: "…"` on a form field (`view.form.sections[].fields[]`) | `visibleWhen: "…"` | + | `visibleOn: "…"` on a form section (`view.form.sections[]`) | `visibleWhen: "…"` | + | `visibility: "…"` on a page component (`page.components[]`, slots and nested containers included) | `visibleWhen: "…"` | + + **The one-line fix:** rename the key to `visibleWhen`; the CEL predicate is unchanged. Where a node wrote both keys, `visibleWhen` was the one in effect: delete the old key. + + **What an author who still writes it sees.** `tsc` fails at a typed authoring site (`FormFieldInput`, `FormSection`, `PageComponent` type the key as retired), and the parse refuses it by name at its own path — `defineStack` throws `STACK_SCHEMA_INVALID` (422), `os validate` / `os build` print the issue, and the metadata save door answers 422: + + > `page.components[].visibility` was removed in @objectstack/spec 18 (ADR-0089 D2) — it was the page component's deprecated spelling of the conditional-visibility predicate, which the parse folded into `visibleWhen` until this release. Use `visibleWhen`. Rename the key; the value (a CEL predicate) is unchanged. Where a component writes both, `visibleWhen` is the one that was in effect: delete `visibility`. + + **No tool makes the rename.** Both conversions belong to protocol 15, below the migration floor, so `os migrate meta --from 17` applies no edit for either key and `--write` writes none: it prints each written key among the refusals the migrated stack still has, and lists the D3 entry `ui-visibility-alias-keys-retired` as the hand edit. + + **Stored metadata needs no edit.** Stored-row rehydration and the artifact-ingestion door inside its window still replay both conversions, so a `sys_metadata` view or page row written with the old key keeps loading with `visibleWhen`. A script that saves a view or page through the metadata API with the old key is refused and needs the same rename. + + ## The retirement kit + + - **Tombstones** on `FormFieldSchema`, `FormSectionSchema` and `PageComponentSchema`; `RETIRED_KEYS_BY_MAJOR[18]`: `ui/FormField:visibleOn`, `ui/FormSection:visibleOn`, `ui/PageComponent:visibility`; the authorable-surface rows read `[RETIRED]`. + - **The fold is gone.** The three shapes are plain closed objects again (no `.transform` pipe): a section's group-reference rule no longer lists `visibleOn` (the tombstone answers it first), and a slotted page node's `type` refusal now arrives at its own path instead of nested in an `invalid_union`. + - **The D3 entry `ui-visibility-alias-keys-retired`**, one for both keys. + - **No deprecation window**, per the project's startup-stage posture. + + `@objectstack/lint`: the visibility-predicate rules (`visibility-root-mislayered`, `visibility-bare-identifier`, `visibility-predicate-syntax`) and the predicate-path rules read `visibleWhen` only. `os validate`, `os build`, `os verify`, `defineStack` and the metadata save door parse first and refuse either retired key by name, so a body they judge cannot carry one. `os lint` is the one door that hands these rules an unparsed stack: there a predicate written under a retired key is not judged, by design — the parsing doors refuse the key itself, and `os lint` reports no refused shape. + + `@objectstack/metadata-core`: `detectUnboundFormViewPredicateRoots` reads `visibleWhen` only. Its caller scans an artifact after `applyArtifactForwardConversions`, which renames a legacy `visibleOn` first. + + Not part of it: `normalizeVisibleWhen` and `VISIBILITY_ALIAS_KEYS` stay exported (no schema here calls them any more); objectui's renderer reads of the old keys leave on the objectui side. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + +- f901746: feat(spec)!: the retired callout node types `http_request`, `http_call` and `webhook` are reserved inside the open flow-node namespace; `flow-node-http-callout-rename` leaves the load path + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** ADR-0018 unified the three outbound callout node types `http_request`, `http_call` and `webhook` into the one `http` node, and through 17 the loader rewrote an authored node typed with one of them to `http` with a notice. A flow node's `type` is an open namespace (a plugin registers its own node types), so nothing refused the old names. At 18 they are reserved: the flow parse refuses each as a node's `type` by name, the automation engine refuses to register a node executor under one, and the ADR-0087 D2 conversion `flow-node-http-callout-rename` (protocol 11) is retired from the load path (`retiredAfter: '17.7.0'`). A wait node's `waitEventConfig.eventType: 'webhook'` is a different field and is not affected. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | a flow node `type: 'http_request'` | `type: 'http'` | + | a flow node `type: 'http_call'` | `type: 'http'` | + | a flow node `type: 'webhook'` | `type: 'http'` | + | a plugin's `engine.registerNodeExecutor({ type: 'webhook', … })` (or either other name) | a type of your own, e.g. `'acme_webhook'` | + + **The one-line fix:** change the node's `type` to `'http'`; the node's `id`, `label` and `config` stay as they are (the conversion never touched `config`, so the `http` contract judges it as written). + + **What an author who still writes it sees.** The parse refuses the node at its own `type` path — a top-level node and a node inside a `loop`, `parallel` or `try_catch` region alike. `defineStack` throws `STACK_SCHEMA_INVALID` (422), `os validate`, `os build` and `os lint` (whose config calls `defineStack`) print the issue, and the metadata save door answers a NEW flow with `422 INVALID_METADATA` at the node's `type`, with or without an automation service, and stores nothing: + + > Flow node `type: 'http_request'` was removed in @objectstack/spec 18 (ADR-0087 D2) — it is one of the three callout node types ADR-0018 unified into `http`, the loader rewrote it to `http` until this release, and the name stays reserved inside the open node namespace so no plugin can claim it (ADR-0018). Use `type: 'http'`. Change the value; the conversion never touched the node's `config`, so the `http` contract judges it as written. + + The refusal is a `pattern` on `FlowNodeSchema.type`, so the published JSON Schema carries it too. + + **`@objectstack/service-automation`:** `AutomationEngine.registerNodeExecutor` (and `registerNodeAlias`, which calls it) throws when the executor's `type` or its descriptor's `type` is one of the three names, and registers nothing. No node could ever reach such an executor: an authored node with the name is refused at parse, and a stored one is rewritten to `http` before it parses. A plugin that registered its own executor under one of these names fails at that call and needs a type of its own. + + **No tool makes the rename.** The conversion belongs to protocol 11, below the migration floor, so `objectstack migrate meta` applies no edit for it and `--write` writes none: it prints each such node among the refusals the migrated stack still has, at `flows.N.nodes.M.type`, and lists the D3 entry `flow-node-http-callout-types-reserved` as the hand edit. + + **Stored metadata needs no edit.** Stored-row rehydration, the automation engine's flow rehydration and the artifact-ingestion door inside its window still replay the conversion, so a `sys_metadata` flow row written with an old name keeps loading as an `http` node. The rewrite there is now unconditional: until 18 it skipped a node whose old name a live executor owned and reported a conflict, and the engine no longer lets such an executor exist. + + ## The retirement kit + + - **The reserved-name table** `FLOW_RESERVED_NODE_TYPES` and its refusal `reservedFlowNodeTypeRefusal` (`@objectstack/spec/automation`), read by the flow parse, the engine's registration refusal and the conversion itself. + - **The conversion** is `retiredFromLoadPath: true`, `retiredAfter: '17.7.0'`; its ADR-0078 conflict guard is gone. + - **The D3 entry `flow-node-http-callout-types-reserved`.** + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` and `@objectstack/service-automation` are published, so this is breaking for consumers no telemetry was consulted for. + + +- 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`. + + +- a69a40a: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + feat(spec)!: a page declares nothing the console does not honour — `isDefault: true` is refused off a record page bound to an object, `template` closes to the registered layouts, and a region the template does not place is refused + + Clause-②: yes (narrowing) + + **BREAKING (authoring):** two accept sets of `PageSchema` narrow to what the console runs, and the `PageTypeSchema` text stops promising what it does not. + + | Writing | Before | Now | + | :--- | :--- | :--- | + | `isDefault: true` on a `home`, `app`, `utility` or `list` page, or on a record page with no `object` | accepted, stored and served; nothing read it | refused at `isDefault` — by `definePage`, `defineStack` (so `os validate` and `os build`, `STACK_SCHEMA_INVALID` 422), the metadata save door (`422 INVALID_METADATA`) and package registration (`SchemaRegistry.installPackage` / `registerItem`, `422 INVALID_METADATA`) | + | `template: 'full-width'` | accepted; drawn exactly as `default` | refused by name: "Page `template: 'full-width'` was removed in @objectstack/spec 18 … Use `template: 'default'`" | + | any other `template` outside `default`, `header-sidebar-main`, `three-column`, `dashboard` (`two-column`, `single-column`, …) | accepted; drawn as the page type's default layout | refused at `template` | + | on a `kind: 'full'` page, a region its template does not place (`left_sidebar`, `right_sidebar`, `content`, …) | accepted; stacked under `main` at full width, its `width` ignored | refused at `regions.N.name`, naming the template's slots and the rename | + | on a `kind: 'full'` page, a second region filling a slot an earlier one fills (two `main` regions; on `dashboard`, a second `header` or `footer`) | accepted; not drawn at all — the console draws the first region of a name and leaves that name out of the rest | refused at `regions.N.name`, naming the region that already fills the slot | + + Migration: + + - **`isDefault` on a non-record page** → delete it. A page does not choose where an app opens: an app's landing page is its **first navigation item** (by `order`), so to land on the page put its entry first in the app's `navigation`; the root landing follows the app's own `isDefault`. A record page meant as an object's default record page binds that object with `object`. + - **`template: 'full-width'`** → `template: 'default'` (or delete the key — `default` is the default). The page renders exactly as before. + - **A `template` held in a widened `string`** (a shared base object, `{ template: 'default' }` spread into `definePage`) no longer type-checks against the closed set → `template: 'default' as const`. + - **An unregistered template** → one of `default` (header, sidebar, main, aside, footer), `header-sidebar-main` (header, sidebar, main), `three-column` (header, sidebar, main, aside, footer) or `dashboard` (header, footer, and every other region a grid cell). + - **An unplaced region** → name it for the slot it fills: `left_sidebar` → `sidebar`, `right_sidebar` → `aside`, `content` → `main`; or choose `dashboard` when the regions are a grid. Names match case-insensitively, as the console's do. `slotted`, `html` and `react` pages are not judged on region names. + - **A second region for a filled slot** → move its components into the first region of that name, or name it for an unfilled slot. On `dashboard`, two regions of any other one name stay two grid cells and are accepted. + + The template set and each template's slots are now published: `PageTemplateSchema`, `PAGE_TEMPLATE_SLOTS` and `PAGE_TEMPLATE_OPEN_GRID` (`@objectstack/spec/ui`), with the two checks `checkPageDefaultPlacement` and `checkPageRegionPlacement` and the one refusal sentence `misplacedDefaultPageRefusal` that the parse and the registry share. + + `os migrate meta` lists both as hand edits — the D3 entries `page-is-default-off-record-page-refused` and `page-template-closed-region-unplaced-refused`. No D2 conversion rewrites them: where an app opens, and which slot a region fills, are the author's calls. + + **Stored metadata keeps loading.** No conversion touches these shapes and the read path does not refuse them, so a `sys_metadata` page row already stored with a non-record `isDefault: true`, `template: 'full-width'` or an unplaced region is served unchanged and renders as before (a stored row hydrates into the registry without a package, so the registry does not judge it either). Saving such a row again through the metadata API is refused until it is fixed. + + The text, without a contract change: `PageTypeSchema`'s docblock and enum comments now say that `home`, `app` and `utility` are the same region layout at three content widths — `home` is not a landing declaration, and `utility` is an ordinary inline page (the console has no floating or docked shell slot for it); `utility` stays a page type, read by the print door and the page-type width. The `print`-on-`utility` refusal no longer calls a utility page "a floating panel". + + `@objectstack/objectql`: the registry refuses `isDefault: true` on a non-record page a package registers (`MisplacedDefaultPageError`, `INVALID_METADATA` 422), at the package door and at the package-bound item seam, with the page parse's sentence. + + +- 0a052e6: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + feat(spec)!: a stack's `datasources` collection refuses a datasource named `default`, the name the host reserves for its primary datasource + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on one collection of the stack definition, shipped as `minor` under the launch-window convention for accept-set narrowings. No config that boots today is newly refused: every boot already refused this one. + + `default` is the host's primary datasource. The runtime declares and connects it itself, and the app plugin refuses a stack that declares it in every boot posture (with `OS_DATABASE_URL`, with `--dev`, and on the standalone path): + + > app 'APP_ID' declares a datasource named 'default' — that name is reserved for the host's primary datasource. + + `defineStack` and `os validate` accepted the same stack, so its author met that refusal only after the boot's earlier plugins had started. The stack's `datasources` collection now refuses the entry at authoring, located at `datasources.N.name` (the map form `datasources: { default: { … } }` is refused the same way), with the runtime's sentence and its remedy: + + > This stack declares a datasource named 'default' — that name is reserved for the host's primary datasource, and every boot refuses a stack that declares it. To give the project its own primary database, set OS_DATABASE_URL and omit this entry. To add a datasource of the stack's own, declare it under another name (e.g. 'app_primary') and route objects to it explicitly (an object's `datasource`, or a `datasourceMapping` rule). + + `DatasourceSchema` is unchanged: it also documents the host's own `default` row, which keeps parsing. + + ### FROM → TO + + | before | what to write instead | + | --- | --- | + | `datasources: [{ name: 'default', driver: 'sqlite', config: { filename } }]`, meant as the project's database | no entry; set `OS_DATABASE_URL` (e.g. `file:./data/app.db`) and the host connects it as `default` | + | the same entry, meant as a datasource of the stack's own | `datasources: [{ name: 'app_primary', … }]`, and route the objects that use it: `datasource: 'app_primary'` on the object, or a `datasourceMapping` rule naming `app_primary` | + + **The one-line fix: drop the `default` entry and set `OS_DATABASE_URL`, or rename it and route objects to it.** `os migrate meta --from 17` lists the D3 entry `stack-datasource-host-reserved-name-refused`; the rewrite is by hand, because which of the two the entry meant is the author's call. + + **Measured producers.** None in this repository (examples, `create-objectstack`, skills, docs); the one instance found was an agent-written measurement fixture. +- 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. +- ae0ca66: feat(spec)!: retire `page:header` `showStar` — the follow-star switch the reference console no longer draws (#23650) + + **BREAKING** — `showStar` on a `page:header` component (`PageHeaderProps`) is now refused. Since objectui#12427 the header's record chip draws the follow star only where the host wires a favorites toggle into the record context, and the reference console wires none: the record's favorites entry is the header's ⋯ menu. So `showStar: true` (the default) drew nothing and `showStar: false` hid nothing. Delete the key; the ⋯ menu's favorites entry is unchanged, and metadata does not control it. + + Clause-②: no (narrowing) + + Measured before removal, at `140238a47`: no producer. No example app, skill, doc or package source in this repository authors the key outside the spec's own schema and tests, and neither does any app or example in objectui at the pinned `f96f771140f1`. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `showStar: true` or `showStar: false` in a `page:header`'s `properties` | nothing — delete the key; the record's favorites entry is the header's ⋯ menu | + + **The one-line fix:** delete `showStar` from every `page:header`. + + **What an author who still writes it sees.** `tsc` fails at a typed authoring site (`PageHeaderProps` types the key as retired), and `os validate`, `os build` and `os lint` refuse it as an `error` (`component-props-invalid`) at its own path with the prescription: + + > `page:header` property `showStar` was removed in @objectstack/spec 18 (ADR-0087 D2) — the reference console draws no star beside the record title, so the key switched nothing: the record's favorites entry is the header's ⋯ menu, and metadata does not control it. Delete the key, whether it was `true` or `false`; the ⋯ menu's favorites entry is unchanged. 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. + + A page's own parse keeps its component `properties` as an open bag, so an existing page carrying the key is never refused at load. + + ## The retirement kit + + - **A `retiredKey()` tombstone** on `PageHeaderProps`, a `strictObject`, beside the `icon` and `breadcrumb` this row lost before it. `RETIRED_KEYS_BY_MAJOR[18]`: `ui/PageHeaderProps:showStar`. The authorable-surface row reads `[RETIRED]`, and the `showStar = true` default leaves the defaults baseline. + - **The D2 conversion `page-header-show-star-removed`** (protocol 18, retired from the load path) deletes the key from every `page:header` — region-level, nested in a container's `children`, and in a slotted page's named slot — `true` and `false` alike, one notice per header, as a lossless delete. It is scoped by component `type`: the same key name on another component is left alone. + - **The D3 entry `page-header-show-star-retired`** records the one case the conversion cannot decide: a host of your own that wires the favorites toggle now draws its star on every record header, a header that said `false` included. + - **No deprecation window**, per the project's startup-stage posture. + + **Not part of it:** the record context's favorites hook in the published objectui component library (`onToggleFavorite`) stays — this removes the metadata switch, not the host hook. The renderer's read of the key leaves on the objectui side. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + +- 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. +- 8776119: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + feat(spec): `referenceDeletedWhen` beside `referenceVia` marks a polymorphic pointer's target as platform-deleted + + Clause-②: yes + + `FieldSchema` gains one optional key beside `referenceVia`: `referenceDeletedWhen: { field, value }`. On rows where the sibling `select` field `field` holds the option `value`, the record the pointer names was deleted by the platform's own assertion, and a renderer draws a tombstone from that option's own label instead of a link. Example: `record_id: Field.text({ referenceVia: 'object_name', referenceDeletedWhen: { field: 'cancel_reason', value: 'record_deleted' } })`. + + Any other shape is refused at parse, naming the field and the option: + + - the key on a field that declares no `referenceVia` (`FieldSchema`), or an unknown key inside it; + - a `field` that is the declaring field itself, is not declared on the object, is not a `select`, is a `multiple: true` select, or declares no inline `options` (a `picklist`-backed select); a `value` that is not one of the sibling's option values (`ObjectSchema`, at every door: `ObjectSchema.create()`, `defineStack`, raw `.parse()` and the metadata save door). + + Nothing that parsed before is refused: the key is new. Its liveness row is `planned` until the renderer that reads it ships. + + The other packages classify the new key where every field key is classified: + + - **`@objectstack/metadata-core`.** `FIELD_REFERENCE_POSITIONS` deletes a served field's `referenceDeletedWhen` when its `field` names a field the caller cannot read (field-level security); `value` is an option value and never a field name. + - **`@objectstack/driver-sql`.** `FIELD_KEY_STORAGE_CLASS` classifies it `presentation`: no DDL reads it. + - **`@objectstack/platform-objects`.** The field form's new `referenceDeletedWhen` row carries its labels and help text in zh-CN, ja-JP and es-ES. +- 0a168a3: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + feat(spec)!: a form section's `columns` has no default — undeclared, the density is the renderer's (by available width); declared, it is the author's ceiling + + Clause-②: no + + **BREAKING:** a parsed form section without `columns` no longer carries `columns: 1`. `FormSectionSchema.columns` drops `.default(1)`: the key stays optional with values `1`–`4`, and the string spellings `'1'`–`'4'` still parse to the number. + + | A form section authored as | Parsed before | Parsed now | + |:---|:---|:---| + | `{ fields: [...] }` (no `columns`) | `columns: 1` | no `columns` key | + | `{ columns: 2, fields: [...] }` | `columns: 2` | `columns: 2` (unchanged) | + | `{ columns: '3', fields: [...] }` | `columns: 3` | `columns: 3` (unchanged) | + + The two meanings, stated on the schema's describe and docblock: + + - **Declared** — the author's ceiling: at most that many fields per row where the container is wide enough. A renderer collapses it on narrow containers. + - **Undeclared** — the renderer's choice, by available width. After parse a renderer can now tell "the author said nothing" apart from "the author asked for one column", which the old default erased. No pixel or breakpoint value enters the protocol. + + Nothing to rewrite: every input accepted before is accepted now with the same declared value, and none refused before is accepted. A section that must stay single-column on a wide container declares `columns: 1`. A consumer that reads a parsed section's `columns` treats an absent key as "the renderer decides" rather than as `1`. `FormViewSchema.columns` (the form body) was already optional with no default and is unchanged. + + +- 97b8e7a: `record:json` is a declared page component type — one key of the bound record drawn as indented JSON behind a required, fail-closed `requiredPermissions` gate — with a strict `ComponentPropsMap` row + + Clause-②: yes + + - **New `PageComponentType` member:** `record:json`, in the reserved `record` namespace. One node draws the value under one key of the bound record as indented JSON, in a panel that starts collapsed, with a copy control. Declared spec first: objectui draws it and `@objectstack/plugin-approvals` places it on the approval request page after this release. + - **Its `ComponentPropsMap` row (`RecordJsonProps`) is strict and has three keys:** + - `field` (required) — the key to draw, in the snake_case machine-name grammar. It may be a declared field or a key the record's read route serves beside the declared fields (`payload` on a `sys_approval_request` row), so it is not checked against the object's fields: a misspelled `field` draws nothing, and no publish check reports it. + - `label` (optional) — the collapsed panel's summary line, a string or an inline locale map. + - `requiredPermissions` (required, at least one non-empty name) — capabilities the user must all hold. It fails CLOSED: a client that cannot resolve the user's capabilities draws nothing, and so does a user missing any of them. Presentation only — it authorises nothing, and the read route still serves the value. + - **The record blocks' own `requiredPermissions` is unchanged.** On `record:details`, `record:highlights`, `record:related_list` and `record:quick_actions` it stays optional and fail-open; the same key name on `record:json` is a separate declaration with its own describe. + - **Refusals.** Any other key in `properties` is refused by name (`component-props-unknown-key` at `os validate` / `os build` / `os lint`); a node with no `field` or no `requiredPermissions`, or with `requiredPermissions: []`, is refused at parse. Inside a page that declares `print`, the type is refused with its own reason (a collapsed panel behind a per-viewer gate). + - **Consumers.** A consumer that derives coverage from `PageComponentType.options` or `ComponentPropsMap` — a designer palette, a renderer registry — sees one more member to classify. +- 9cb9d95: feat(spec,plugin-auth): a short-lived principal-session mint, `IAuthService.mintPrincipalSession`, implemented by `AuthManager` + + Clause-②: yes (widening) + + An in-process service can now act as the requesting user through a door that only accepts a session, such as a headless browser rendering a page the user may see, under the user's own principal and never a system one. Nothing is removed and no HTTP route is added. + + - **`@objectstack/spec`** (`contracts`): an optional `IAuthService` member, `mintPrincipalSession(context, options?)`, its types `PrincipalSession`, `PrincipalSessionCookie` and `PrincipalSessionMintOptions`, and the constant `PRINCIPAL_SESSION_MAX_TTL_SECONDS` (`120`). The member is optional, so every existing `IAuthService` implementation compiles unchanged. + - **`@objectstack/plugin-auth`**: `AuthManager.mintPrincipalSession(context, options?)`. + + What the mint guarantees: + + - **Human principals only, refused before anything is minted.** A context with no user, a system context, or a `principalKind` other than `human` gets `PERMISSION_DENIED` / `403`. So does a user row that is missing, the system user or banned. A user an authentication policy holds gets `403` with the policy's own code (`MFA_REQUIRED`, `PASSWORD_EXPIRED`, `PASSWORD_RESET_REQUIRED`), whether the posture rides on the context or is computed at the call. A `ttlSeconds` outside 1 to 120 gets `VALIDATION_ERROR` / `400`: it is refused, never clamped. + - **Short and never renewed.** The session lives at most 120 seconds. Reading it never extends it, on the cookie lane, the bearer lane or the in-process session read alike. + - **Revocable.** `revoke()` ends the session. Afterwards neither the token nor the cookie resolves one. It is bound to that session alone and safe to call twice, so call it in a `finally`. + - **The deployment's own cookie.** The cookie carries the name and attributes of the plugin's session cookie, the cookie prefix and, on a secure deployment, `__Secure-` included. The session's active organization is the context's `tenantId`. + + ```ts + const auth = ctx.getService('auth'); + const session = await auth.mintPrincipalSession?.(context, { ttlSeconds: 60 }); + try { + // send session.token as `Authorization: Bearer`, or set session.cookie in a browser + } finally { + await session?.revoke(); + } + ``` +- adad73f: feat(spec)!: a select option declares its lifecycle `kind` (`open` / `won` / `lost`), and every option of an object's `stageField` must declare it + + Clause-②: yes (narrowing) + + `SelectOptionSchema` gains one optional key, `kind`, a closed vocabulary of three lifecycle categories that means the same on every object: `open` (a step on the way), `won` (the successful end: a deal's Closed Won, a lead's Converted, a task's Done, a case's Resolved) and `lost` (the unsuccessful end: Closed Lost, Unqualified, Cancelled). It is the spelling `record:path` `stages[].terminal` already takes, so the data layer and the page say one thing one way. Example: `{ label: 'Closed Lost', value: 'closed_lost', kind: 'lost' }`. The exported type `SelectOptionKind` names the three values. + + - On a plain single-value `select` / `radio` the key is optional, and an absent kind reads as `open`. Nothing is written in at parse: an option without `kind` parses without it. + - On any other field's options (`multiselect`, `checkboxes`, `tags`, a `multiple: true` select, a lookup's static options) `FieldSchema` refuses it at `options..kind`: a lifecycle is one value per record. + - A shared `picklist`'s options accept it (the same option shape). A form view's option list does not (`FormSelectOptionSchema`): it is refused there as an unknown key whose message points at the object field's own option. + + **BREAKING (authoring):** when an object's `stageField` names a field, every option of that field must declare `kind`. `ObjectSchema` refuses the object otherwise, at every door that parses one (`ObjectSchema.create()`, `defineStack`, `os validate`, `os build`, the metadata save door), with one issue per option at `fields..options..kind` naming the field and the option. There is no default under `stageField`. Also refused, at `stageField`: a stage field that takes its options from a `picklist` (the object cannot see them to check), and a stage field carrying options that holds no single option code per record (`multiselect`, a `multiple: true` select). Unchanged: `stageField: false`, a `stageField` naming no declared field, and one naming a field with no options. + + Migration: add `kind` to each option of the stage field, marking each terminal state `won` or `lost` and every other state `open`: + + - `status: Field.select({ options: [{ label: 'New', value: 'new' }, { label: 'Converted', value: 'converted' }, { label: 'Unqualified', value: 'unqualified' }] })` with `stageField: 'status'` → the same options with `kind: 'open'`, `kind: 'won'` and `kind: 'lost'`. + - A picklist-backed stage field → declare its options inline on the field. + - An object with no linear lifecycle → `stageField: false`. + + No conversion makes this edit, because which option ends a record's life successfully is the author's to say. `os migrate meta` lists the D3 entry `stage-field-option-kind-required` with the prescription. + + The Studio object designer's options repeater offers `kind`; the field form's options repeater lists it. + + The other packages follow the new key: + + - **`@objectstack/platform-objects`.** The metadata-form translation bundles (`./metadata-translations`) carry the options repeater's new `kind` row, with its label and help text in zh-CN, ja-JP and es-ES. + - **`@objectstack/metadata-core`.** The published contract fixture `FLS_CONTRACT_OBJECT` (`@objectstack/metadata-core/testing`) names `salary_grade` as its `stageField`, so its two options now declare `kind: 'open'`, and the fixture meets the stage-field rule: `ObjectSchema` raises no issue at `stageField` or under `fields.salary_grade.options`. The fixture is not otherwise a spec-valid object (its `script` actions carry no `body` or `target`), and this change leaves that as it was. + + +- 6422da5: feat(spec): `FlowRuntimeState` gains `unboundCause`, a closed classifier beside `reason` + + Clause-②: yes (widening) + + `FlowRuntimeState.reason` is a sentence a consumer renders and must not parse, so until now a console could not tell a flow left unarmed by deployment policy from one whose binding is broken: both arrive as `enabled: true, bound: false` with a `reason`. The row now also carries an optional `unboundCause`, present on exactly the rows that carry `reason` and absent on every other row. + + Its values are published as `FLOW_UNBOUND_CAUSES` (`@objectstack/spec/contracts`), with the derived type `FlowUnboundCause`: + + - `deployment_policy`: package-authored scheduled work is switched off on this deployment. An operator remedy, not a defect. + - `disabled_subflow`: the flow calls a packaged subflow that is disabled (ADR-0126 §7.3). Enable the subflow and the caller arms. + - `missing_trigger`: no trigger of the declared type is registered. Depend on the trigger package (`requires: ['triggers']`). + - `binding_failure`: the trigger is registered but binding the flow to it failed. The earlier boot warnings name the error. + + Additive: existing producers and consumers keep working unchanged, and `reason` keeps its text. +- dd11563: Clause-②: yes (widening) + + The platform translation bundle (`PlatformTranslationDataSchema`) gains a slot for a settings + `info_banner` row: `settings..banners..title` and `.text`, keyed by the + specifier's `id`, beside `groups`, `keys` and `actions`. `title` translates the specifier's `label` + (the heading spelling `groups` already uses) and `text` translates its `bannerText` (markdown). + Before this, a translated banner was refused with `unrecognized_keys`, so a banner reached every + locale in its manifest English. The slot is strict: a borrowed `label`, `bannerText` or + `description` leaf, or a singular `banner` group, is refused with the spelling it meant. + + Two resolvers read it, `resolveSettingsBannerTitle` and `resolveSettingsBannerText`, from the + `resolveSettings*` family: they walk the requested locale and the declared fallback chain, then + return the manifest literal. The `info_banner` specifier's own accept set is unchanged. Give the + banner an `id` to make it addressable; a banner without one keeps rendering its literals. +- 2c702fd: feat(spec)!: `@objectstack/spec/shared` no longer exports `normalizeVisibleWhen` or `VISIBILITY_ALIAS_KEYS`; read `visibleWhen` directly + + Clause-②: yes (narrowing) + + **BREAKING (TS/API surface):** two exports of `@objectstack/spec/shared` are gone — the function `normalizeVisibleWhen`, which folded the retired `visibleOn` / `visibility` keys onto the canonical `visibleWhen`, and the const `VISIBILITY_ALIAS_KEYS`, which listed those two keys. Use `visibleWhen` directly instead: it is the one conditional-visibility key, and since 18 every schema that had the two old keys (the form field, the form section and the page component) refuses them by name. No schema used the fold after those keys retired, and no caller of either export was found in this repository or in the pinned renderer. + + ## FROM → TO + + | you wrote | write instead | + | --- | --- | + | `import { normalizeVisibleWhen } from '@objectstack/spec/shared'`, then `normalizeVisibleWhen(node)` | read and write `node.visibleWhen`; there is no alias left to fold | + | `import { VISIBILITY_ALIAS_KEYS } from '@objectstack/spec/shared'` | nothing — the two keys it named are refused by the schemas; to reject them yourself, parse with the schema | + + **The one-line fix:** delete the import and the call, and read `visibleWhen`. + + This supersedes the sentence in this release's `visibleOn` / `visibility` retirement note that says the two exports "stay exported": they leave in the same release. + + **What an author who still imports them sees:** `tsc` fails with TS2305 (`Module '"@objectstack/spec/shared"' has no exported member …`); an ESM named import fails at link time with a `SyntaxError` (the module does not provide an export of that name); a CommonJS `require` reads the member as `undefined`. + + **Nothing to migrate in metadata.** No authored document ever carried either export, so `os migrate meta` has nothing to visit. The stored-row conversions `view-visibleOn-to-visibleWhen` and `page-component-visibility-to-visibleWhen` are unchanged: a pre-18 `sys_metadata` view or page row that still carries an old key keeps loading with `visibleWhen`. + + +- bbdfc05: feat(spec)!: retire the permission set's authored `managedBy` — a provenance key nothing read (#23891) + + **BREAKING** — an author can no longer write `managedBy` (`'package' | 'platform' | 'user'`) on a permission set (`PermissionSetSchema`, ADR-0086 D3): it is refused at parse. Nothing ever read the authored key. No seeder, package upgrade or lock consulted it, so `package` never made a set upgrade-owned, and `platform` or `user` never kept an upgrade off a set. Delete the key. A set's provenance is the package that ships it, decided from that package's artifact and recorded as `packageId`; a set created in this environment carries neither. The `sys_permission_set.managed_by` column is a different surface and is untouched, as is `ObjectSchema.managedBy`. + + Clause-②: yes (narrowing) + + Measured before removal, at `6d1660dc3b`: no shipped permission set authored the key (the example apps, `plugin-security`'s default sets, and HotCRM 4.0.1's seven sets at `1d7148bf2d`: zero, against seven `objects:` controls). Its one writer was Setup's Permission Sets form row, which wrote it into stored rows. The liveness ledger had ruled it `dead` on 2026-10-11. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `definePermissionSet({ …, managedBy: 'package' })` | nothing: delete the key; a package-shipped set is the package's because the package ships it (`packageId`) | + | `managedBy: 'platform'` or `managedBy: 'user'` | nothing: delete the key; a set created in this environment carries neither `managedBy` nor `packageId` | + | relying on `managedBy` to keep a package upgrade off a set | clone the packaged set and edit the clone (the packaged-set lock refuses in-place edits) | + + **The one-line fix:** delete `managedBy` from every permission set. + + **What an author who still writes it sees.** `tsc` fails at the authoring site (`PermissionSet` types the key as retired), and the parse (`definePermissionSet()`, `defineStack({ permissions })`, `PUT /api/v1/meta/permission/:name`) refuses it at its own path with the prescription: + + > `permission.managedBy` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove, ADR-0086 D3) — nothing ever read the authored key: no seeder, package upgrade or lock consulted it, so `package` never made a set upgrade-owned and `platform` or `user` never shielded one from an upgrade. Delete the key; a set's provenance is the package that ships it, decided from that package's artifact and recorded as `packageId`, and a set created in this environment carries neither. 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. + + **What does not stop on it.** A stored `permission` row that Setup's form wrote, and an installed package's built artifact, still load: the rehydration seams (`applyConversionsToStoredItem`, `applyArtifactForwardConversions`) replay the D2 conversion below and strip the key, with a notice naming the set. Only the authoring, save and publish doors refuse it. + + ## The retirement kit + + - **A `retiredKey()` tombstone** on `PermissionSetSchema`, a `strictObject`, so the refusal carries its prescription and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `security/PermissionSet:managedBy`. + - **The D2 conversion `permission-set-managed-by-removed`** (protocol 18, retired from the load path) deletes the key from every `permissions[]` item, whatever it holds, one notice per set. + - **The D3 entry `permission-set-managed-by-retired`** records the judgement the strip cannot make: any runbook that relied on the value to keep an upgrade off a set needs another path. + - **`@objectstack/platform-objects`:** the Permission Sets form's `managedBy` row, its help text and its three option labels (Package / Platform / User) leave Setup in all four locales. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED** beyond HotCRM 4.0.1. `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. Studio's provenance badge reads the value as one of three inputs; it retires in an objectui follow-up. + + +- d050a8f: fix(spec,objectql)!: an installed or stored page carrying a declaration spec 18 retires converts at load instead of stopping the kernel build — `isDefault` off a bound record page is deleted and `template: 'full-width'` re-spelled `default`, each with a named notice; the authoring, save and publish doors keep refusing + + Clause-②: yes (widening) + + **What changes.** Protocol 18 refused `isDefault: true` on a page that is not a record page bound to an object, and closed the page `template` set (retiring `full-width`), at every door — the page parse (`definePage`, `defineStack`, so `os validate` / `os build`, and the metadata save door) and the kernel's package registration (`SchemaRegistry.installPackage` / `registerItem`, `MisplacedDefaultPageError`). The registration refusal also fired when a kernel was built from a package that was installed BEFORE the retirement: every hosted environment holding such a package failed its kernel build, with no remedy short of upgrading the package in each one. Data at rest now converts, and only data at rest: + + | Door | Before | Now | + | :--- | :--- | :--- | + | `definePage` / `defineStack` (`os validate`, `os build`), the metadata save door, package publish | refused at `isDefault` / `template` | **unchanged** — refused with the same sentence | + | a `sys_metadata` page row read back | served with `_diagnostics.valid: false` | converted on read: the flag deleted, `full-width` → `default`, `OS_METADATA_CONVERTED` notice | + | a compiled artifact inside the window its `engines.protocol` opens | the artifact door refused the artifact | converted before the strict parse, as every retired entry is | + | a package body registered through `manifest.register` / `ObjectQL.registerApp` that declares `engines.protocol` (an installed package, a marketplace install) | `MisplacedDefaultPageError`, kernel build stopped | converted before anything registers, with one `warn` per (package, conversion) naming the conversion id and the first site; the body is left unchanged on disk | + | the same body with NO declared range (a manifest a plugin builds in code), or declaring a floor newer than every retirement | refused | **unchanged** — refused | + + **The two ADR-0087 D2 entries**, both `retiredFromLoadPath` from the day they land (the authoring funnel never replays them): + + - `page-is-default-off-record-page-removed` (`page.isDefault`): deletes `isDefault: true` from exactly the pages `misplacedDefaultPageRefusal` refuses — a `home`, `app`, `utility` or `list` page, or a record page with no `object`. Lossless: nothing read the flag there (an app opens on its first navigation item). A default record page bound to an object keeps its flag. + - `page-template-full-width-to-default` (`page.template`): `'full-width'` → `'default'`. Lossless: the console drew both names with one template component. An unregistered template name (`centered`, `two-column`) and a region the template does not place have no lossless rewrite and are NOT converted — they stay with the parse, and the D3 entries `page-is-default-off-record-page-refused` / `page-template-closed-region-unplaced-refused` name the hand edits. + + **`@objectstack/objectql`** — `ObjectQL.registerApp` (and the `manifest` service, for an artifact carrying `packages[]`) replays the versioned forward conversion (`applyArtifactForwardConversions`, `@objectstack/metadata-core`) over a package body that declares its protocol range, before `installPackage`; the converted copy is what the package record and every registered item carry. Public: `ObjectQL.convertPackageBodyForward(body)`. + + **`@objectstack/metadata-core`** — `applyArtifactForwardConversions` reads the declared range off a FLAT package body (the manifest half beside its collections, no `manifest` key) when the definition carries no `manifest`; a definition with a `manifest` object is read there, as before. + + Migration: none for authors — the authoring doors refuse exactly what they refused. An installed package converts again at every registration until it is rebuilt (`os build`) and re-published with tooling from a release that ships the retirement; the boot log's warn names the conversion and the site. + + +- d731b89: fix(identity): the Invite User dialog offers only the roles the caller may grant, read from the same table the invitation role cap enforces + + Clause-②: yes (widening) + + **What was wrong.** A `delegated_admin` who opened Invite User was offered Owner, Admin and Delegated Admin. The server's invitation role cap refuses all three for that issuer. The `role` param inherited one static option list from the field, and nothing told the client which roles this caller may grant. + + **What is new in `@objectstack/spec` (`identity`).** One table, `INVITATION_ROLE_GRANTORS`, says which issuer grades may invite as each membership role: + + | role | issuers that may grant it | + |---|---| + | `owner` | `owner` | + | `admin`, `delegated_admin` | `owner`, `admin` | + | `member` | any issuer (`INVITATION_ROLE_ANY_ISSUER`) | + + New exports beside it: `invitationRoleGrantors(role)` (a role the closed vocabulary does not list takes the `owner`, `admin` row), `invitationRoleGrantPredicate(role)` (the CEL gate, one `'' in current_user.positions` term per grade, `undefined` for an any-issuer row), `INVITATION_ROLE_OPTIONS` (the role picker's options, each carrying its gate as `visibleWhen`), and the types `InvitationRoleGrantors` and `InvitationRoleOption`. + + **`@objectstack/plugin-auth`.** The invitation role cap now reads that table to decide which invitations it admits. It refuses exactly what it refused before, with the same refusal text. A pin compares it with the previous implementation on 1,517 request and issuer pairs. + + **`@objectstack/platform-objects`.** The `role` param of the three `invite_user` actions (`sys_user`, `sys_member`, `sys_invitation`) declares `INVITATION_ROLE_OPTIONS` itself instead of inheriting the field's list. The option values and English labels are unchanged. A `delegated_admin` is now offered Member only, an admin Admin, Delegated Admin and Member, and an owner all four. The per-option gate is a UI courtesy; the server cap remains the authority. Because the options are declared on the param, their labels now translate under `_actions.invite_user.params.role.options.`, and the bundles carry those keys in every locale. +- 66d2757: feat(spec)!: every installed-package row declares where it came from, a closed `source: 'code' | 'marketplace' | 'authored'`, and the `GET /packages` rows declare the `writable` verdict they already served (#23972) + + Clause-②: yes (narrowing — the served row gains two declared keys, and the record schema, the served-row union and the registry's installPackage signature each refuse what they accepted before) + + **BREAKING** — three published accept sets narrow. `InstalledPackageSchema` refuses a row without `source`, or with a value outside `code` / `marketplace` / `authored`. The served-row union `InstalledPackageAtEitherStageSchema` refuses a row without a boolean `writable`. `SchemaRegistry.installPackage` and `EngineSchemaRegistryView.installPackage` take `source` as their second argument and refuse a call without it. The migration is below. None of it is mechanically convertible, because only the door that registers a package knows where it came from. + + **What is new on the wire.** `GET /api/v1/packages` and `GET /api/v1/packages/:id` serve two declared keys on every row: + + - `source` names the package's origin. `code` means the deployment's own code registered it (a `defineStack()` host, a plugin's bundled manifest, a platform package). `marketplace` means the marketplace install-local plugin installed it, and that holds after a restart too, when the plugin rehydrates it from its ledger. `authored` means it is a database-authored package (a Studio base, a duplicate, an HTTP install), including after the restart that rehydrates it from `sys_packages`. The door that registers the package stamps the value. No reader derives it. + - `writable` is the server's own writability verdict (ADR-0070 D2), served on these rows since #14375 and now declared. It is computed per read and never stored, so it is declared on the served row (`InstalledPackageAtEitherStageSchema`) and not on the registry record. + + FROM → TO for a reader of these rows: + + | you did | do instead | + | --- | --- | + | treated `writable: true` as authored, and looked up each non-writable row in the marketplace's installed list (the control plane's, else install-local's) to tell marketplace from code | read `row.source` | + | read the undeclared `writable` | read the declared `writable` (unchanged value) | + + The two keys are separate statements: `source` says where the package came from, and `writable` says what this server lets an org do to it. + + **`InstalledPackageSchema` requires `source`.** It is `InstalledPackageSourceSchema`, exported from `@objectstack/spec/kernel`. A row without it, or with a value outside the closed set, is refused at the `source` key. + + **API change for code that registers packages (`@objectstack/objectql`).** The registry records the origin and never infers it: + + | you wrote | write instead | + | --- | --- | + | `registry.installPackage(manifest)` | `registry.installPackage(manifest, 'code')` (or `'marketplace'` / `'authored'`, whichever is where the package came from) | + | `registry.installPackage(manifest, settings, scope)` | `registry.installPackage(manifest, source, settings, scope)` | + | `ctx.getService('manifest').register(artifact)` for a marketplace install | `ctx.getService('manifest').register(artifact, { source: 'marketplace' })` | + + `SchemaRegistry.installPackage` throws a `TypeError` naming the closed set when `source` is missing or unknown, before anything is recorded. `ObjectQL.registerApp(body, scope?, source?)` and the `manifest` service record `'code'` when no source is given. They accept `'code'` or `'marketplace'` and refuse `'authored'`. The `IObjectQLEngine` registry contract (`@objectstack/spec/contracts`) declares the same `installPackage(manifest, source, settings?)`. + + **The one-line fix:** pass the package's origin as `installPackage`'s second argument. + + Readers: objectui's Setup → Packages Source column and its `?source=` filter (it switches from the two-read join to `source` in its own follow-up), and the client SDK's `packages.list()` / `packages.get()` row types. + + +- a65a59b: feat(spec)!: the ADR-0078 conversion-conflict channel is retired — `CONVERSION_CONFLICT_CODE`, `ConversionConflictNotice`, `ConversionConflictDetail`, the `onConflict` / `reservedNodeTypes` conversion options, the `conflicts` arrays and the `FLOW_CONVERSION_CONFLICT` 409 + + Clause-②: yes (narrowing) + + **BREAKING (published API):** the ADR-0087 conversion API carried a channel for a rename REFUSED because its old token was a live name in the environment: a conversion reported it through `ConversionContext.reportConflict`, `applyConversions` handed it to an `onConflict` sink as a `ConversionConflictNotice` (code `OS_METADATA_CONVERSION_CONFLICT`), the automation engine collected it in `canonicalizeStoredFlow`'s `conflicts` array, and the metadata protocol answered it with `409 FLOW_CONVERSION_CONFLICT`. Its only producer was the live-executor guard inside the flow-node HTTP callout rename. Protocol 18 reserved those three names (`FLOW_RESERVED_NODE_TYPES`), so the engine refuses an executor under one, no live owner can exist, and the guard went. Nothing can raise a conversion conflict any more, so the channel leaves the published surface rather than staying as a live-looking failure mode nothing triggers. + + ## FROM → TO + + | you wrote | write instead | + | --- | --- | + | `import { CONVERSION_CONFLICT_CODE, type ConversionConflictNotice, type ConversionConflictDetail } from '@objectstack/spec'` | delete the import — no replacement | + | `applyConversions(stack, { onConflict, reservedNodeTypes, … })` (likewise `applyConversionsToFlow`, `applyConversionsToStoredItem`, `collectConversionNotices`) | delete both options; keep `onNotice` / `onTodo` | + | a conversion's `apply` reading `context.reservedNodeTypes` or calling `context.reportConflict` | a rename over an open namespace reserves its old names instead (the `FLOW_RESERVED_NODE_TYPES` pattern), so the refusal happens at registration; a site with no lossless rewrite goes to `context.reportTodo` | + | an `IAutomationService.canonicalizeStoredFlow` implementation returning `conflicts: […]` (or a `StoredFlowCanonicalization` from `@objectstack/metadata-protocol` carrying one) | drop the member; return `parsed`, `storable`, `notices` and optionally `todos` | + | a client branch on `error.code === 'FLOW_CONVERSION_CONFLICT'` / HTTP 409 from a flow save, a package duplication or an unmanaged package import | delete the branch — no request can produce it | + + **The one-line fix:** delete every reference; the branch it guarded was already dead, so deleting it changes no behaviour. + + **What an upgrader sees.** Importing a removed name is `TS2305`; passing a removed option, or returning a `conflicts` member from a typed canonicalizer, is an excess-property `tsc` error. `ErrorCode` no longer parses `FLOW_CONVERSION_CONFLICT`. + + **Runtime behaviour is unchanged.** The removed branches could not fire: a stored flow carrying a reserved callout name still loads as `http` (the rewrite has been unconditional since the names were reserved), and `saveMetaItem`, `duplicatePackage`, `importUnmanagedPackage` and `os migrate meta --stored` answer exactly as before. No metadata document needs editing. + + ## The retirement kit + + - **`@objectstack/spec`:** the three exports leave the root; `ApplyConversionsOptions` loses `onConflict` and `reservedNodeTypes`, `ConversionContext` loses `reservedNodeTypes` and `reportConflict`, the `IAutomationService.canonicalizeStoredFlow` result loses `conflicts`; the error-code ledger drops `FLOW_CONVERSION_CONFLICT` by its own "Retiring a code" rule (a row comes out with its last emitter). + - **`@objectstack/service-automation`:** `AutomationEngine.canonicalizeStoredFlow` no longer returns `conflicts`, and the engine no longer builds a live-name set for the conversion pass. + - **`@objectstack/metadata-protocol`:** `StoredFlowCanonicalization` loses `conflicts`; the flow save door's 409, the stored-migration pass's `failed` row for a refused rename, `duplicatePackage`'s failed item and `importUnmanagedPackage`'s 409 are gone with it. + - **The D3 entry `conversion-conflict-channel-removed`.** No D2 conversion and no tombstone: no metadata document ever carried any of it. + - **No deprecation window**, per the project's startup-stage posture. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** The three packages are published, so this is breaking for consumers no telemetry was consulted for. + + +- eda6f14: feat(spec)!: an install through `POST /api/v1/packages` may declare `manifest.scope` `project` only; `cloud` and `system` are refused, so an authored install can no longer lock itself + + `cloud` and `system` are the scopes of packages the platform ships, and the ADR-0070 D2 read-only rule (`isWritablePackage`) judges a registered package by its stored `manifest.scope`. An install through `POST /api/v1/packages` authors the package, yet the install declaration admitted all three scopes. So a manifest that copied `scope: 'cloud'` or `'system'` from a shipped package answered `201` and produced an authored package that `DELETE /packages/:id`, `PATCH /packages/:id/disable` and `POST /packages?overwrite=true` then refused with `422 WRITABLE_PACKAGE_REQUIRED`, and the stored row came back on every restart. The install declaration now narrows the install manifest's `scope` to `project` (new export `PackageInstallManifestSchema`, `ManifestSchema` with that one key narrowed), on both body forms of `PackageInstallBodySchema` and on `PackageInstallRequestSchema.manifest`. `ManifestSchema` itself is unchanged: platform packages still declare all three scopes, and they reach the registry by code boot, not over this door. The door already parses its body through that declaration, so it answers the refusal with no rule of its own; its bare-form refusal sentence now re-reads the union's own bare branch, so it carries the prescription too. + + Clause-②: no (narrowing) + + + + **BREAKING** (a published request declaration's accept set narrows), shipped as `minor` under the launch-window convention for breaking changes. + + - **FROM:** `POST /api/v1/packages` with a manifest declaring `scope: 'cloud'` or `scope: 'system'`, in the wrapped body `{ manifest }` or as a bare manifest, with or without `overwrite`, answered `201`; `PackageInstallBodySchema` and `PackageInstallRequestSchema` parsed it green. + - **TO:** the same request answers `400` with `error.code: 'VALIDATION_ERROR'`, names `scope` and the value sent, and installs nothing; both schemas refuse it with an `invalid_value` issue at `manifest.scope` (wrapped) or `scope` (bare). + - **Unchanged:** a manifest with `scope: 'project'` or no `scope` installs as before (`201`), and the door still stores the manifest as sent, so an absent `scope` stays absent. `ManifestSchema` and every other producer of it keep `cloud` / `system`. + - **The fix for an integrator:** remove `scope` from the manifest you install (it defaults to `project`), or write `scope: 'project'`. + - **A package that already locked itself this way** (an authored `sys_packages` row whose manifest declares `cloud` or `system`) stays read-only after this change, because the read-only rule reads the stored scope. No API door removes it; an operator deletes its row from the environment's database (`DELETE FROM sys_packages WHERE id = 'PACKAGE_ID'`) and restarts the server. +- 45e6001: feat(spec): register the invitation role cap's three refusal codes under `@objectstack/plugin-auth` + + Clause-②: yes (widening) + + The error-code ledger (`ERROR_CODE_LEDGER`, and so `ErrorCode`) gains three `@objectstack/plugin-auth` codes, one per refusal the invitation role cap can return. Today the cap answers all three as a bare `403 FORBIDDEN`, so a client such as the Invite User dialog cannot tell them apart: + + - `INVITE_ROLE_ABOVE_ISSUER` — the requested role grades above the issuer's own; an invitation never confers authority above its issuer. + - `INVITE_ISSUER_MEMBER_ONLY` — an issuer below admin grade may invite only as a plain `member`; the requested role may carry capability of its own. + - `INVITE_ISSUER_UNVERIFIED` — fail-closed: the issuer's organization membership could not be established, so the invitation may confer nothing beyond `member`. + + Registration only: `@objectstack/plugin-auth` still throws `FORBIDDEN` until #23945 stamps these codes on its refusals. No existing code changes, and no standard-catalog member is shadowed (none needs a synonym waiver). +- 9f024d8: feat(spec): a solution blueprint view declares `filters` and `sort`, so a view named by a condition can carry that condition + + Clause-②: yes (widening) + + `BlueprintViewSchema` and the strict structured-output mirror `SolutionBlueprintStrictSchema.views[]` gain two keys. Before this, a blueprint could say "overdue only" in a view's name and nowhere else, and the built view showed every record. + + - **`filters`** — which records the view shows, as list-view filter rules `[{ field, operator, value }]`, ANDed. + - Lenient (`BlueprintViewSchema`): optional, and the array element IS `ViewFilterRuleSchema` (the rule `ListView.filter` declares), so it takes the same operators, folds the same legacy spellings, and refuses the same operator/value-shape mismatches (`in` without an array, `between` without `[min, max]`, `icontains` without a non-empty string). + - Strict (`SolutionBlueprintStrictSchema`): required and nullable (null = a view over every record). Each rule carries the same three keys, every one present: `operator` is the canonical vocabulary only (`VIEW_FILTER_OPERATORS`, no legacy shorthand such as `eq`), and `value` is nullable (null for the unary operators). The operator/value-shape coupling is not in the strict mirror's JSON Schema; the lenient parse refuses it. + - **`sort`** — the default record order, `[{ field, order }]` with `order` `asc` or `desc`, the first entry sorting first. Lenient: optional, the element is the shared `SortItemSchema` (the `{ field, order }` pair `ListView.sort` declares; array only, a `"field desc"` string is refused). Strict: required and nullable, `{ field, order }` with both keys present. + - Both describes tell the model that a view whose name or label states a condition ("overdue", "my open") must carry the matching filter, with worked rules using the platform tokens `{today}` and `{current_user_id}`. + - Additive only: every blueprint that parsed before still parses with the lenient schema. A producer of the STRICT shape must now send `filters` and `sort` on each view (null when it has none), as with every other key in that mirror. +- 030a7b8: feat(spec)!: `record:json` is retired — refused by name at the schema door; the approval request page's raw JSON panel it was declared for was dropped (#24162, ADR-0049) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings (the `user:profile`, `element:filter`, `element:form` and `ai:chat_window` retirements shipped the same way). + + `record:json` was added to `PageComponentType` in `18.0.0-next.3`, with a strict `ComponentPropsMap` row, `RecordJsonProps` (`field`, `label`, `requiredPermissions`). It was declared spec first for one face: the approval request page's raw JSON panel. The maintainer then dropped that panel — the person deciding an approval has no business need to read the request as JSON — so no renderer was built and no page placed the type: zero consumers in objectstack and objectui, and its one planned producer was closed unmerged. A declared element that nothing draws is a capability claim nothing keeps, so it is retired under ADR-0049 enforce-or-remove, refused by name, the `ai:chat_window` way. The `18.0.0-next.3` entry below records what that prerelease shipped; this entry is its retirement. + + **What is refused:** an authored `record:json` component node, at `PageComponentSchema.type`. That covers `definePage()`, `PageSchema`, and every door that parses pages: `os validate`, `os build`, `os lint` and the metadata save door. The issue is located at the node's own path, with `code: 'custom'` and `params.retiredComponentType`, and its message is the retirement prescription. `PageComponentType`'s own error map refuses the name with the same text when the enum is parsed alone. `ComponentPropsMap['record:json']` stays as a row, so every reader that dispatches on it keeps recognising the name: the component-props gate, `check-yaml-examples` and the type vocabulary's known set. The row now refuses every props bag, `{}` included, with the same prescription. One prescription string, `RETIRED_PAGE_COMPONENT_TYPES` in `@objectstack/spec/ui`, answers at all three doors. The authoring rule `component-type-unknown` reports the node by name, and stops suggesting `record:json` for a misspelling in the `record:` namespace. + + **What is removed from the exports:** `RecordJsonProps` (`@objectstack/spec/ui`), the props schema the element no longer has. Its JSON Schema (`ui/RecordJsonProps`) is no longer published. Its print classification row (`PRINT_REFUSED_PAGE_COMPONENT_TYPES`) leaves with it: a retired type is refused at the parse before any print rule reads it. + + **What stays accepted:** every other member of `PageComponentType` and `ComponentPropsMap`, byte-identically, including the four record blocks' own `requiredPermissions` gate, which `record:json` never changed. The open string arm also stays open: custom and plugin-registered types keep parsing. The only string refused is the retired name itself. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | a `{ type: 'record:json' }` component node in a page region, slot or container | nothing: delete the node. No replacement panel exists, by ruling | + | `properties: { field, label, requiredPermissions }` on that node | nothing: none of them was ever read | + + The one-line fix: delete the `record:json` component node. No ADR-0087 conversion is registered, because the only edit is deleting an authored page node, and a mechanical conversion does not delete page nodes: which region closes up is a layout decision. The D3 entry `ui-record-json-retired` carries that delegation, so `os migrate meta --from 17` lists it as a manual change for every stack that still names the type. + + ## Who is affected, measured + + - **objectstack** at `eda6f14b7b`: zero authored `record:json` nodes in `examples/**`, `packages/apps/**`, `apps/**`, `skills/**` and `content/docs/**` code samples. The only hits were the spec's own type list, row and print row, its tests, the lint accept pin and the generated reference docs. A tree-scoped absence pin now holds that zero. + - **objectui** `main`: zero hits (the card's census); the `.objectui-sha` pin is not moved. + - **Deployed metadata** was not measured; the type was published in one prerelease only. + + The retirement kit: + + - the retired-type map entry and the enum value removed (`packages/spec/src/ui/page.zod.ts`), its print classification row removed, and the row turned into a whole-bag refusal, with `RecordJsonProps` removed (`packages/spec/src/ui/component.zod.ts`) + - the D3 semantic entry `ui-record-json-retired`, its step-18 rationale fragment, and the `RETIRED_DEFS_BY_MAJOR` entry `ui/RecordJsonProps` + - pin tests: `component-record-json-retirement.test.ts` pins `code`, `path`, `params` and the first sentence at each door (the map, the row with empty and populated bags, `PageComponentSchema`, `PageSchema` at the element path, the enum error map), with `record:approvals` as the live control, plus a tree-scoped absence walk; `component-type-vocabulary.test.ts` pins the type known, out of the typo candidates, and `record:` still reserved; the lint accept pin `validate-record-json-23745.test.ts` is flipped into a refusal pin at both lint rules and the shared pipeline + - generated baselines and docs follow the schema: `api-surface/`, `export-origins/`, `declaration-map/`, `authorable-surface/`, `json-schema.manifest/`, the strictness-ledger counts, `spec-changes.json`, the upgrade guide and the reference docs +- b1845ed: feat(spec/contracts): `IAuthService.verifyMcpAccessToken`'s verified result gains an optional `organizationId`, the organization the user's OAuth consent bound + + Clause-②: yes (widening) + + - **What changed.** The verified result of `verifyMcpAccessToken` (`@objectstack/spec/contracts`) is now `{ userId: string; scopes: string[]; clientId?: string; organizationId?: string }`. `organizationId` is the organization the user's OAuth consent bound (the user's current organization at the moment of consent), read from the access token's `organization_id` claim. + - **Absent** when no consent bound one: a token minted before consents bound an organization. Absent means no organization, so only the user's global grants apply. + - **A claim, not an authorization.** A caller vets it on every request by the rule that vets a session's active organization (`vetOrganizationClaim`, `@objectstack/core`). Once no current membership backs it, the principal resolves with no organization (global grants only: tenant reads come back empty and tenant writes are refused), never in another organization. The token acts in that one organization only: never the union of the user's organizations, and never the organization the user's browser session has selected now. + - **Type only, and additive.** The member is optional, so an implementation that returns no `organizationId` still satisfies the contract, and nothing this package does at runtime changes. This package declares the member only: writing the claim at consent is the auth plugin's, and reading it into the OAuth session is the runtime's. +- 8215ff7: fix(spec)!: `defineStack` judges a `record:line_items` column that declares no `type` as the type it renders, as it already does on the master-detail carriers (#24190) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing of what `defineStack` and `objectstack validate` publish, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. The console's `record:line_items` panel fills a column that declares no `type` from the child object's field, with the same rule as the master-detail grid. `defineStack`'s cross-reference check judged that resolved type on the three master-detail carriers and not on this block, so the same column got two answers. + + **`@objectstack/spec`** + + - **`defineStack`'s cross-reference check** now reaches a `record:line_items` block in a page region's components, in a container's component children, in a slot and, since #24349, in a `page:tabs` or `page:accordion` item's `children`. It judges a column that declares no `type` as the type it renders, and reports the column schema's own refusal (`STACK_CROSS_REFERENCE_INVALID`, 422). Today that means `scale` on an identity-only column over a `currency` field of the child object. The child object is the one the panel lists: the object `dataSource` binding's `object` when the node carries one, else `childObject`. A child object the stack does not declare, a column naming no field of it, and a column the column schema refuses on its own (left to the component-props gate) are not judged. + - The step-18 migration entry `ui-record-line-items-props-closed` now says so, and no longer says that this block draws a column exactly as declared. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `{ type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'amount', scale: 2 }] } }` where `amount` is a `currency` field of `invoice_line` | the same block with `columns: [{ name: 'amount' }]` | + + The one-line fix: delete `scale` from a `record:line_items` column that renders as a currency column, whether it declares `type: 'currency'` or takes it from a `currency` child field. Nothing replaces `scale` there: the currency's ISO 4217 minor unit decides the displayed decimals. + + ## Who is affected, measured + + On `origin/main` `ec749fbb44`: two authored `record:line_items` blocks in the examples (the showcase project detail page, five typed columns, its block under a `page:tabs` item; the showcase invoice document page, four `{ name, label }` columns, two of them over `currency` fields) and one in the published skills (four `{ name }` columns). None carries `scale`, so no example, skill or fixture outside this change's own pins changes verdict. Deployed metadata was not measured. +- b036ac5: feat(spec): `AuthSessionApi.getSession`'s result declares the fields its in-process readers read + + Clause-②: yes (widening) + + - **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains five optional members**, each declared for a named reader and typed as that reader takes it: `user.email` and `session.id` / `session.token` (read by `resolveAuthzContext`'s session arm in `@objectstack/core`), `user.authGate` as `unknown` (the ADR-0069 posture, which `normalizeAuthGate` validates against the closed posture vocabulary), and `session.impersonatedBy` as `string | null` (read by the print door's `impersonatorOf` in `@objectstack/service-print`). Before this, those reads went through `any` or a cast, so a field better-auth dropped or renamed compiled and read `undefined`. For `impersonatedBy`, that would have silently turned off the impersonation refusal. Every member is optional, so every implementer and caller typechecks unchanged. + - **`@objectstack/core`**: `resolveAuthzContext`'s session arm reads the session through the declared type instead of `any`. `ResolveAuthzInput.getSession`'s own type is unchanged. Runtime behaviour is unchanged. + - **`@objectstack/service-print`**: `impersonatorOf` reads `session.impersonatedBy` through the declared type instead of a cast. Runtime behaviour is unchanged. +- 6ec0e12: feat(spec/ai): a solution blueprint's select options declare their lifecycle `kind`, and a blueprint object names its `stageField` + + Clause-②: yes + + The object schema already requires a lifecycle `kind` (`open` / `won` / `lost`) on every option of the field an object's `stageField` names. The AI blueprint had no slot for either key, so a model could not say which field was an object's stage or which option ended its lifecycle, and that rule never applied to an AI-built object. Both blueprint shapes now carry both keys. + + - **Option `kind`**, on `BlueprintFieldSchema.options[]` and on the strict structured-output mirror's (`SolutionBlueprintStrictSchema`) field options. It is `SelectOption.kind`'s own enum, taken by reference, so the blueprint accepts exactly the kinds an object field's option accepts. Lenient: optional. Strict: required and nullable (null = no kind stated). + - **Object `stageField`**, on `BlueprintObjectSchema` and the strict mirror's objects: a snake_case name of one of the object's own fields, mapping onto `ObjectSchema.stageField`. Lenient: optional. Strict: required and nullable (null = no stage field). The object schema's `false` value ("no linear lifecycle") has no blueprint spelling. + - Both describes state what the object schema refuses: the field `stageField` names must be a single-value `select` or `radio` whose inline options each declare `kind`. A multiselect / checkboxes / tags field that carries options is refused as a stage field, and `kind` on such a field's options is refused by the field schema. The blueprint does not check these rules itself; the object schema does, when the applier writes the object. + - Additive only: every blueprint that parsed before still parses with the lenient schema. A producer of the STRICT shape must now send `stageField` on each object and `kind` on each option (null when it has none), as with every other key in that mirror. +- af284b0: feat(spec): the remaining `getSession` readers read `AuthSessionApi`'s declared result, and none reads a top-level `activeOrganizationId` + + Clause-②: yes (widening) + + - **`@objectstack/spec/contracts` — `AuthSessionApi.getSession`'s result gains three optional `user` members**, each declared for a named reader: `isPlatformAdmin` (`boolean`, the ADR-0068 D2 rung plugin-auth's `customSession` derives) and `role` (`string | null`, better-auth's `admin` plugin scalar), both read by `judgePlatformAdmin` in `@objectstack/plugin-auth`; and `name` (`string`), read by the runtime dispatcher's AI-route `req.user` resolver as the display name. Every member is optional, so every implementer and caller typechecks unchanged. + - **The readers read through the declared type instead of `any` or a cast** (types only, runtime behaviour unchanged): `judgePlatformAdmin` and the four platform-admin mounts in `@objectstack/plugin-auth` (better-auth's own session type now has to fit the declaration at those reads), the REST server's two session reads (`@objectstack/rest`), the dispatcher's posture read, `resolveSessionData` and both `HttpDispatcher` reads (`@objectstack/runtime`), the cloud-connection and install-local session reads (`@objectstack/cloud-connection`), the webhook redeliver door (`@objectstack/plugin-webhooks`), and the storage session bridge (`@objectstack/service-storage`). + - **Fix — the top-level `activeOrganizationId` fallback is removed** from the storage upload stamp (`@objectstack/service-storage`) and from install-local's `resolveActiveOrgId` (`@objectstack/cloud-connection`). The active organization is read from `session.activeOrganizationId` alone, where the contract declares it. No in-process producer answers a top-level `activeOrganizationId` (better-auth's read is `{ user, session }`), so for the shipped auth plugin nothing changes. An auth service that answered only the flattened shape now gets no organization: the upload is not stamped, and install-local reports no active organization. To fix it, answer the active organization on `session.activeOrganizationId`, as `AuthSessionApi` declares it. +- 14a8bd2: feat(spec)!: `marketplace:installed-list` is retired — refused by name at the schema door; the separate Installed Apps view it drew was retired, and Setup → Packages filtered to Source = marketplace replaces it (#24230, ADR-0049) + + Clause-②: no (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings (the `user:profile`, `element:filter`, `element:form`, `ai:chat_window` and `record:json` retirements shipped the same way). + + `marketplace:installed-list` was the plugin console widget that drew the separate Installed Apps view. It was published in 17.3.0 with an empty `ComponentPropsMap` row and was never a `PageComponentType` member: it reached the page schema through the type union's open string arm. The separate view was then retired, because Setup → Packages carries the same packages and their actions behind a Source = marketplace filter. The widget's one page, `marketplace_installed`, had already left `@objectstack/cloud-connection`, whose "Installed Apps" navigation entry opens the filtered Packages view instead, and the console deleted the widget's one renderer. After that, an authored node validated clean and then drew "Unknown component type" in front of a user. A declared element nothing draws is a capability claim nothing keeps, so it is retired under ADR-0049 enforce-or-remove and refused by name, the `ai:chat_window` way. + + **What is refused:** an authored `marketplace:installed-list` component node, at `PageComponentSchema.type`. That covers `definePage()`, `PageSchema`, and every door that parses pages: `os validate`, `os build`, `os lint` and the metadata save door. The issue is located at the node's own path, with `code: 'custom'` and `params.retiredComponentType`, and its message is the retirement prescription. `ComponentPropsMap['marketplace:installed-list']` stays as a row, because for a type outside the enum the row is the only thing that keeps the name known to the component-props gate, `check-yaml-examples` and the type vocabulary. The row now refuses every props bag, `{}` included, with the same prescription. One prescription string, `RETIRED_PAGE_COMPONENT_TYPES` in `@objectstack/spec/ui`, answers at every door. The authoring rule `component-type-unknown` reports the node by name, and the typo suggester no longer offers it. + + **What else moves:** its print classification row (`PRINT_REFUSED_PAGE_COMPONENT_TYPES`) leaves with it, because a retired type is refused at the parse before any print rule reads it. The D3 entry `ui-cloud-connection-widgets-unknown-keys-refused`, which closed this type's props bag to `{}` in the same major, now speaks for `cloud-connection:panel` alone, so the upgrade guide no longer tells an upgrader to keep a node that is refused. No export is removed: the row was built by the `emptyProps` factory, which exports no def, so no JSON Schema leaves the published set. + + **What stays accepted:** every other `ComponentPropsMap` row, byte-identically, including the sibling console widgets `cloud-connection:panel` and `mcp:connect-agent`. The open string arm also stays open: custom and plugin-registered types keep parsing, the `marketplace:` namespace included. The only string refused is the retired name itself. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | a `{ type: 'marketplace:installed-list' }` component node in a page region, slot or container | nothing: delete the node. The Installed Apps list is Setup → Packages filtered to Source = marketplace | + | a page whose only job was to show that list | a navigation item `{ type: 'component', componentRef: 'developer:packages', params: { source: 'marketplace' } }`, which is what `@objectstack/cloud-connection`'s own "Installed Apps" entry does | + + The one-line fix: delete the `marketplace:installed-list` component node, and link to the filtered Packages view if users need to reach it. No ADR-0087 conversion is registered, because the only edit is deleting an authored page node, and a mechanical conversion does not delete page nodes: which region closes up is a layout decision. The D3 entry `ui-marketplace-installed-list-retired` carries that delegation, so `os migrate meta --from 17` lists it as a manual change for every stack that still names the type. + + ## Who is affected, measured + + - **objectstack** at `cbfa5e4335`: zero authored `marketplace:installed-list` nodes in shipped metadata. `@objectstack/cloud-connection` serves no page for it, and its pin test asserts that. The other hits were the spec's own row, print row and docblocks, its tests, two lint pins, one synthetic test fixture in `packages/cli` (now a live block) and the release-owned changelogs. A tree-scoped absence pin now holds that zero. + - **objectui** at the console pin `.objectui-sha` `5e15928b46`: the type is registered by string only, with no typed read of `ComponentPropsMap['marketplace:installed-list']`. The row's key is kept, so the console build at the pin is unaffected. objectui `main` has deleted the renderer. + - **Deployed metadata** was not measured. + + The retirement kit: + + - the retired-type map entry and the print classification row removed (`packages/spec/src/ui/page.zod.ts`), the row turned into a whole-bag refusal (`packages/spec/src/ui/component.zod.ts`), and the vocabulary docblock updated (`component-type-vocabulary.ts`) + - the D3 semantic entry `ui-marketplace-installed-list-retired` and its step-18 rationale fragment; the D3 entries `ui-cloud-connection-widgets-unknown-keys-refused` (narrowed to `cloud-connection:panel`) and `ui-mcp-connect-agent-unknown-keys-refused` (one clause) amended + - pin tests: `component-marketplace-installed-list-retirement.test.ts` pins `code`, `path`, `params` and the first sentence at each door (the map, the row with empty and keyed bags, `PageComponentSchema`, `PageSchema` at the element path), the absence from the enum and the print rows, the D3 registration and the narrowed sibling entry, with `cloud-connection:panel` as the live control, plus a tree-scoped absence walk; `component-type-vocabulary.test.ts` pins the type known only through its row, out of the typo candidates, and `marketplace:` still unreserved; the lint accept pins in `validate-component-types.test.ts` and `validate-component-props.test.ts` are flipped into refusal pins + - generated docs follow the schema: the reference page for `ui/page`, `spec-changes.json` and the upgrade guide +- 1e001c7: feat(spec): `PublishPackageDraftsResponseSchema` declares `backfillApplied`, the per-field receipt of the publish door's opt-in backfill + + Clause-②: yes (widening) + + - `backfillApplied?: { success, fields: [{ object, field, updated, error?, code? }] }`, present only when the request named a `backfill` and the batch published. Declared so the door's response parse carries it instead of stripping it. +- 0b44840: feat(spec): register four organization and invitation-placement refusal codes under `@objectstack/plugin-auth` + + Clause-②: yes (widening) + + The error-code ledger (`ERROR_CODE_LEDGER`, and so `ErrorCode`) gains four `@objectstack/plugin-auth` codes. Each names a hook refusal that today answers a bare `403 FORBIDDEN` with a message and no `code`, so a client such as the console cannot tell it apart from any other refusal on the same endpoint: + + - `ORGANIZATION_CREATION_DISABLED`: creating an organization is refused for every caller, because the deployment's effective tenancy posture enforces no organization wall (`/auth/config` reports `features.multiOrgEnabled: false`). + - `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS`: an organization's slug cannot change while the organization has an environment whose status is neither archived nor failed. + - `INVITE_PLACEMENT_UNSUPPORTED`: fail-closed. The invitation carries a placement (a business unit and positions), and this deployment has no delegated-administration runtime to authorize it. + - `INVITE_PLACEMENT_OUTSIDE_ISSUER_SCOPE`: the delegated-administration gate refused the invitation's placement, because the business unit or a position lies outside the issuer's delegated scope. + + Registration only: `@objectstack/plugin-auth` still throws these refusals without a code until #24243 stamps them. The same change names a standard code, which needs no ledger row, for the plugin's other four code-less refusals: `RATE_LIMIT_EXCEEDED` for both phone-OTP send walls, which stay indistinguishable; `SERVICE_UNAVAILABLE` for the slug guard's read fault; and `VALIDATION_ERROR` for the reserved `__global__` organization id or slug. No existing code changes, and no standard-catalog member is shadowed (none needs a synonym waiver). +- ef65646: Clause-②: yes (widening) + + feat(spec): an app page binds its row as `record`, so a component `visibleWhen` can follow a bound count or a plan field + + - **What it adds.** `PageSchema.record` (`PageRecordBindingSchema`): a one-row query `{ object, view?, filter?, sort? }` on a `type: 'app'` page. The page's row is the FIRST row of that query, read under the caller's identity, and every component predicate on the page reads it as `record`, the root ADR-0089 D1 already names for runtime surfaces. No new expression root is added. + - **What a predicate reads.** A count or a plan is a field on the bound row: a `summary` (roll-up) field (`record.environment_count > 1`) or the object's own field (`record.plan != 'free'`). `features.*` is not a plan: that root is the deployment's public auth flags (`/api/v1/auth/config`), the same for every organisation and every viewer. The `visibleWhen` docblock and describe now say so, and they say that a block's own element `dataSource` binds no `record` for its siblings or for its own `visibleWhen`. + - **What it refuses.** `record` on any page that is not `type: 'app'` is refused at parse (`checkPageRecordBindingPlacement`, exported from `@objectstack/spec/ui` like the other page checks): a record page's row is the record it opens, and a `home` / `list` / `utility` page has no row. Inside the binding, `limit` is refused with a prescription (a page binds one row; say which with `sort` or `filter`), and so are `id` / `recordId` (match the row with a `filter` rule on `id`). + - **Planned in the renderer.** The console does not mount the binding yet: until it does, `record` stays unbound on an app page, as before, and a predicate reading it faults, so the component renders. The liveness ledger grades the key `planned`. + - **Unchanged:** every existing page, component and predicate; the predicate root set (`record`, `current_user` with its aliases, `page.NAME`); the lint's declared roots. +- 586705d: feat(spec): the bound record blocks `record:details`, `record:highlights` and `record:quick_actions` accept an authored `emptyState`, drawn only when the block's `dataSource` binding answers no row + + Clause-②: yes (widening) + + - **The key.** `RecordDetailsProps`, `RecordHighlightsProps` and `RecordQuickActionsProps` each gain an optional `emptyState`: the list view's own `EmptyStateSchema` by reference (`{ title, message, icon, link }`), the same shape `object-grid.emptyState` takes. Until now all three rows refused the key, so a page whose binding can legitimately answer no row (for example an environment block on an organisation with no production environment) could only show the block's own placeholder: "record not found", nothing, or an empty action bar. + - **When it is drawn.** Only when the block carries a node-level `dataSource` binding and that read answers no row. A block with no binding reads the record page's record and never draws it, a loading read draws the loading state, and a failed read draws its error. On `record:quick_actions`, a bound record whose bar resolves no action keeps the existing empty placeholder. + - **Planned, not yet rendered.** No renderer reads the key yet; each describe opens with `[PLANNED — not yet rendered]`, and the renderer half is objectstack-ai/objectui#12932, which ships once a spec release carrying the key is installable. Until then an authored `emptyState` validates and is not drawn. + - **No other row moves.** Every other `ComponentPropsMap` row still refuses `emptyState` (`record:related_list`, whose `parentDataSource` has its own declared answer for no row, included), so `os validate` keeps reporting it there as `component-props-unknown-key`. Nothing narrows. +- fa26308: fix(objectql,runtime): a hook whose `handler` names a function gets `ctx.title(field?)` in-process, as an inline handler and a `body` do + + Clause-②: yes (widening) + + - **What was wrong.** A hook whose `handler` is a string naming a function ran in-process without `ctx.title`: `defineStack({ functions })` plus a hook naming an entry on a source boot (`@objectstack/verify`'s `bootStack(config)`, `os serve` over a config), and a string `handler` resolved from the artifact's `runtimeModule` (what `objectstack build` ships for a callable it could not lower to a `body`). The engine's binder resolved the name after the runtime had wrapped the inline handlers, so the function was called with the bare engine context and `await ctx.title()` threw `TypeError: ctx.title is not a function`. + - **What changed.** `bindHooksToEngine` (`@objectstack/objectql`) takes a `wrapHandler(handler, hook)` option, applied at bind to every handler a hook runs in-process, after a string `handler` resolves: an inline function, a name from the bind's `functions`, or a name the same package registered on the engine. A `body`'s handler (built by `bodyRunner`) never takes it. `ObjectQL.bindHooks` and the `IObjectQLEngine.bindHooks` contract (`@objectstack/spec`) carry the option. `bindAppArtifactHandlers` (`@objectstack/runtime`) passes the existing `withInProcessHookTitle` through it, in place of wrapping inline handlers before the bind, so the inline and the named handler take one wrap. + - **Unchanged.** A bind that passes no `wrapHandler` binds every handler exactly as resolved, so a host or harness that calls `bindHooks` itself, and a hook registered with `ql.registerHook(...)`, are not handed `ctx.title`. `HookContext` still declares no `title`. +- 584b422: feat(spec): the draft read declares `draftChanges`, a pending draft's per-key provenance: which authoring surface last changed each key path of the draft, under which actor, and when + + Clause-②: yes (widening) + + - **The key.** `GetMetaItemResponseSchema` gains an optional `draftChanges` beside `item`: one entry per changed key path of the pending draft body, each `{ path, by, actor, at }`. `path` is an array of the keys from the body root to the deepest key the write changed (`["fields", "renewal_count"]`, `["fields", "renewal_count", "label"]`); an array value is one leaf, so a change to a view's columns is `["columns"]` and never carries an index (which elements differ is read by comparing the array with the published body's; the entry says who last changed the array); `[]` is the whole body of an item created as a draft. `by` is a closed set: `copilot` (an AI assistant's tool writing on a person's behalf), `studio` (a person editing in Studio) or `api` (any other caller of the save door). `actor` is the write's recorded identity or `null`, and `at` its ISO-8601 instant. + - **Where it is served.** Only on the strict draft read (`GET /meta/:type/:name?state=draft`), the read Studio's editor loads an item from, so the marks always describe the body that read returned. It is absent on the active read, the cached branch and the `?preview=draft` read. `ListDraftsResponseSchema` (`GET /meta/_drafts`) is unchanged. + - **Absent means "not recorded".** No server writes the key yet; until one does, and for a draft row saved before it did, the key is absent rather than `[]`, so a reader never takes "not recorded" for "nothing changed". + - **Nothing breaks.** Every response that parsed before still parses, and no runtime behaviour changes in this release of the spec. +- 370833a: feat(spec): `ApprovalRequestRow` declares `current_step_label`, the `sys_approval_request` column's own name for the step's label + + Clause-②: yes (widening) + + - **The member.** `ApprovalRequestRow` (`@objectstack/spec/contracts`) gains an optional `current_step_label?: string` beside `step_label`. It names the declared `sys_approval_request.current_step_label` column: the Approval node's authored label as it stood when the request reached the step. A page bound to `sys_approval_request` names the object's declared fields, and `step_label` is not one, so a served row that carries this member lets such a page list the step by its label instead of by `current_step`, the node's machine id. + - **Nothing moves.** `step_label` stays, at the same shape and with the same value, so no reader changes. The member is optional, so every existing implementer and every row built without it still conforms. It is a member of a TypeScript interface: no Zod schema and no runtime accept set changes. + - **Its producer.** `@objectstack/plugin-approvals`' request read mapping is the producer; serving the member there is that package's own change. +- 3a734e0: feat(spec)!: a page region named `main` takes only `width: 'full'` — `'small'`, `'medium'` and `'large'` there are refused at publish (#24326) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention. + + `PageRegionSchema.width` is one enum shared by every region, so a `main` region accepted `'small'`, `'medium'` and `'large'`. The console never read them: every layout draws `main` at the width the layout leaves it (the flexible column beside `sidebar` and `aside`; one grid cell on `dashboard`), and the only reader of `main.width` is the page itself, for `'full'`, which lifts the page's max-width cap. A three-column page that asked for a `large` main got the space the two rails left, and every door reported success. + + - `PageSchema` now refuses `width: 'small' | 'medium' | 'large'` on a region named `main` (matched case-insensitively, as the console matches it), at `regions.N.width`, on every page kind and template. The message names the region and `'full'` as the one width it takes. The doors are `definePage`, `defineStack` (so `os validate` and `os build`, `STACK_SCHEMA_INVALID` 422) and the metadata save door (422 `INVALID_METADATA`). + - `'full'` on `main` is unchanged, with its page-wide meaning. Every other region accepts the same widths as before. + - The check is exported as `checkPageMainRegionWidth` from `@objectstack/spec/ui`, so a mirror built from `PageSchema.shape` can attach the same rule. + - Data at rest keeps loading: the retired ADR-0087 conversion `page-main-region-width-removed` deletes those widths on read: a stored page row, a compiled artifact inside the window its declared protocol range opens, and a package body registered with its declared range. The page renders exactly as before. + - `@objectstack/mcp` (the Connect an Agent page) and `@objectstack/cloud-connection` (the Cloud Connection page) drop `width: 'large'` from their `main` region. Both render the same. + + ### Migration: FROM → TO + + | Wrote | Write instead | + | :--- | :--- | + | `width: 'small' \| 'medium' \| 'large'` on a region named `main` | drop it; `'full'` stays | + + Deleting the key changes nothing on screen. Write `width: 'full'` only when you want a full-bleed page, which drops the page's max-width cap. +- 26501ae: feat(spec): a navigation entry may declare one API data source, which its `visible` reads as `data` (planned, not yet rendered) + + Clause-②: yes (widening) + + - **What changed.** Every navigation entry that spreads the shared nav base (`object`, `dashboard`, `page`, `url`, `report`, `action`, `component`, `doc`, `group`) accepts an optional `dataSource`. It is the page element's API binding, `ElementApiDataSourceSchema`, by reference: `{ provider: 'api', read: { url, method? }, path? }` reads one same-origin endpoint with the console's signed-in fetch, and the entry's `visible` reads the value `path` selects as `data`, for example `visible: 'data.total == 1'`. + - **The refusals are the binding's own.** A `read.url` that is not a same-origin API path, a `method` other than `GET`, a credential key (`headers`, `token`, …), the object binding `{ object, … }` and an unknown key are all refused at parse, at the entry's `dataSource` path. A `separator` refuses `dataSource` with its own prescription. + - **What does not read it.** Only `visible` reads `data`. `label` and the target keys (`recordId`, `filters`, `params`) take no `data` read, and a group's binding is not published to its `children`: each entry declares its own. + - **Planned, not yet rendered.** No console reads the key yet, so a `visible` that reads `data` has no value to read until the navigation renderer ships. The describe opens with `[PLANNED — not yet rendered]`, and the liveness row `app` → `navigation.dataSource` is `planned` with an author warning (`os lint` / `os validate` report it on a top-level entry). + - **Who notices.** Nobody, by construction: every app that parsed before parses unchanged. Nothing narrows. +- 31e7d0b: feat(spec): an object can declare its default row scope, `defaultRowScope`, the row-level security policy shape without `object` + + Clause-②: yes (widening) + + `ObjectSchema` gains one optional key, `defaultRowScope`: one row-level security policy, written like a permission set's `rowLevelSecurity` entry without `object`, which the declaring object implies. The shape is exported as `DefaultRowScopeSchema` from `@objectstack/spec/security` (types `DefaultRowScope` and `DefaultRowScopeParsed`). + + - **What it parses.** Every member of `RowLevelSecurityPolicySchema` except `object`, the same member schemas: `name`, `label`, `description`, `operation`, `using`, `check`, `positions` and `enabled` (default `true`). The policy's two rules hold as written: at least one of `using` / `check`, and no `check` on a `select` or `delete` policy. + - **What it refuses.** `object` is refused by name, with a prescription to delete it: the target is implied. Any other unknown key is refused too, the policy's retired `priority` and `tags` included. + - **What it does.** The security plugin's row-policy collection (`collectRLSPolicies`), the one step every read, write and explain passes through, applies the key: when the permission sets the caller holds carry no row policy for the object and the operation (a `'*'` policy counts; one switched off, outside the caller's positions, or for another operation does not), the object's default is used in their place and judged like a held policy. A policy an authored set carries for the object is used as authored, with the default not added to it; a principal whose superuser bypass skips row policies is unaffected; an object without the key keeps its behaviour. A `using` the row-filter compiler cannot lower makes the reads it governs return no rows, with one server warning. The platform's identity objects are to declare it for their own tables in a separate change. See the `@objectstack/plugin-security` entry in this release for the reader. + - **`composeStacks` with `objectConflict: 'merge'`** now refuses two stacks that declare a different `defaultRowScope` on the same object, the way it refuses every other fixed-shape config object (`access`, `enable`, `publicSharing`, …). The refusal message's list of those objects names `defaultRowScope` too. Nothing composed before is affected, because no stack declared the key. + - **`@objectstack/metadata-core`: the field-level-security projection classifies the new key.** When an object document is served to a caller who cannot read some of its fields, `defaultRowScope` is treated like a validation rule: the whole policy is dropped from the served document when its `using`, `check` or prose names a field that caller cannot read, and served unchanged otherwise. No behaviour moves until an object declares the key, and no object declares it yet. + - Additive: every object that parsed before still parses, and its parsed output is unchanged. +- db1f1fd: fix(spec)!: `defineStack`'s page cross-reference checks judge a component inside a `page:tabs` or `page:accordion` item's `children`, as they judge one at region level (#24349) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing of what `defineStack` and `objectstack validate` publish, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. `defineStack` reads a page's components through one traversal, shared by its inline-action target check and its inline grid column check. A tab or accordion item is a panel record (`{ label, value?, children }`), not a component, and the traversal stopped at it, so nothing inside a tab or an accordion panel was judged: a button whose modal target names no page built green in a tab and was refused one level up. + + **`@objectstack/spec`** + + - **`defineStack`'s page cross-reference checks** now reach a component in a `page:tabs` or `page:accordion` item's `children`, at any depth (tabs in an accordion panel, a panel in a container or under a slot). The traversal descends the panel positions the component rows declare (`pageComponentSlotPositions`), the list the other page walks already read, so it follows the declared shapes rather than naming tabs. A record under a key no row declares a panel list is not descended. + - A component in a panel gets the region-level verdict and message, located through the panel: `STACK_CROSS_REFERENCE_INVALID` (422), for example `Inline action 'open_detail' on page 'invoice_record' (regions.0.components.0.properties.items.1.children.0) references page 'no_such_modal' (via modal target) which is not defined in pages.` + - Two checks reach further: an inline action's `type: 'modal'` / `type: 'flow'` `target`, and an identity-only column (one that declares no `type`) carrying `scale` over a `currency` child field, on an `object-master-detail-form` block's `details[].columns` or a `record:line_items` block's `columns`. That includes a `record:line_items` block in a tab or accordion panel, a position the block's identity-only check did not reach before this change. + - New step-18 migration entry `ui-page-panel-children-cross-references-judged`; the entry `ui-record-line-items-props-closed` now names the panel positions in its reach. + + ## FROM → TO + + | you wrote, inside a `page:tabs` or `page:accordion` item's `children` | write instead | + |:--|:--| + | `{ type: 'element:button', properties: { action: { type: 'modal', target: 'no_such_page' } } }` | a `target` naming a page the stack declares (or a declared flow, for `type: 'flow'`) | + | `{ type: 'record:line_items', properties: { childObject: 'invoice_line', relationshipField: 'invoice', columns: [{ name: 'amount', scale: 2 }] } }` where `amount` is a `currency` field of `invoice_line` | the same block with `columns: [{ name: 'amount' }]` | + + The one-line fix: give the component inside the panel the same fix it would need at region level. Nothing about the panel or its items changes. + + ## Who is affected, measured + + On `origin/main` `31e7d0ba2`: four authored pages nest components under a panel item (the showcase project detail page, whose `record:line_items` block declares a `type` on all five columns; the platform user and organization record pages; the approval request record page), one documentation example does (three tabs of record blocks), and the published skills carry none. None holds an inline action target or an identity-only column under a panel, so no example, skill or fixture outside this change's own pins changes verdict. Deployed metadata was not measured. +- adb7910: fix(spec)!: `UndoImportJobResponseSchema` requires `failures`, one entry per row an import undo did not revert, and the `skipAutomations` describe names no import-undo exception + + Clause-②: yes (narrowing) + + + + **BREAKING** (`UndoImportJobResponseSchema` accepts fewer answers: `failures` is required, so a response parsed with the schema is refused without it, and code that constructs an `UndoImportJobResponse` must set it, `[]` on a full revert; readers of `client.data.undoImportJob` are unchanged and gain the key), shipped as `minor` under the launch-window convention for breaking changes. + + `UndoImportJobResponseSchema` gains a required `failures`: one `{ id, action, code, error }` entry per row an import undo did not revert, `action` being `delete` or `restore`, and an empty array when every row was reverted. It is required rather than optional so a server that does not report its unreverted rows is off-contract instead of silently spec-valid. The `success` describe now says it is `false` when any row is listed there, and `failed` counts those entries. `revertedAt` on `ImportJobProgressSchema` and `ImportJobSummarySchema` now says it is set only once every row of the job is reverted; an undo that left rows unreverted sets nothing, and the job stays undoable for them. + + The `skipAutomations` describe on the hook context's `session` no longer names the import undo as a case that skips an app's controls for any caller. It names the system principal's import and undo, and says the import undo door is not an exception: only the system principal may take an app's declared controls off a write, and any other caller's undo runs under the object's hooks, record-change triggers and state machine. The `ExecutionContext` TSDoc for `skipAutomations` says the import runner sets it from its caller's `runAutomations: false`, which the import doors admit over HTTP from the system principal alone while an in-process caller of the runner passes its own setting, and that the import undo sets it only when the system principal undoes a job; the `skipStateMachine` TSDoc says the same of the undo. +- f573976: `MEMBERSHIP_REACH` (`@objectstack/spec/identity`) gains two rows, so `requiresMembershipReach` accepts two more names: `update_organization` (better-auth's `/organization/update`, statement `organization: ['update']`, reached by `owner` and `admin`) and `delete_organization` (`/organization/delete`, statement `organization: ['delete']`, reached by `owner` alone). Both are read off better-auth's own default role statements, like the existing rows, and plugin-auth's reach-table test holds each equal to the installed vendor door. An action declaring `requiresMembershipReach: 'update_organization'` is lowered to `visible: "'org_owner' in current_user.positions || 'org_admin' in current_user.positions"`; `'delete_organization'` lowers to `"'org_owner' in current_user.positions"`. Existing rows and their lowering are unchanged. + + Clause-②: yes (widening) +- 69f342b: feat(spec)!: retire a page action row's own `patch` — a row's `operation: 'update'` runs the declared action its `name` names, and the platform action route writes that action's `patch` (#24394) + + **BREAKING** — `patch` on a page action row (`action:button`, `action:icon`, and each `action:group` / `action:menu` member of `properties.actions`) is now refused. A row's `operation: 'update'` posts to the platform action route under the row's `name`. The route resolves that declared action by name and writes the action's own `patch`, merged under the request's params. The row's `patch` was never sent: the reference console read it only for its Undo snapshot. Beside `operation: 'update'` a row now needs a `name`, since without one it has no declared action to run and the console refused it before any request. A row's static `params` are written only where that action declares them, and the route refuses any other with a 400 (ADR-0104 D2). `ActionSchema.patch`, the action's own declaration, is unchanged. + + Clause-②: yes (narrowing) + + Both directions, measured on the built rows. Narrowing: every row `patch` is refused, and a row with `operation: 'update'` and no `name` (an input list or static `params` included) is refused at `name`. Widening: a row with `operation: 'update'` and a `name` but no `patch` and no `params` now parses; before, the rows refused it at `patch` as having nothing to write, though its write is the named action's `patch`. + + Measured before removal, at `71fa166e96`: no producer. No page action row in this repository's examples or apps authors `patch` (or any `operation`), and neither does any objectui page row at the pinned `0707b4ae37` or at objectui `main` `4b7ce47`. + + ## FROM → TO + + | you wrote (17.7 and earlier) | write instead | + | --- | --- | + | `operation: 'update'` with a `patch` on a page action row | the values in the `patch` of the declared action the row's `name` names (`defineAction({ name, operation: 'update', patch })`), the row keeping only `operation: 'update'` and that `name` | + | `patch` on a page action row with no `operation` | nothing — delete the key; it was already refused | + | `operation: 'update'` on a page action row with no `name` | a `name` naming the declared `operation: 'update'` action | + + **The one-line fix:** move each row's `patch` values onto the declared action its `name` names, then delete the row's `patch`. + + **What an author who still writes it sees.** `tsc` fails at a typed authoring site (the rows type the key as retired), and `os validate`, `os build` and `os lint` refuse it as an `error` (`component-props-invalid`) at its own path (`properties.patch`, `properties.actions.N.patch`) with the prescription: + + > An `action:button`'s `patch` was removed in @objectstack/spec 18 (ADR-0110 D2) — a page action row's `operation: 'update'` runs the declared action its `name` names, and the platform action route resolves that action by name and writes ITS `patch`, so the row's own `patch` was never sent and never written. Move the values into the `patch` of the action the row's `name` names, then delete the key. 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. + + A page's own parse keeps its component `properties` as an open bag, so an existing page carrying the key is never refused at load. + + ## The retirement kit + + - **A `retiredKey()` tombstone** at `patch` on `ActionButtonProps`, `ActionIconProps` and the `action:group` / `action:menu` member. `RETIRED_KEYS_BY_MAJOR[18]`: `ui/ActionButtonProps:patch`, `ui/ActionIconProps:patch` (the member's keys have no surface row of their own). The authorable-surface rows read `[RETIRED]`. + - **The rows' half of the declarative-update judge** reads the row's own surface: a row has no "`patch` without `operation`" rule of its own (its tombstone refuses every `patch`), and its "nothing to write" rule asks for the `name`. `ActionSchema`'s half of every rule, and every text it prints, is unchanged. + - **The D2 conversion `page-action-row-patch-removed`** (protocol 18, retired from the load path) deletes the key from every node and member — region-level, nested in a container's `children`, and in a slotted page's named slot — one notice per row. It is scoped by component `type`: the same key name on another component is left alone. + - **The D3 entry `ui-page-action-row-patch-retired`** records the judgement the strip cannot make: when the named action's own `patch` lacks a value the row carried, that value was never written, and adding it to the action changes what the action writes. + - **No deprecation window**, per the project's startup-stage posture. + + **Not part of it:** objectui's renderers still forward the row's `patch`, its runner still reads it for the Undo snapshot, and its component registry still lists a `patch` input on `action:button` / `action:icon` (so the tracked `sdui.manifest.json` advertises it to the html-tier page gate until the pin moves). Those reads leave on the objectui side. + + ⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + +- 2ec6827: feat(spec): a metadata save declares its authoring surface — `SaveMetaItemRequestSchema.surface`, in one exported closed set shared with `draftChanges[].by` + + Clause-②: yes (widening) + + - **One closed set, one name.** `MetadataAuthoringSurfaceSchema` (`copilot | studio | api`) is exported from `@objectstack/spec/api`, with its type `MetadataAuthoringSurface`. `GetMetaItemResponseSchema.draftChanges[].by` now reads it instead of an inline enum: the accepted values are unchanged. + - **The save states its surface.** `SaveMetaItemRequestSchema` gains an optional `surface` from that set. A draft save stamps it as `by` on each key path it changes; an absent `surface` stamps `api`. On the REST door and the runtime dispatcher it is the `?surface=` query parameter, which takes `studio` or `api` only: `copilot` is stated by an in-process AI tool, never over HTTP. + - **The writer exists.** The `draftChanges` describe no longer says no server writes it: it states the writer's rules (the first draft save is compared with the stored published row, an item with no stored published row is recorded as `[]`, and a `migrate-stored` rewrite records nothing). `18.0.0-next.4`'s note said no server writes `draftChanges` yet; from this release the draft save writes it. +- fb0d85b: feat(spec,objectql)!: a top-level action bound by `objectName` to an object another package owns is listed on that object wherever it is served, and a bound action whose object no loaded package registers fails the boot + + Clause-②: yes (narrowing) + + + + **BREAKING** (one narrowing beside a widening), shipped as `minor` under the launch-window convention for breaking changes. + + - **Widening** (`@objectstack/spec`, `@objectstack/objectql`): an action declared in a stack's top-level `actions` with `objectName` naming an object that another package owns now reaches that object. `defineStack()` accepts the action when the stack extends the object through `objectExtensions`, or when the object is a known platform object (`sys_organization` and the rest of the platform's named list). It keeps the action in the top-level `actions`. The engine's schema registry then attaches it to the object at the one step every object read ends in. That covers `GET /api/v1/meta/object/` (stored-row, MetadataService and registry layers alike), `engine.getSchema()` and the action router's object lookup, in whichever order the two packages register. The attached entry is the action's own declaration, so its `requiredPermissions`, `requiresMembershipReach` and execution identity are unchanged. An action the object already declares under the same name keeps the object's declaration. A disabled package's bound actions are not attached. Before this, such an action was refused by `defineStack()` whenever the stack declared an object of its own. Where it got through (an object-less stack, or `strict: false`), it registered but was listed on no object, so no client rendered it. + - **FROM** (`@objectstack/objectql`): a top-level action bound by `objectName` to an object no loaded package registers was registered and listed nowhere, and the boot succeeded. **TO:** the boot fails at `kernel:ready` with `INVALID_METADATA` (422), naming each such action, its package and the object. An artifact registered through the `manifest` service after the boot is refused before any of it registers. **Fix:** register the object (in the package or in one it depends on), or correct the action's `objectName`. + - **Unchanged:** a name `defineStack()` cannot attribute (an object this stack neither defines nor extends, and not a known platform object) is still refused at build with `STACK_CROSS_REFERENCE_INVALID`. A same-stack bound action is still merged into its object by `defineStack()` and is attached once. + + The `ObjectExtensionSchema` refusal for `actions` now says exactly this: declare a top-level action with `objectName: ''` in the same stack; the schema registry attaches it to the object wherever the object is served; a target no loaded package registers fails the boot. +- ef1fcb2: A declared index states its uniqueness scope: bare `unique: true` on `indexes[]` is refused (protocol 18, ADR-0120 D1/D7), stored metadata converts it to `unique: 'global'` with zero drift, and `VISIBILITY_STRICT_OPTIONS` leaves `@objectstack/spec`'s public surface. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface and one export removal, shipped as `minor` under the launch-window convention for accept-set narrowings (Changesets pre mode is not in on `main`). + + **Why.** On a declared index, bare `unique: true` was the one `unique` spelling whose scope was positional. It built the index over exactly `fields`, one holder across the whole installation, while reading like "unique per organization" to an author who knew the field-level meaning. 17.x warned (lint `unique/unscoped-declared-index`). Protocol 18 refuses it, so the scope is always stated. + + **What is refused.** A declared index (`objects[].indexes[]`, `objectExtensions[].indexes[]`) whose `unique` is bare `true`. The refusal names both replacements, and says which one keeps the index bare `true` built. It is raised by: + + - the schema (`IndexSchema.unique`, now `false | 'global' | 'organization'`), at every door that parses: `ObjectSchema.create()` and `ObjectSchema.parse()`, `defineStack`, `os validate`, `os build`, and the runtime save door (`422 INVALID_METADATA`). `tsc` refuses it too, because the input type no longer admits `true`; + - lint `unique/unscoped-declared-index`, now an `error` and a gating rule on all three commands. It is what refuses the spelling under `os lint`, which never parses. + + **What converts.** The protocol-18 ADR-0087 conversion `declared-index-unique-scope` rewrites a declared index's bare `true` to `'global'` on every data-at-rest seam: stored `sys_metadata` rows (`applyConversionsToStoredItem`), built artifacts inside their declared-floor window, and `os migrate meta --from 17`. `'global'` is exactly the index bare `true` built, so the physical index is byte-identical and the drift plan is empty. It is retired from the authoring funnel, so a live author is refused and taught instead of converted silently. + + **What stays accepted.** Field-level `unique: true` still means one holder per organization and stays valid indefinitely. On a declared index, `unique: false` or omitted, `'global'` and `'organization'` parse exactly as before. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `indexes: [{ fields: [...], unique: true }]` | `indexes: [{ fields: [...], unique: 'global' }]`: the same index, nothing on disk changes | + | …the same, when you meant one holder per organization | `unique: 'organization'`: the driver prepends the NULL-safe organization key part at registration, and `os migrate plan` shows the index change | + | `import { VISIBILITY_STRICT_OPTIONS } from '@objectstack/spec/shared'` (or the root entry) | delete the import: it was an internal option bag for the spec's own visibility-carrying schemas, and those schemas are unchanged | + + **The one-line fix: on every declared index, write `unique: 'global'` where you wrote `unique: true`.** Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. + + **Who is affected, measured.** At `e67ba80049`, an AST census found 48 declared indexes with bare `unique: true` in 39 source files of this repository, all platform and plugin objects. Every one is respelled `'global'` in this change, and the nine-key S5 corpus is pinned to build byte-identical indexes before and after (`driver-sql`'s `sql-driver-unique-tenancy.test.ts`). `examples/**` and `apps/**` carry none. Deployed metadata and other repositories were not measured. + + ### The kit + + - **The refusal.** `IndexSchema.unique` in `data/object.zod.ts`, with its own prescription. The lint rule moved from `warning` to `error` and from advisory to gating. + - **The conversion.** `declared-index-unique-scope` (`toMajor: 18`, retired from the load path, `retiredAfter: '17.7.0'`), with its S4/S5 fixture. + - **The ledger.** The D3 semantic entries `declared-index-bare-unique-true-retired` (the scope each respelled index really meant is the author's call) and `visibility-strict-options-unexported`, plus a step-18 rationale fragment. + - **The export.** `VISIBILITY_STRICT_OPTIONS` moved to the unbarrelled `shared/visibility-strict-options.ts`, beside its type `StrictObjectOptions`. `check:api-surface` counts the removal. + - **The pins.** The "`'global'` is a synonym of `true`" driver pin retired with the bare spelling. The verbatim pin is stated in `'global'`. +- ace0a53: feat(lint)!: `relationship/master-detail-required` refuses the three unsafe master-reference shapes at `error` on a `controlled_by_parent` object (#9139) + + Clause-②: no (narrowing) + + A `controlled_by_parent` detail derives all of its record access from the master its `master_detail` reference names (ADR-0055). Three declarable shapes of that reference leave the security gate as the only thing refusing a detail record saved without its master, because record validation never checks a field that is not `required` and skips `readonly` and `system` fields before its required check: + + 1. `required` absent, or `required: false`; + 2. `required: true` with `readonly: true`; + 3. `required: true` with `system: true`. + + A record that lands without its master anyway is readable by nobody, and every later write to it by id is refused. Until now `relationship/master-detail-required` was a `warning` with the predicate "`required` is not `true`", on every object, so shapes 2 and 3 drew no finding at any severity. The maintainer ruling of 2026-08-16 (Direction 1) scheduled the promotion for the v18 boundary, scoped to `controlled_by_parent`. + + **BREAKING — what moves for consumers.** + + - `os lint` reports each of the three shapes at `error` when the object declares `sharingModel: 'controlled_by_parent'`, located at the defect (`…fields.FIELD.required`, `.readonly` or `.system`). It covers every `master_detail` field of such an object, the same scope the builder's `required: true` force already applies. `os lint` therefore exits non-zero on such a stack, and the metadata-generation rubric (`scoreMetadata`) weighs the finding as an error and marks the stack `valid: false`. + - `@objectstack/spec` gains the step-18 semantic migration entry `cbp-master-detail-required-lint-error`, so `os migrate meta` across protocol 18 prints the prescription below. + + **Remedy — the v18 upgrade-checklist line.** On every object with `sharingModel: 'controlled_by_parent'`, give each `master_detail` reference `required: true` and remove any `readonly: true` or `system: true` from it. `os lint` now refuses the missing-`required`, `required` + `readonly` and `required` + `system` shapes there at `error` (`relationship/master-detail-required`). An object authored through `ObjectSchema.create` already gets `required: true` when the key is omitted, so the edit there is dropping the flag. + + **Unchanged.** + + - On every object that is not `controlled_by_parent` the rule is exactly as before: a `warning` for a `master_detail` without `required: true`, the same message and fix, and no finding for the two flagged shapes. + - The rule is not in the authoring-rule registry. `os build`, `os validate` and the metadata save door do not run it, so a stack carrying one of the shapes still builds and publishes. Only `os lint`'s exit code and the generation rubric move. + - Runtime is untouched. The security gate keeps refusing an insert that omits the master FK on these shapes and keeps resolving the master for metadata already at rest, and stored metadata is neither rewritten nor refused on load. + - No export or signature moves in either package. + - Measured before crossing, at `b04a5295f`: 129 authored objects across the example apps, the platform, plugin and service objects and the CLI's golden eval corpus. 7 of them are `controlled_by_parent`, and 0 draw the new `error`. + + + +### Patch Changes + +- ffba02e: `record:related_list`'s `parentDataSource` is described as rendered + + Clause-②: no (describe and TSDoc text only: `RecordRelatedListProps` accepts exactly what it accepted before, and every refusal keeps its issue `code` and `path`) + + - The `parentDataSource` describe no longer opens with `[PLANNED — not yet rendered]`. The console renders the key: a related list reads its parent record from the binding, the first row by its `sort`, and draws a notice in its place when no row answers, never an empty list. Without the key, the record page's record is the parent, as before. The rest of the describe is unchanged. + - The key's TSDoc names the console's read points in place of the planned note, and no longer calls the `api` data source unrendered. +- ca426cb: **Shipped in 18.0.0-next.5**, whose CHANGELOG missed this entry; listed here one prerelease late. + + feat(formula): `current_user.hasCapability(name)` — a page predicate can ask whether the viewer holds a system capability, from the same list the action gate reads + + Clause-②: yes (widening) + + - **The predicate.** The CEL standard library registers `hasCapability` beside `can`, receiver-only, so the one spelling is `current_user.hasCapability('')` (the `user` / `ctx.user` / `os.user` aliases work too). It answers `true` when the name is in the acting subject's held system capabilities and `false` when it is not. A name no action declares is just a string: there is no registry lookup. + - **The data.** `EvalContext` gains `systemPermissions` (type `EvalSystemPermissions`, a list of capability names): the `systemPermissions` array of the published `/auth/me/permissions` response, unchanged — the list an action's `requiredPermissions` is checked against (ADR-0066 D4). `PermissionBinding` gains the same field, optional. Pass it beside `permissions`: + + ```ts + celEngine.evaluate(expr, { user, permissions: toEvalPermissions(me.objects), systemPermissions: me.systemPermissions }); + ``` + + - **Loud, like `can`.** With no `systemPermissions` on the context the call fails (`ok: false`, `kind: 'runtime'`) rather than answering; an empty list is a real answer and evaluates to `false`. A non-list, a non-string or empty name, or a receiver that is not the acting subject fails the same way. A bare `hasCapability(name)` does not resolve. + - **The authoring gate.** The visibility-predicate rule's function-existence check reads the evaluation environment, so a page `visibleWhen` calling `current_user.hasCapability(…)` now passes `os validate`, `os lint` and `os build`, where before it was refused as an unknown function (`visibility-predicate-unknown-function`). An invented sibling (`hasCapabilities`) is still refused. + - **`can` points at it.** `current_user.can(object, verb)` with a verb outside the object-permission vocabulary still fails, and its message now names `current_user.hasCapability('')` for a capability question. + - **Docs.** The page component `visibleWhen` describe names `current_user.hasCapability('')` as the way to gate a component on a capability: `visibleWhen` hides, `requiredPermissions` refuses. + - **Hosts.** A host that evaluates page predicates supplies the list for the predicate to answer; one that does not makes it fail, and a failing page `visibleWhen` renders the component. +- 4ad3ce5: fix(lint, service-analytics): a total over a formula field names the stored-number remedy, not a count + + Clause-②: no + + - **What changed.** A dataset or cube measure taking `sum`, `avg`, `min` or `max` over a `formula` field is still refused (`measure-aggregate-field-type-refused` in `os validate`, `400 DATASET_INVALID` at the dataset compile). The prescription changed. It used to name `count` / `count_distinct` as the way out, and a count read as a total is a plausible wrong number (3 records shown as "total leave days" where the true total is 10). It now reads: "The field is computed on read and stores no value: store the value as a number field (kept by a hook) and aggregate that. `count` / `count_distinct` answer a different question: how many records." + - **One source.** The sentence lives in `@objectstack/spec/data` as `VIRTUAL_FIELD_AGGREGATE_REMEDY`, returned by `virtualFieldAggregateRemedy(aggregate, fieldType)` for a refused value-reading aggregate over a virtual type (`SEARCH_VIRTUAL_TYPES`, which is `formula`). The function returns `undefined` for every other pair. The lint hint, its `os explain measure-aggregate-field-type-refused` entry and the dataset compile leg all print this one sentence. + - **What stays the same.** Nothing is newly accepted or refused. Every other refused pair keeps its own hint, including `count_distinct` over a JSON-stored or `multiple: true` field. The analytics door's cube-query refusal (`400 INVALID_FIELD`) and the engine's `aggregate` door keep their own wording. +- df9d07e: fix(lint): the RLS predicate and org-axis rules judge an object's `defaultRowScope` + + Clause-②: no + + - **What changed.** `validateRlsPredicateEnforceability` now judges `objects[].defaultRowScope.using` and `.check` with the same passes it runs on a permission set's `rowLevelSecurity[]` policy, with the declaring object as the target. A default whose predicate does not lower, does not parse, overruns a parse bound, names a column the object does not declare, reads a `current_user` key nothing resolves, or (for a `select` / `all` read scope) is refused by the engine's filter admission is reported under the same id, at the same `error` severity, at `objects[N].defaultRowScope.`. `validateOrgAxisRedLines` rule ① (`org-axis-permission-inheritance`) also judges a default that reads `parent_organization_id`. + - **A new authoring-time finding on a shape that was silent.** Until now these rules walked `permissions[].rowLevelSecurity[]` only, so `os validate`, `os build` and `os lint` passed such a default. The security plugin's row-policy collection hands an object's default to the same compiler an authored policy reaches, so a predicate that does not lower is dropped at request time and the object reads as zero rows for every caller the default applies to. A stack that declares such a default now fails these commands; the fix is the same rewrite the finding prescribes for a permission-set policy. A default that lowers, and an object with no default, report nothing new. + - **How the finding reads.** `where` names the default and its object (`default row scope "NAME" of object "OBJECT"`), never a permission set. `os explain` for the RLS predicate ids now says what a dropped `using` does for both sources: a permission set's policy denies the holders of that set, and a default, applied only when the caller's sets carry no row policy for the object and operation, denies every caller it applies to. + - **The key's own text.** `ObjectSchema.defaultRowScope`'s description said authoring checks do not judge its predicate; it now says `os validate`, `os build` and `os lint` judge it as they judge a permission set's policy, and that saving the object through the server does not run that check. +- 8eac321: **Shipped in 18.0.0-next.5**, whose CHANGELOG missed this entry; listed here one prerelease late. + + Clause-②: no + + fix(spec): the object-extension refusals for `hooks` and `listViews` say what `defineStack()` does with a hook or view on another package's object + + `ObjectExtensionSchema` refuses `hooks` and `listViews` on an `objectExtensions` entry. Both refusals used to tell the author to declare a top-level hook, or a top-level `view`, "bound to the target object instead". For an object the stack does not define, `defineStack()` refuses both at build, an extended object included, so the route the refusal named could not be taken. + + - **`hooks`** now reads: a hook follows the ownership of its object, so declare it as a top-level hook in the stack that defines the object; `defineStack()` refuses a top-level hook bound to an object the stack does not define, an extended one included. The refusal is the recorded decision of ADR-0130 §1.5 ("the split must follow hook ownership"). + - **`listViews`** now reads: declare the views as a top-level `view` in the stack that defines the target object; `defineStack()` refuses a top-level `view` whose `list` or `form` data source is an object the stack does not define, an extended one included. + - **Unchanged.** What `defineStack()` accepts and refuses, every error code, status and issue path. Only the refusal text moves, with its mirror in the object-extensions guide. +- a5a781b: Clause-②: no + + docs(spec): the `GET /api/v1/ai/models` response JSDoc cites `cloud ADR-0028`, not a bare `ADR-0028` + + - **What changed.** The JSDoc on `AiModelsResponseSchema` and on its `models` field now cites `cloud ADR-0028` (the plan-filtered AI model picker decision) where it cited a bare `ADR-0028`. This repository's own ADR-0028 is about metadata naming and namespace isolation; the AI service that serves this list is a cloud package. The client SDK TSDoc and the docs already cite it this way. + - **Unchanged.** Every schema shape, `.describe()` string, generated JSON Schema and behaviour. +- 191e043: feat(plugin-webhooks,runtime)!: declared webhooks are read from the registry — the boot seeder retires, no `sys_webhook` row is written for one, and it is switched through the activation ledger + + Clause-②: yes (narrowing) + + + + **BREAKING** (a narrowing of where a declared webhook can be edited), shipped as `minor` under the launch-window convention for breaking changes. + + ADR-0131 D2 says no seeder writes declared items into a table, and D6 charts behavioural types under regime C. A webhook declared with `defineStack({ webhooks })` now follows both, as declared sharing rules already do: the dispatcher reads the declaration from the metadata registry on every subscription-cache refresh, and `bootstrapDeclaredWebhooks` no longer runs. + + What changes: + + - **No `sys_webhook` row for a declared webhook.** Before: every boot wrote or refreshed one `managed_by: 'package'` row per declared webhook, shown and editable in Setup → Integrations → Webhooks. Now: a fresh install writes none, and Setup lists only the webhooks an organization authors. The declared webhook still delivers, with the same URL, method, triggers, `timeoutMs`, signing secret and headers; its secret and headers are code, held in memory, and never stored. Its deliveries carry `ref_id = declared:`. + - **A declared webhook is read-only, and switched through the activation ledger.** Before: an administrator edited the seeded row in Setup (`customized: true`). Now: switch it on or off for the deployment with `POST /api/v1/security/_activation/webhook/` and `{ "enabled": true | false }` — one `sys_metadata_activation` row of type `webhook`, which overrides the declared `isActive` in either direction, so a webhook shipped inactive is switched on the same way. The door is the declared-sharing-rule door with `webhook` admitted: `manage_metadata`, then the platform operator under a wall; `404` for a name nothing declares; `503` when no declared-webhook reader is bound or it cannot be read. The dispatcher refreshes in the request that wrote the row. Remedy for a change beyond on/off: author a copy under a new name as a `sys_webhook` row, and switch the declared one off. + - **`label` and `description` on a declared webhook are no longer shown anywhere.** The seeded row was their only reader. They are display-only, so no delivery changes; the liveness ledger marks them dead, owed enforce-or-remove. + + On a database an 18.0 install created (expand first — nothing is moved or deleted): + + - The rows the 18.0 seeder wrote stay where they are. A pristine one (`managed_by: 'package'`, not `customized`) is ignored in favour of its declaration, so a declared webhook is never delivered twice; it follows the declaration's URL and switch, as the seeder's rewrite on every boot made it do. + - A seeded row an administrator edited (`customized: true`), or a row an administrator authored under a declared name, answers as itself, and the declaration is not dispatched beside it — an administrator's `active: false` on a noisy webhook keeps holding. This is logged at `warn` once per name per process, with the remedy. + - A seeded row whose declaration was removed keeps delivering, as it did on 18.0. + - Redelivering an old delivery of a pristine seeded row is judged by the declaration that now answers for it, so a webhook switched off through the ledger is not re-sent. + + Deleting the seeded rows, and re-pointing their `sys_http_delivery` history at the declaration, is a step of the ownership migration in a later 18.x release. +- a789148: fix(spec): the 18.0 upgrade-guide entries that name `os migrate organization-ownership` say first that 18.0 supports fresh installs only + + Clause-②: no + + In 18.0, `os migrate organization-ownership` refuses plan and apply alike on every database (exit 1, `NOT_IMPLEMENTED`, nothing read or written). Seven ADR-0087 semantic entries still told an operator to run `--out PLAN`, then `--apply --plan-file PLAN`, as if 18.0 upgraded a v17 database. Each now says, in the same words as the CLI and the boot refusal: 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path. The plan and apply steps stay, marked as the 18.x release's ceremony. + + - `sys-metadata-organization-column-retired`, `sys-metadata-audit-organization-column-retired`, `sys-metadata-commit-organization-column-retired`, `sys-metadata-history-organization-column-retired`, `ups-permission-set-id-column-retired`: the replacement and the acceptance criteria. + - `sharing-rule-declared-mirror-retired`: the replacement and the acceptance criteria. + - `cli-organization-ownership-apply-plan-file-required`: the replacement now says that 18.0 refuses this invocation, as it refuses every invocation of the command, and keeps the plan-file rule as the 18.x release's. + + Only the text changes. Every entry keeps its `id`, its surface and its step. +- dac99c2: feat(plugin-security)!: the permission-set projection, its seed-name lookup and refusal sink are deleted (ADR-0131 D3, C3 stage 7a) + + Clause-②: no (narrowing) + + + + **BREAKING** (retired exports of `@objectstack/plugin-security`), graded `minor` on the v18 prerelease line. + + Stage 8 stopped registering the `sys_permission_set` row object, the `permission` mutation projector and the data-door write-through. This change deletes the modules behind them: `permission-set-projection`, `seed-name-lookup` and `seed-refusal-sink`. A permission set is written through the metadata door alone (`PUT /api/v1/meta/permission/:name`), and the security catalog serves it by name. + + What a consumer sees: + + - FROM `createPermissionSetWriteThrough(deps)` TO nothing: no `sys_permission_set` data-door write exists to redirect. Write the set through the metadata door. Delete the call. + - FROM `registerPermissionSetProjection(protocol, deps)` TO nothing: no row mirrors a definition. Delete the call. + - FROM `reconcilePermissionSetProjection(...)` TO nothing: there is no projection to reconcile at boot. Delete the call. + - FROM `projectPermissionMutation(...)` TO nothing. Delete the call. + - FROM `upsertEnvPermissionSet(...)` TO `PUT /api/v1/meta/permission/:name`, or the metadata protocol's `saveMetaItem({ type: 'permission', name, item })`. + - FROM `permissionSetBodyFromRow(row)` TO `permissionSetDefinitionFromRow(row)` from `@objectstack/core`: the same mapping, for converting a stored `sys_permission_set` row into its definition. + - FROM `permissionSetRowFields(definition)` TO nothing: no row is written. Delete the call. + - FROM the types `PermissionSeedOutcome`, `ProjectionDeps`, `ProjectionReconcileOutcome` and `WriteThroughDeps` TO nothing. Delete the import. + + Unchanged: the security catalog's echo of a metadata-door permission set or position on an environment kernel, which lives in its own module since #23308. The packaged-permission-set lock still refuses a metadata-door save of a set a code package ships (`403 NOT_OVERRIDABLE`), and the boot still reports environment overlays that shadow such a set. `OverlayDetectionOptions.logger` now asks only for `warn`, the one channel the reading reports on. + + **`@objectstack/spec`** (patch): the `permission.tabPermissions` liveness entry no longer cites the deleted projection, and the liveness README's registered-but-unbound `permission-set-projection` proof row is retired with the invariant it described (no `@proof` tag carries it any more). +- 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`. +- 0253192: The TSDoc on `RecordRelatedListProps.add` names its canonical example by name: "Assigned Users" on a permission set keys `sys_user_permission_set` on `permission_set` with `relationshipValueField: 'name'`, since a grant names its permission set (ADR-0131 D4). Text only; no schema changes. +- 7aa8d51: feat(plugin-security): the boot no longer writes `sys_capability` rows for the platform's curated capabilities + + Clause-②: no + + The curated and derived-default capability seeder (`bootstrapSystemCapabilities`) is deleted. On a fresh database the boot writes no `sys_capability` row for the nine curated platform capabilities (`manage_users`, `manage_org_users`, `manage_metadata`, `manage_platform_settings`, `setup.access`, `setup.write`, `studio.access`, `manage_sharing`, `view_all_audit_log`), and none for a name a permission set grants in `systemPermissions` without declaring it. The curated capabilities are served by the registry: `GET /api/v1/meta/capability` lists them and `GET /api/v1/meta/capability/:name` answers each one's definition, as before. Declare a capability your package grants with `defineCapability` (`stack.capabilities`); a declared capability is still seeded as a package row. + + No authorization decision changes: a grant is still resolved by the capability name in `systemPermissions`, and no runtime check reads a `sys_capability` row. On a database that was already seeded, the existing rows stay as they are; they are no longer refreshed at boot. + + In `@objectstack/spec` (`PLATFORM_CAPABILITIES`) and `@objectstack/lint` (`validateCapabilityReferences`), the doc comments now name the registry instead of the retired seeder; no behaviour changes there. +- 95a8435: feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted + + Clause-②: no + + `SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served. + + A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run. + + In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged. + + Removed from `@objectstack/plugin-security`'s public exports, with no replacement: + + - `CAPABILITY_NAME_COLLISION` + - `capabilityNameCollisionDiagnostic` + - `formatCapabilityNameCollisionDiagnostic` + - `reportCapabilityNameCollisions` + - `CapabilityNameCollisionDiagnostic` (type) + + FROM → TO: + + - FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`. + - FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place. +- d112087: docs: the suggested-binding acceptance is deployment-level and takes effect + + Clause-②: no + + Text only. These are the published sentences that describe what confirming a package's `isDefault` suggestion does. They now match the behaviour `@objectstack/plugin-security` ships in this release. + + - `@objectstack/spec`: `ISecurityService.listAudienceBindingSuggestions`, `confirmAudienceBindingSuggestion` and `dismissAudienceBindingSuggestion` keep "writes the binding" for the confirm. The text now also says what that means: a `manage_metadata` holder accepts once for the deployment, `everyone`'s definition is derived again, and a dismiss on an accepted suggestion revokes it. The package-level `isDefault` note keeps "an admin confirms in Setup" and adds who that admin is. + - `@objectstack/client`: the `security.suggestedBindings.confirm` and `.dismiss` TSDoc. + - `@objectstack/rest`: the route summaries of the confirm and dismiss routes. Paths and envelopes are unchanged. + - `@objectstack/cli`: the ownership-inventory citation for `sys_audience_binding_suggestion` now says the record is deployment-level and owned by the Default Organization. +- 7de0265: feat(plugin-email)!: email templates resolve from the registry; the built-in seed, the declared-template boot sweep and the live projector retire + + Clause-②: yes (narrowing) + + + + **BREAKING** (retired exports and plugin options of `@objectstack/plugin-email`), graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, so this ships in an `18.0.0-next.N`. It carries ADR-0131 D6/D10 and the maintainer's ruling C on ADR-0131 §6 Q1. + + **What changes.** + + - **Templates resolve from the registry.** `sendTemplate` and `renderTemplate` read `email_template` metadata by `(name, locale)` at every send: the protocol's layered list (`GET /meta/email_template`'s items — the environment's item over each code package's declaration), or the ObjectQL registry on a host with no metadata door. The locale ladder is unchanged: the named locale exactly, then `en-US`, then — only for a call that named none — the bundle's lowest locale tag. + - **The built-in auth templates are code-declared items** of the email plugin's own package (`com.objectstack.service.email`), registered at plugin init, so they now appear in Studio's email-template list. An app that declares the same `(name, locale)` wins over the built-in; the built-in still serves the locales the app leaves out; a Studio save overrides either at environment level. Every built-in renders byte-identical to the row it used to be seeded into, in every locale (pinned on the rendered subject, HTML and text). + - **A Studio save is live on the next send**, with no projection and no restart. The `PUT /meta/email_template/:name` response no longer carries `projectionApplied`, because nothing is projected. + - **No `sys_email_template` row is written at boot or read at send.** The table and its rows stay; the organization door stays closed. + - **A malformed template fails its send loudly** with the schema's own message, instead of falling through to the built-in copy of the same slot. + + **What moves for consumers.** + + - FROM `createSysEmailTemplateLoader(engine, options?)` TO `createRegistryEmailTemplateLoader(sources)`, where `sources` is `{ protocol?, registry? }` (type `EmailTemplateRegistrySources`) or a function returning it. FROM the types `TemplateLoaderEngine` / `CreateTemplateLoaderOptions` TO `EmailTemplateRegistrySources`. + - FROM `bootstrapDeclaredEmailTemplates(...)`, `upsertDeclaredEmailTemplate(...)` or `deactivateDeclaredEmailTemplate(...)` TO nothing: templates are not copied into rows any more. Delete the call. + - FROM `mapTemplateToRow(tpl)` TO nothing: there is no row projection. To resolve a template the way the mailer does, call `createRegistryEmailTemplateLoader(...).load(name, locale)`. + - FROM `EMAIL_TEMPLATE_OBJECT` TO the literal `'sys_email_template'`, only if you still address the legacy table. FROM the type `BootstrapDeclaredEmailTemplatesResult` TO nothing. + - FROM `new EmailServicePlugin({ seedTemplates: false })` TO `new EmailServicePlugin()`: there is no seed to switch off. Delete the option. + - FROM `new EmailServicePlugin({ templates: [...] })` TO the stack's `emailTemplates: [...]` (or a `*.email-template.ts` file): declared templates resolve from the registry. Delete the option. + + **What a deployment observes.** A `sys_email_template` row an organization customized before its door closed is no longer what sends: the template's code declaration (or an environment-level Studio save) is. The v18 migration ceremony promotes those rows to environment-level templates; to make such an edit live before it runs, re-save the template in Studio. + + **`@objectstack/spec`** (patch): the `email_template` liveness ledger is re-anchored to the registry loader, and `category` and `isSystem` are graded `dead` — their only consumer was the retired row projection. The `notify` node's `template` description and refusal messages, and the `IEmailService` contract's documentation, name the email templates rather than `sys_email_template` rows. No schema shape changes. + + **`@objectstack/service-automation`** (patch): the notify node's `template` help text served at `GET /api/v1/automation/actions` names the email templates rather than `sys_email_template` rows. +- 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 + ``` +- 7958bdf: No flow position reads the single-brace `{…}` template dialect any more, and its resolver is deleted (ADR-0032 Sequencing 6: "delete the single-brace resolver"). `AutomationEngine.evaluateCondition` evaluates every condition as CEL: a condition carrying a `{var}` hole is refused as the CEL brace trap, naming its source, and a `template` or `flow` envelope is refused before evaluation, where the deleted path substituted the variables and compared the text. A literal in a value slot is handed on as written: a `Date` in a code-defined flow reaches the write as the `Date`, where the deleted interpolator wrote `{}`. + + Clause-②: no (narrowing) + + + + **BREAKING**: a narrowing of a published runtime method and of published lint exports, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 3 keeps one template delimiter, `{{ }}`, and its Sequencing 6 deletes the single-brace resolver. The earlier changes on this line retired the dialect position by position — every value slot, the text slots, each CRUD filter operand — so the resolver's last readers were two calls that saw literals only and a condition path that only a direct call reached. + + **What changes.** + + - **Conditions.** `AutomationEngine.evaluateCondition(condition, variables, context?)` reads every condition as CEL — bare text, an envelope with `dialect: 'cel'`, or one naming no dialect. The template path it kept for a string carrying a `{var}` hole is deleted: such a string is refused as a CEL fault that names its source and says not to wrap field references in `{…}`, and an envelope with `dialect: 'template'` or `dialect: 'flow'` is refused before evaluation. `registerFlow` already refused both at every condition slot (a start node's `condition`, an edge `condition`, a `decision` branch's `expression`, a screen field's `visibleWhen`), so no registered flow changes. What changes is a direct caller: a plugin calling the method, or `@objectstack/verify`'s `stack.automation.evaluateCondition`, which hands the condition over as given. + - **Value-slot literals.** A literal in a value slot — a `fields`, `input` or `templateData` value, an `assignment` value in any of its three shapes, a `notify` recipient, an `http` `body`, a screen's `defaults`, `defaultValue` or `recordId` — is handed on as a copy of what was written (arrays and plain objects copied, every other value as it is). The deleted interpolator walked every object's own entries, so a `Date` written in a code-defined flow reached the write as `{}`; it reaches it as the `Date`. + - **`@objectstack/lint`.** `validateFlowTemplatePaths` reads the `{{ }}` holes of a node's strings and no single-brace token; a CRUD node's `filter` is not scanned, since each operand is a value slot that renders no template. So `flow-template-unknown-field` and `flow-template-lookup-traversal` report only `warning` now: their `error` at a filter operand, and every finding on a single-brace token, are gone, and the value-slot and text-slot judges refuse those tokens at the same doors as `expression-invalid`. Messages and hints spell a hole `{{ record.x }}`. `flow-double-brace-interpolation` and `flow-bare-dollar-reference`, at a string outside every value slot, text slot and declared literal (a plugin node's own config, a `connector_action` input), say the value is read as written, where they prescribed a `{var}` token nothing reads. The lint's mirror of the retired grammar is removed with its exports. + - **`@objectstack/spec`.** The `FlowSchema` example's `connector_action` input holds a literal, since nothing reads a token there. The upgrade guide's step-18 text-slot entry says no other flow string reads the single-brace dialect, where it said every other one kept it, and its value-slot entry names every value slot the retirement reached on this line, where it named only the `assignment` values and the CRUD `fields` map. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `engine.evaluateCondition('{amount} > 100', vars)` | `engine.evaluateCondition('amount > 100', vars)` | the template substituted text and compared it; CEL compares the values and fails the run on an absent variable | + | `"{status} == 'open'"`, `'{status} == open'` | `"status == 'open'"` | a string literal is quoted | + | `{ dialect: 'template', source: "{flag} == true" }`, `dialect: 'flow'` | `{ dialect: 'cel', source: 'flag == true' }` | — | + | `classifyFlowTemplateToken(inner)` (`@objectstack/lint`) | `valueSlotTemplateRefusals(value)` / `textSlotTemplateRefusal(text)` (`@objectstack/spec/automation`), which refuse a token with the spelling that replaces it | the grammar it classified is read by nothing | + | `FLOW_TEMPLATE_DATE_FUNCTIONS`, `FLOW_TEMPLATE_VALUE_FUNCTIONS` | the CEL stdlib: `isoDate(today())`, `isoDatetime(now())`, `round`, `floor`, `ceil`, `abs`, `min`, `max` | — | + | `FlowTemplateTokenVerdict` | nothing: the judges' reading of the retired grammar is internal to `@objectstack/spec` | — | + + **The one-line fix: write a condition as CEL, without `{…}` around a reference.** + + **Who is affected, measured.** In this repository at the base: no braced condition and no `template` or `flow` condition envelope in `examples/`, the docs or any package source; the deleted path's own pins in `@objectstack/service-automation` (`engine.test.ts`, `nested-region-parity.test.ts`) and `@objectstack/verify`'s door test are re-pinned to the refusal here. No authored `Date` literal in a flow was found; a compiled artifact serialises one to text, so only a flow handed to `registerFlow` in code carried one. Neither repository imports the removed lint exports. hotcrm at `0f84c9c` carries no braced condition and no `connector_action` input; its flows still author the single-brace dialect at value and text positions earlier changes on this line refuse, which its own upgrade migrates. + + ### The kit + + - **The engine.** `AutomationEngine.evaluateCondition` (`@objectstack/service-automation`): the template path, its hole discriminator and its text comparison are deleted. The value-slot resolvers' literal arm and the `assignment` executor copy a literal (`copyLiteral`, `builtin/value-slot-map.ts`). The single-brace interpolator (`interpolate`, `interpolateString`, `resolveToken`, its function table and `FlowExpressionFunctionError`), which the package did not export, is deleted with its tests. + - **The lint.** `validateFlowTemplatePaths` and the two wrong-syntax hints (`lint-flow-patterns.ts`); `classifyFlowTemplateToken`, `FLOW_TEMPLATE_DATE_FUNCTIONS`, `FLOW_TEMPLATE_VALUE_FUNCTIONS` and `FlowTemplateTokenVerdict` are removed. + - **The verify handle.** `stack.automation.evaluateCondition`'s documentation states the engine's reading: CEL, a `{var}` hole refused. + - **ADR-0087.** No new entry: the step-18 D3 entries `flow-value-slot-template-dialect-refused` and `flow-text-slot-single-brace-refused` record the retirement. +- 0af4f66: Three `ComponentPropsMap` read-point records now quote the objectui line they cite + + Clause-②: no + + The docblocks of `action:button`, `action:icon` and `element:definition-list` in + `src/ui/component.zod.ts` each quote the first line of the row's props-read site, re-read + against objectui at the pin this package builds against (`a58626c88`): the runner-forward + literal of the two action blocks, and the `readProps` call of the definition list. A pin bump + that moves one of those lines, or changes it, now fails `check:objectui-pin-citations` and + names where the quoted line went, where before only the cited sha was checked. The other + three rows of that section (`action:group`, `action:menu`, `element:repeater`) carry no quote + yet. Comment text only: no schema, key, type or export changes. +- 9a0401f: The `action:group`, `action:menu` and `element:repeater` read-point records are re-measured at the objectui pin and quote the line they cite + + Clause-②: no + + The docblocks of `action:group`, `action:menu` and `element:repeater` in `src/ui/component.zod.ts` + cited objectui lines that had moved without any gate noticing: the two containers' anchors by 3 to 8 + lines since objectui#11638, every repeater anchor by 28 lines since objectui#11168 slice 2, and the + repeater's `data-objectstack` filter and sort anchors since earlier pins. Each is now re-pointed at + the pin this package builds against (`a58626c88`), as are `action:button`'s `static-params.ts` + citation and the `element:definition-list` registration notes, which said the registration + publishes the strings `'1'` / `'2'` and marks `items` required (it no longer does either). Each of + the three rows now quotes the first line of its props-read site (the member forward of the two + containers, the `readProps` call of the repeater), so a pin bump that moves or changes one of those + lines fails `check:objectui-pin-citations`. All six rows of that section now carry a quote. Comment + text only: no schema, key, type or export changes. +- 9c93ce6: Source comments that cited a tracker number now say in words which decision they mean, and the pin tests that read those comments match the new words + + Clause-②: no + + The package ships its `src/**/*.zod.ts` sources, and `dist` carries the member docblocks. Four of those texts named a decision only by its tracker number. Each now names the decision in words. The pin test that reads each text matches the new words, at the places the old number stood: + + - `ai/build-progress.zod.ts`: the cloud card that ruled the build-progress phase vocabulary in, and that owns where the emitter sits, is now "the cloud build-progress card" in all seven places. The consumer's strict-parse carrier is "block 2 of the objectui build-panel card". The `ai/build-progress` reference page is regenerated from the module docblock, so its description now ends with "(the cloud build-progress card, ruling A)". + - `contracts/approval-service.ts`: the `IApprovalService.continueRestoredRun` docblock says the verb is declared "exactly as the automation-service contract declared" `IAutomationService.cancelRun` / `restoreConsumedSuspension`. + - `ui/notification.zod.ts`: the tombstone note for the removed `./ui` `Notification` / `NotificationConfig` wrappers is labelled `[dual-source removal]`. The `NotificationAction` removal is called "the no-door retirement". + - `ui/sharing.zod.ts`: `EmbedConfigSchema` is recorded as removed "at the no-door retirement (ADR-0049 enforce-or-remove)". The `ui/sharing` reference page is regenerated from the module docblock. + + Comments and docblocks only. No key, type, default, accepted value, `.describe()` string or export changes. +- 04e776b: `App.defaultAgent` docblock: the agent route is the one chat door, and the console is what reads the key. + + Clause-②: no + + - The docblock no longer says the assistant chat endpoint (`POST /api/v1/ai/assistant/chat`) resolves this agent from `context.appName`. That route, with `GET /api/v1/ai/assistant` and `GET /api/v1/ai/assistant/skills`, was retired in the cloud AI runtime (objectstack-ai/cloud#2621, objectstack-ai/cloud#2651), and no server route reads `defaultAgent`. + - It now says who does read it. The console's chat dock hands the active app's `defaultAgent` to its one surface-to-agent resolver, which honours only `ask` or `build` (legacy aliases included) and otherwise falls back to the surface default. The resolved agent is then called by name on `POST /api/v1/ai/agents/:agentName/chat`, where the path segment, not this key, selects the agent. + - The ADR-0063 surface-binding paragraph and the rule that only the two platform agents resolve are unchanged, as is the note that the bare `POST /api/v1/ai/chat` resolves no agent. + - The docs page `ai/actions-as-tools` lists the agent route as the only in-product chat route. + - ⛔ No schema, parse, `.describe()`, export, type or accept-set change. The docblock ships in the published package, in the `dist/ui` and `dist/browser` JavaScript bundles and in the shipped `src/ui/app.zod.ts`, which is why this is a patch. +- a7df552: `ObjectNavItemSchema.viewName`'s describe no longer says the default is "all". It now states what the console does when an object nav entry names no view: it opens the object's default list view, else its first declared list view. `all` is only the console's fallback tab, and it exists only for an object that declares no list view. + + Clause-②: no + + - The rule is read from objectui at the `.objectui-sha` pin. `ObjectView` opens `defaultViewId || views[0]`, where `defaultViewId` is the view `buildViewTabs` marks `isDefault` (the default `list`). `buildViewTabs` adds the `all` tab only when the object has no list view at all. + - An author who omitted `viewName` expecting all records got that default or first declared view instead. When the object declares more than one list view, name the one the entry should open in `viewName`. + - The generated app reference page follows (#21973). The lint header that quoted the old sentence follows too, as a comment only. + - ⛔ No schema, type, optionality, default, export or accept-set change. The console's behaviour does not change. +- d5a14dd: `EvalUserSchema.isPlatformAdmin` is no longer marked deprecated. Its describe and docblock now say what the key reports: the `PLATFORM_ADMIN` standing of ADR-0095 D3. + + Clause-②: no + + - The platform resolves that standing per request, from the deployment's declared administrator list (`OS_PLATFORM_OWNER_EMAIL`) under every tenancy posture, or from an unscoped `admin_full_access` grant under the `single` posture. + - It is the predicate platform-operator gates read: `current_user.isPlatformAdmin == true` (ADR-0068 D4). The session payload emits it from the posture rung, and the platform-admin route gate reads it. + - The resolver projects the `platform_admin` name into `positions` from the same grant, so the name and the key agree for every genuine administrator. Gate on the key, never on `'platform_admin' in current_user.positions`. + - The old text, "DERIVED alias of 'platform_admin' in positions. Deprecated.", described a reading ADR-0095 D3 superseded. ADR-0068 carries dated notes under D2 and D4 that say so. + - ⛔ Nothing you author changes. There is no schema, type, optionality, default, export or accept-set change, and `createEvalUser` computes exactly what it did. A predicate that already reads `current_user.isPlatformAdmin` keeps working and is the supported form. +- 93125ae: A browser bundle that imports from `@objectstack/spec/shared` or the package root no longer keeps the ADR-0087 conversion table unless it uses it + + Clause-②: no + + Each published entry is one flat file, so a consumer's bundler keeps every top-level call it cannot prove pure, together with everything that call references. Two such calls built the conversion table when the module loaded: the major-18 list's `inApplicationOrder(...)` and the flattening into `ALL_CONVERSIONS`. So every bundle of an entry that reaches the table kept all of it: every conversion, plus the view, field, page-component, dashboard, chart and report schemas the conversions read. `./shared` reaches the table only through `normalizeStackInput`, so a bundle that imported an expression schema from it carried the table too, and 17.7.0's new conversions made that copy larger. Both calls now carry a `@__PURE__` annotation, so a bundle keeps the table only when something it keeps reads it, for example `defineStack`, `normalizeStackInput` or `applyConversions`. + + Measured on objectui's console (objectui `c0862c1c`), against the same build with this package's previous source: the first screen's eager closure is 226,238 bytes gzip smaller, all of it in the `vendor-objectstack` chunk. Every entry's export list, every declaration and every runtime value is unchanged. Only the bytes a bundler keeps change. +- d7c5c33: The `sharing.publicLink` describe says the value is a slug and names where it is served + + Clause-②: no + + `FormView.sharing.publicLink` described itself as a "Generated public share URL". It is + neither: the author chooses it, nothing generates it, and the server reads it as a slug, where + `/forms/x`, `forms/x` and `x` name one slug, `x` (`publicFormSlug`). The describe now says so, + and names where an anonymous visitor reaches the form while `enabled` and `allowAnonymous` are + both true: the REST form door `GET /api/v1/forms/:slug` (on the default API base), and, on a + host that serves the console, the console page `/_console/f/:slug`, with `/forms/:slug` + redirected there and its query string carried. The generated `ui/sharing` and `ui/view` + reference pages carry the new text. Describe text only: the accepted values, keys, types and + exports do not change. +- 15ec50e: A notify flow node's `title` / `message` refusal now names the key and prescribes `'{{ record.name }}'`, the `{{ }}` hole the notify executor renders. It used to answer with the shared template sentence, which names no key. + + Clause-②: no + + - Both slots take the same input as before: a bare, non-blank string or a `{ dialect: 'template', source }` envelope. Every value that parsed still parses, every value that was refused is still refused, with the same `invalid_union` code at the same path. Only the sentence changes. + - A blank bare string, a number, a non-template envelope and the like at `title` or `message` are refused with a sentence that names the key, prescribes `'{{ record.name }}'` or `{ dialect: 'template', source: '{{ record.name }}' }`, and says why: the notify executor renders `{{ }}` template holes, and a single-brace token is not one. The branch issue that `formatZodIssue` and the API error mapper print beneath it carries the same sentence. The build's flow judge (`FlowSchema`, flow registration, `os validate`) quotes the new sentence. + - Every other template slot keeps its sentence, which still prescribes `'{{record.name}}'`. That covers `titleFormat`, the prompt template's `system` / `user`, and any slot typed `TemplateExpressionInputSchema`. `TYPED_EXPRESSION_SOURCE_REQUIRED.template` and `TYPED_EXPRESSION_DIALECT_ONLY.template` are unchanged. + - The `tmpl` docblock no longer calls the envelope "Mustache" or shows only `{{record.x}}`. It now says which renderers read which braces: `{{record.x}}` for the formula template engine, the messaging, email and i18n renderers and a notify node's `title` / `message`, and either for `titleFormat`. The `TemplateExpressionInputSchema` docblock lists the notify slots the same way, and the generated expression reference page says a template slot's fix is written in the spelling its renderer reads. + - No export is added, removed or renamed, and no type changes. The notify slots take the same input as `TemplateExpressionInputSchema` from a constructor that stays internal to the package. +- 51290bc: Form help and refusals an author reads no longer carry service-interface names, ruling dates or another product's ids + + Clause-②: no + + Wording only: no schema, key, type, export or error-code change. + + - The notify node's Template help (the `NotifyConfigSchema.template` describe and the Studio + inspector's copy in `@objectstack/service-automation`) names the deployment's default locale in + product words instead of `II18nService.getDefaultLocale()`, and drops its ruling date. + - `MANIFEST_ID_EXAMPLES` is now `com.acme.crm` and `org.example.help-desk` (was `com.steedos.crm` + and `org.apache.superset`). The package-id refusal opens with the headline + "Invalid package id 'VALUE'." and names the key in the sentence after it, so the headline alone + carries no JSON path; the rule, the examples and the suggestion follow unchanged in substance. A + caller that matched the old "on KEY. Expected reverse-domain notation" wording matches the + headline, or compares against `manifestIdRefusal()` by reference, instead. + - Ruling dates leave the describes Studio renders as form help: field `required` and `multiple`, + form-view field and section `visibleWhen`, section `collapsible` / `collapsed`, the redirect + arm's `submitBehavior.url`, and page `kind` / `source` (the ADR citations stay). They also + leave the refusals for padded grouping field names, `submitBehavior.url`, `features.*` in a + form-view predicate, and the four filter comparand refusals (null ordering comparand, + `{ $field }` in a list position, null list member, blank `$between` bound). Each sentence still + states the rule, why it exists and the repair. + - The email-template form's Identity section help says how senders address a template instead of + naming `IEmailService.sendTemplate`, in all four shipped locales. + - The action `description` help no longer ends in a dangling dash left behind by an earlier + strip: "(one dialog, not two —)" now reads "(one dialog, not two)". +- ad381fd: The form view `submitBehavior` property help no longer ends in a ruling date (#22093) + + Clause-②: no + + Studio, the reference docs and the published `objectstack-ui` skill show this describe as the property help. It still says that on the `redirect` arm `url` is relative-only, interpolates only declared record fields as `{{record.field_name}}` and is URL-escaped; the "(ruled 2026-08-11)" parenthetical is gone, and the ruling's date and tracker reference live in the code comment above the describe. Wording only: no key, type, accept/reject or export change. The `react-blocks.md` row and the `view` / `protocol` reference pages are regenerated from it. +- 3b49318: fix(meta): a second unpinned draft save of a package-owned item is no longer refused 409, the item read serves that draft's `version`, and a package-less draft save keeps a package-owned datasource's stored credential + + Clause-②: yes (widening) + + - **The second draft save is accepted.** Save an item into a package (`PUT /api/v1/meta/:type/:name?package=`), then save a draft of it without `?package=` twice. The repository stores that draft in the item's package (the draft inheritance that keeps a pending change in the package it edits). The save door took its expected parent from the package-unbound draft row instead. It found none, so the second save, which sent no `If-Match`, was refused `409 METADATA_CONFLICT` ("Expected parent null but current is hmac-sha256:…"). ADR-0008 makes an unpinned save last-writer-wins, and it is accepted again. + - **One head row per write address.** `SysMetadataRepository.headAt(ref, { state, packageId })` answers `{ head, packageId }`: the row a `put` at that address upserts (`null` for a create), and the package binding that put writes under. Both come from the same resolution `put`'s optimistic lock runs. That resolution includes the draft inheritance and the adoption of a draft stored without a package. The save door's expected parent and the item read's `version` both come from it, so the token `GET /meta/:type/:name?state=draft` serves is the token a draft save at the same address accepts. Before this, that read served `version: null` while the inherited draft existed. + - **`?package=all` on the item read.** `GET /meta/:type/:name?package=all` now reads `all` as the save and publish doors read it: as naming no package. Before, the read forwarded the literal `all`. Its `version` was resolved at a package no save writes, so it served `null` beside a row the save then judged. Its `?state=draft` read answered `404 NO_DRAFT` for a draft stored in the item's package. The read now serves the item as a read with no `?package=` serves it, and `?package=all` still takes the uncached arm, so it keeps serving `version`. + - **The credential carry-forward reads the same row.** A save of a type whose read withholds a credential (`datasource`; `flow` with the automation plugin) carries the stored credential back into a body that returns as it was served. It compared that body against the row at the key the caller named. A package-less draft save of a package-owned item therefore found neither the inherited draft nor the package's active row, and compared against the code layer. The stored credential was persisted away on the first such save, measured on the real route with a datasource whose `config.url` holds one. Both reads now go through `headAt`: the draft the save overwrites, or with no draft yet the active row in the package the draft is stamped into. An item with no package binding is unchanged. + - **Descriptions only, in `@objectstack/spec`.** `SaveMetaItemRequestSchema.packageId` now states the draft inheritance, where it said "absent = env-local overlay". The `POST /meta/:type/:name/publish` route description spells the conflict code `METADATA_CONFLICT`, as the wire sends it. + - A stale `If-Match` is still refused `409`, and `If-None-Match: *` is still refused while a row exists. An item with no package binding is unchanged. No spec key, request field or response field is added or removed. + - The only change to the public surface is one added method on the exported `SysMetadataRepository`: `headAt`. No existing signature, schema or accept set changes. +- 9fe7448: fix(spec, lint): the functional-completeness findings print one verdict line, and `os explain ` carries their reasoning + + Clause-②: no + + - **Shorter verdicts.** The nine rule ids the ADR-0078 completeness predicate emits (`field/summary-without-operations`, `field/formula-without-expression`, `field/relationship-without-reference`, `field/choice-without-options`, `view/layout-without-binding`, `view/tree-without-parent-field`, `view/row-color-without-colors`, `view/row-color-unresolvable-value`, `webhook/without-triggers`) printed a `message` of 239 to 793 characters. Each `message` is now one verdict sentence of at most 200 characters (for `view/row-color-unresolvable-value`, plus the offending `key` = "value" entries it lists). It still names the runtime site that makes the verdict true, for example `` A `summary` field with no `summaryOperations` reads 0/null everywhere: the engine's summary index skips it (`engine.ts` — `if (!d.summaryOperations) continue`) ``. The `fix` (the CLI's `fix:` line, the runtime issue's `hint`) is unchanged, and so are every rule id, severity, `path`, and what each rule accepts or refuses. For `view/layout-without-binding`, the keys the old body told the author to declare are on that `fix:` line. A tool that matched the old message text should match on `rule` and `path` instead. + - **`os explain ` takes these nine ids**, for example `os explain view/layout-without-binding`. It prints the reasoning the verdicts no longer carry: each view type's measured renderer path, refusal screen and required keys, the exemptions (`user` relationships, `multiselect`, `gallery`), the hex trap behind the two `rowColor` rules, and why an empty `triggers` is not an off switch. The `rule:` line under each of these findings now ends with `` — `os explain ` for … ``. The no-argument listing and its `--json` `rules` array list the nine ids, and so does the unknown-id error's `Rules with an explanation:` line. `RULE_EXPLANATIONS` in `@objectstack/lint` gains the nine entries. + - **Where the new text prints.** On the CLI, `os validate`, `os build` (and `os compile`, which `os dev` runs on every compile), `os lint`, `os verify` and `os init`'s scaffold check print the new `message` on the text face. `os validate --json` and `os build --json` carry it in their `warnings` and author-time `issues`. At the runtime publish gate, the rule runs on `object` writes only (Studio, REST `/meta`, MCP), so only the `field/*` ids reach it. A `summary` field with no `summaryOperations` or a `formula` field with no `expression` is an `error`: the 422 issue's `message` changes, and so does the server's refusal log line under the unlinted-writes hatch. A `checkboxes` field with no `options` is a `warning`: the `message` in the 2xx response's `advisories` changes, and so does the deduped `[Protocol] authoring advisory` server log line. Each issue's `hint` is unchanged. A `lookup`/`master_detail` with no `reference` and a `select`/`radio` with no `options` never reach this gate: the object schema's own parse refuses them first, with its own message, which this change does not touch. + - **Unchanged doors.** The registry's boot-time line (`[Registry] Object "…" registered with N functionally-incomplete field(s) …`) prints each finding's rule id and `fix`, never its `message`, so it reads as before. The webhook auto-enqueuer's `webhook/without-triggers` boot warning has its own text, also unchanged. The `view/*` and `webhook/*` ids do not run at the runtime publish gate. +- 2e11d90: docs(spec): `ApproverType`'s description points at `os explain approval-approvers-may-resolve-empty` for the graded `manager` routes + + Clause-②: no + + - **What changes:** the `.describe()` text of `ApproverType` (`@objectstack/spec/automation`), which the generated reference page renders for every approver entry (`content/docs/references/automation/approval.mdx`). It said `os lint` "carries the graded population routes and the full remedy". Since `@objectstack/lint` shortened `approval-approvers-may-resolve-empty`'s fix line to one sentence, the finding names two routes in one line, and `os explain approval-approvers-may-resolve-empty` prints the graded routes and the full remedy. The description now says so. Its "the request waits" clause now says the request falls to the node's `onEmptyApprovers` policy; under the default policy that opens the request on the organization's administrators, so it no longer waits. + - **Unchanged:** the `ApproverType` values, the schema's shape, and what it accepts or refuses. Only the description text moves. +- d8d0711: fix(cli): `os migrate meta` lists and writes a conversion the load already applies inside a composed project's package body (#22256) + + Clause-②: no + + - **What was wrong.** In a project whose config exports `composeStacks([defineStack({ … }), …], { manifest: 'preserve' })`, each package body is assembled from the stack its input's `defineStack` call returns. That call runs the load-time ADR-0087 conversions, both when the schema accepts the input and when `os migrate meta` produces a refused input again in `strict: false` mode. So a spelling the load still converts inside a package body, such as `datasources[].driver: 'mongo'`, was never listed in `applied`, and `--write` never wrote it. Every later load kept printing `converted at load … Update the source to the canonical shape`, and the run exited 0. + - **What changed.** `os migrate meta` now produces every input of the composition again from what its author wrote, with the load-time conversions skipped, before the inputs are composed. Composition then assembles each package body from the authored source. The conversion is listed under the body's path (for example `packages[0].manifest.datasources[0].driver`), `--write` writes it into the file that authored that input, and the next load converts nothing. This holds whether the input's `defineStack` call accepted its argument or was refused. + - **`@objectstack/spec`.** `defineStack` reads one internal parameter for this, which only the CLI passes: an undeclared, symbol-keyed entry on its options, honoured only together with `strict: false`, that skips the conversion pass. It is not part of `DefineStackOptions` and nothing new is exported. A strict call ignores it, and every call without it behaves exactly as before. + - **What did not change.** A project with nothing to convert gives the same `--json` summary and the same `--write` result and written bytes as before. This was measured on the four example apps, one of them composed, at `--from 16` and `--from 17`. Every other command still reads the stacks `defineStack` builds and the composition they make. + - **The `--out` snapshot of a composed project.** Each package body in it is now the body as written, with the chain's changes applied. That is what the snapshot of a one-package stack already is. It no longer carries values the schema's parse fills in and the source never wrote, such as default keys. +- b15f8a1: feat(service-print): print a record's print page to a PDF in headless Chromium, as the requesting user, and archive it as a file attached to the record + + Clause-②: yes (widening) + + A new package, `@objectstack/service-print`. `PrintServicePlugin` registers the in-process `print` service (`IPrintService`): `status()` and `printRecordToFile({ app, object, recordId, page, filename? }, context)`. It adds no route, no metadata key and no error code. Compose it explicitly: + + ```ts + import { PrintServicePlugin } from '@objectstack/service-print'; + plugins: [new PrintServicePlugin()] + ``` + + - **One engine, headless Chromium, in one of two placements.** A local executable (`OS_PRINT_CHROMIUM_PATH`, option `chromiumPath`), or a remote browser's DevTools WebSocket endpoint (`OS_PRINT_BROWSER_ENDPOINT`, option `browserEndpoint`). Either one needs `OS_PRINT_CONSOLE_URL` (option `consoleUrl`): the console's URL as that browser reaches it, `/_console` included. Configuring both placements is refused. `playwright-core` is pinned exact on this package only. + - **Loud at startup.** `start()` probes the engine and logs its placement and Chromium version. An engine that is not configured, is misconfigured or does not answer is reported once, with the setting to fix, and `status()` says why. A remote endpoint is never logged beyond its scheme and host. + - **As the requesting user.** The page is rendered through a short-lived session minted for the caller's own principal by the auth service (`mintPrincipalSession`), and revoked in a `finally`. There is no system principal. + - **Refusals first, each with `code` and `status`, before any session is minted or browser started** (an engine that failed its last probe is re-probed only after them all): a caller that is not a human user acting for themselves (`PERMISSION_DENIED` / 403); an engine that is not ready (`SERVICE_UNAVAILABLE` / 503); an unknown page (`RESOURCE_NOT_FOUND` / 404); a page that is not the object's default record page or declares no `print` (`VALIDATION_ERROR` / 400); a record the caller cannot read (`RECORD_NOT_FOUND` / 404); an object without `enable: { files: true }` (`FILES_DISABLED` / 403). + - **The archive runs as the requesting user.** `@objectstack/service-storage`'s `file-attachments` service gains a caller-scoped face, `ICallerFileAttachmentService` (exported type), on the same slot and the same writer: `assertMayAttach(parentObject, parentId, context)` throws the insert's own refusal without writing (`PERMISSION_DENIED` / 403 when the caller may not create attachments, `ATTACHMENT_PARENT_ACCESS` / 403 when the attach rule refuses), and `attachFileAs(input, context)` writes one `sys_file` owned by the caller and one `sys_attachment` inserted under the caller's context, so every insert gate judges the caller and the row is stamped `uploaded_by` the caller. The print service asks `assertMayAttach` in its files step, before any session is minted or browser started. `attachFileAs` acts only in the caller's own organization: an `organizationId` other than the context's `tenantId` (or `null` when it names none) is refused `PERMISSION_DENIED` / 403. `attachFile` (the system verb) is unchanged. + + `@objectstack/spec`'s `llms.txt` counts the new package: the workspace publishes 72 packages. +- 40a6ee5: A value-slot `{…}` refusal now names a CEL spelling that evaluates when the path's head variable is named like a CEL type or keyword (`{list.0}` → `vars["list"][0]`) + + Clause-②: no + + The refusal for a `{…}` path token in a flow value slot (`valueSlotTemplateRefusals` / `flowNodeValueTemplateRefusals`, shown by `objectstack validate`, `registerFlow` and the executors) printed the path as bare CEL. For a head variable named `list`, that remedy was `list[0]`, and CEL read `list` as its own type, not the variable: `objectstack validate` refused the remedy it had just suggested (`Cannot index type 'type' with type 'int'`), and the bare `{list}` remedy `list` passed validation and evaluated to the type instead of the variable's value. A head that CEL claims for itself is now read through the flow scope's `vars` map, the route a `$`-named head already took: `{list.0}` → `vars["list"][0]`, `{list}` → `vars["list"]`. + + The claimed names are read off the CEL implementation the formula engine builds (cel-js 8.0.0): the type identifiers `bool`, `bytes`, `double`, `int`, `list`, `map`, `null_type`, `string`, `type` and `uint`; the namespaces `cel`, `google` and `optional`; the reserved words, such as `for`, `if` and `var`; and the keywords `true`, `false`, `null` and `in`. `timestamp`, `duration` and `dyn` are functions there, not bindings, so a variable with one of those names already read correctly and is unchanged. A later path segment that is a keyword is indexed by name (`{record.in}` → `record["in"]`). The `has()` guard the refusal suggests is now printed only where `has()` accepts it. CEL refuses `has()` over an index at run time (`has(rows[0].name)`, `has(vars["list"].tags)`), so a path with an index gets no guard, and a claimed head is guarded a step at a time from `vars`, like any other head: `has(vars.list) && has(vars.list.tags) ? vars.list.tags : null`. + + Ordinary heads (`record.owner`, `items[0]`) print the same remedy as before. Which strings are refused and which are kept is unchanged; only the remedy text changes. +- 081e6a0: docs(spec): `ISecurityService` no longer says a context with no principal is admitted or keeps its scope + + Clause-②: no + + The contract text on `ISecurityService.getReadFilter`, `ISecurityService.canExport` and `ISecurityService.canReadObject` (its verdict's arm list and its fails-closed paragraph) said that a context carrying no principal at all keeps the scope the other layers compose for it, or is admitted, as the security middleware hands it through. Since ADR-0096 D5 strict mode `plugin-security` does neither. A non-system context with no position, no named permission set and no user id now gets `false` from `canReadObject` and `canExport`, the deny filter from `getReadFilter`, and `403 PERMISSION_DENIED` (`PermissionDeniedError`) from the engine middleware's principal-less refusal. The text now says so, at each of the three methods, and cites ADR-0096 D5. + + The TSDoc on `ExecutionContext.flowRunId` and on the `ExecutionContext` input type (`kernel/execution-context.zod.ts`), and on `BaseEngineOptionsSchema.context` (`data/data-engine.zod.ts`), no longer teaches `{ flowRunId }` alone as an admitted, unscoped context: it carries no principal and is refused like any other. + + Only comments change. No behaviour, export or type moves, and the system bypass and the ADR-0056 D2 deny baseline for a caller that carries a principal and resolves no permission set read as before. The corrected text ships in the published `.d.ts`. + + What changes for you: nothing at runtime. If your code read the old text and passes the engine a context that carries neither the caller's principal nor `isSystem: true`, it is refused; pass the caller's own context or, for platform code whose own door already authorized the caller, the explicit system opt-in `{ isSystem: true }`. +- 440bed6: A select option whose `visibleWhen` cannot be evaluated refuses the write instead of admitting the value with one warn line (#22402) + + Clause-②: no (narrowing) + + + + **BREAKING** accept-set narrowing on `@objectstack/objectql`, shipped as `minor` under the launch-window convention for breaking changes (recorded in `scripts/check-changeset-no-major.mjs`). The governing decision is ADR-0137 D2, which the maintainer's ruling on #22402 (letter A) extended to the option gate on the write path. + + A select, multiselect, radio or checkboxes option may gate itself with a `visibleWhen` predicate, and the server re-checks it for every value a write picks. ADR-0137 D2 says a field-rule predicate that cannot be evaluated refuses the submit. The option gate is the server's enforcement of who may pick the option (ADR-0124 D1), so D2 now reaches it on the write path too. + + - **Before.** When an option's predicate faulted on a write (an undeclared key, a root or member the gate does not bind, a read through a reference, a computed key or receiver, `current_user.can()` with no permission data), the server logged "the option's gate was NOT enforced on this write" and admitted the value. A role-gated option whose rule was broken was open to every caller. + - **Now.** The write is refused with `VALIDATION_FAILED` (HTTP 400) and one field entry in the envelope a faulting `requiredWhen` or `readonlyWhen` already uses: `code: 'rule_violation'`, `constraint.reason: 'unevaluable'`, `constraint.rule: 'visibleWhen'`, `constraint.fault` naming the fault, and the picked option as `value`. The message names the option, the field and the fault, for example `Option 'gold' of field 'tier' visibleWhen could not be evaluated (runtime: No such key: statsu) — write rejected.` Nothing is written. This holds on insert (one row or a batch), by-id update and bulk update, and so on an import, which writes through them; `validate()` previews the same refusal. + - **Unchanged.** A predicate that evaluates is judged exactly as before: false refuses with `invalid_option`, true admits. A system write with no acting user whose predicate reads the acting user (`'admin' in current_user.positions` on a seed) is still admitted and logged with reason `no-acting-user`, because the gate has nobody to ask about. A system write whose predicate faults for any other reason is refused, as a faulting `requiredWhen` is. The form still offers an option whose predicate faults (ADR-0137 D3); the refusal comes at save. + - **`current_user.can()` with no permission data.** On an engine with no effective-permission resolver registered (no security plugin composed), a write by an acting user that picks a `can`-gated option is now refused, naming the missing permission data, instead of admitted. + - **A preview limit this inherits.** An `update`-mode `validate()` preview (an import dry run of a matched row) reads no stored row, so a cascade predicate whose parent column the patch omits faults there and the preview now refuses the row, as it already refuses a `requiredWhen` that reads an omitted column. The write itself reads the stored row and judges the predicate cleanly. + + **The remedy: fix the predicate, which `os build` names for every statically judgeable shape.** The shapes no build can judge (a computed key, a computed receiver, a row stored before the build verdicts, or a save under `OS_ALLOW_UNLINTED_METADATA_WRITES=1`) now meet this refusal at the first write that picks the option, naming the fault. + + Measured before the change, on `origin/main` 3054516ef1: the objects this repository ships carry 5 option predicates, all on the showcase's `showcase_cascade` (four `record.country == …` cascades and one `'org_admin' in current_user.positions` role gate), and none of them faults on a write. Insert and update evaluate them over a total record, so a payload without `country` reads `null == 'cn'`, a clean false. No seed or dogfood write picks a gated option. + + Earlier entries in this release that say an option predicate which faults is still logged and admitted describe the gate before this change. + + `@objectstack/lint` and `@objectstack/spec`: the published text that said the server admits a faulting option predicate now says it refuses the write. This covers the `objectstack validate` finding for an option predicate that reads through a reference, a root or member the gate does not bind, and the `SelectOption.visibleWhen` description. No verdict moves. +- 96e4be4: feat(service-storage)!: the `sys_file` scope option `public` is retired, and rows already stored with it are rewritten to `user` by a one-time operator sweep (#22443) + + Clause-②: yes (narrowing) + + + + **BREAKING** — an accept-set narrowing on the `sys_file` object's `scope` select, shipped as `minor` under the launch-window convention for accept-set narrowings. It completes the retirement registered as `storage-scope-public-retired`: `StorageScopeSchema` and both upload doors already refuse `public`, and now the stored vocabulary does too. + + ### What changes + + - **`sys_file.scope` no longer lists `public`.** Its options are `user`, `tenant`, `private`, `temp` and `attachments`. The engine refuses a write of `public` to the column as it refuses any undeclared option: `VALIDATION_FAILED`, `invalid_option` on `scope`. No access behaviour changes: the only scope value any code reads is `attachments`, and whether a file can be read before sign-in is decided by `acl: 'public_read'` alone. + - **Rows already stored with scope `public` are rewritten to `user`**, by a one-time sweep the operator runs (below), never at boot. `user` is the scope the upload doors and the field-reference copy path default to, and no reader tells it apart from `public`. One column moves: the storage key keeps its `public/` prefix, the acl and ownership columns are untouched, and no byte moves in the storage backend. + + ### FROM → TO + + | before | write instead | + | --- | --- | + | a `sys_file` row written with `scope: 'public'` | `scope: 'user'`, or no scope; and `acl: 'public_read'` on the row if the file must be readable before sign-in | + | rows already stored with `scope: 'public'` | nothing by hand: the sweep below rewrites them to `user` | + + **The one-line fix: stop writing scope `public` on `sys_file`, and run the sweep once on each deployment that stored it.** + + ### The operator step: run the sweep once after upgrading + + Until the sweep has run on a deployment that stored `public` rows, a record write that names a `public` file another field already owns is refused. The field-reference copy path copies the file into a new row with the source row's scope, the engine refuses `public` there, and the write fails with `ERR_FILE_REFERENCE_COPY`. The data REST doors answer that `500 INTERNAL_ERROR` ("Internal server error"); the server log carries the full sentence, which ends `Scope must be one of: user, tenant, private, temp, attachments`. Reads, downloads and updates that do not write `scope` are unaffected. + + The sweep ships in `@objectstack/service-storage` itself: `planSysFilePublicScopeBackfill`, `applySysFilePublicScopeBackfill`, `runSysFilePublicScopeBackfill` and `formatSysFilePublicScopeBackfillReport` are exported from the package, with their report types. It has the shape of the `sys_file` organization backfill: an operator step, never a boot hook. Import it from the package you upgraded to and run it server-side, from a context that holds the engine. It is a dry run first, and by default: + + ```ts + import { + planSysFilePublicScopeBackfill, + applySysFilePublicScopeBackfill, + formatSysFilePublicScopeBackfillReport, + } from '@objectstack/service-storage'; + + const plan = await planSysFilePublicScopeBackfill(engine); // counts, writes nothing + console.log(formatSysFilePublicScopeBackfillReport(plan)); + const applied = await applySysFilePublicScopeBackfill(engine, plan); // one scope write per row + console.log(formatSysFilePublicScopeBackfillReport(applied)); // keep it: it is the rollback list + ``` + + It counts before it writes (`scanned`), writes nothing where the count is zero, and is idempotent: every write moves its row out of `scope = 'public'`, so a second run scans and writes zero. A row whose write fails is reported, never retried, and is picked up by the next run. + + ### Rollback + + The inverse is `user` back to `public` on exactly the ids the applied report lists. This release cannot write it through the engine, which refuses `public` like any undeclared option. So the inverse goes with a code rollback: on the previous release, which still declares the option, write `scope = 'public'` back to those ids, through the engine there or as one raw driver statement against `sys_file` filtered to them. + + ### The step-18 entry says so too (`@objectstack/spec`) + + The D3 entry `storage-scope-public-retired`, which the protocol upgrade guide is built from, said that files already stored with scope `public` are not touched. That no longer holds for the `scope` column: after the sweep they read `user`. Its reason now names the `sys_file` option's retirement and the operator sweep above, and says that the rewrite changes no access: the storage key and the bytes stay as they are, and those files still download exactly as before. Its acceptance criteria add the sweep's end state: a dry run on each deployment scans zero `sys_file` records with scope `public`. +- c76edeb: A declared read attachment resolves only where `record` is a row a service serves, so `os validate` refuses a flow condition that reads one + + Clause-②: no + + `ObjectSchema.attachedOnRead` declares the blocks a service attaches to the rows it serves, computed per caller and never stored. An earlier entry in this release adds each declared block to the names `record.` resolves to, at every expression site bound to the object. That covered the sites whose `record` is the stored row, which never carries a block. There `os build`, `os validate` and the object save door accepted a read such as `record.viewer.can_act`, and the expression then faulted with `No such key: viewer` on every row. A record-change flow on `sys_approval_request` whose start condition read `record.viewer.can_act == true` validated, then failed on every record it fired for. + + A declared block now resolves, and its leaves are judged, only at the sites whose `record` is a served row: an action's `visible` and `disabled` predicates. + + - **Refused now.** On an object that declares a block, `record.BLOCK` at any other site gets the refusal it gets on an object that declares none: ``unknown field `viewer` on `sys_approval_request` ``, at `error`. Those sites are a flow's node and edge conditions, a validation rule, a field's `requiredWhen`, `readonlyWhen` and `visibleWhen`, an option's `visibleWhen`, a field formula, a sharing-rule condition and a hook condition. The block is no longer offered as a "did you mean?" candidate there either. The object save door runs the same rule over an object's own slots, so an object write in publish mode that reads a block outside an action predicate is refused with an `expression-invalid` issue. + - **Unchanged.** At an action predicate a declared leaf is accepted, and a misspelt leaf is refused with a message that names the leaves the block declares. `sys_approval_request`'s eight action predicates pass. No refusal code is added, and no export or signature moves. + - **`@objectstack/spec`.** The description of `ObjectSchema.attachedOnRead`, which the JSON schema and the reference pages carry, now says where the validator reads the key: in an action's `visible` and `disabled` predicates `record.` resolves and its leaves are judged, and every other expression site binds the stored row and refuses `record.` as an unknown field. The schema and its parse verdicts are unchanged. + - **Reach.** The only shipped object that declares a block is `sys_approval_request`, and its action predicates are the only shipped expressions that read it. The earlier entry has not been released, so no released version accepted a block read at these sites. +- 514bf3c: docs(spec): the protocol upgrade guide has a public address, one docs-site page per protocol major + + Clause-②: no + + The guide is published on the docs site, one page per protocol major, at `https://objectstack.ai/docs/protocol-upgrade/N`; `https://objectstack.ai/docs/protocol-upgrade` lists them all. A link to one major keeps resolving after the next major opens. The site builds from `main`, so each page says whether its major is released, in prerelease, or not released yet. + + `docs/protocol-upgrade-guide.md` in the repository, which earlier READMEs and changelog entries point at, is now a short pointer naming that address. + + What a release shipped is unchanged: `protocol-upgrade-guide.md` inside the package and attached to its GitHub Release. Its only change is the header comment, which now names the command that writes the file. + + Nothing changes for an author, and nothing is renamed or removed. +- b3a3634: docs: four contract docblocks now describe what the code decides. No type, schema or runtime change. + + Clause-②: no + + - **`ControlledByParentWriteDenialLeg` (`@objectstack/spec`).** The `master_chain` leg has three resolution refusals: no relation to derive from, a row that is not present, and an empty master reference. Each names the master whose own master the walk could not derive. That master is the record's own master, when it is itself `controlled_by_parent`, or any master above it. The docblock said only a master above the record's own master. The next sentence now says "past the record itself" where it said "above the first hop", so the two agree. The `record_not_found` bullet of `ControlledByParentWriteUnresolvedReason` had the same first-hop gap for a missing master row: it now says an absent row of a master that is itself `controlled_by_parent` is the `master_chain` leg, on the first hop or above it, and the legs judge a missing row only where the master governs its own rows. + - **`ISharingService.canEdit` (`@objectstack/spec`) and `SharingService.canEdit` (`@objectstack/plugin-sharing`).** The caller lists no longer name the `sys_attachment` and `sys_comment` parent gates. Those gates read `checkEdit` and the master-detail write check. The `ISharingService.canEdit` sentence on how they reach the service now names `checkEdit`. + + `ControlledByParentWriteDenialLeg`, `ControlledByParentWriteOutcome`, `canEdit` and `checkEdit` keep their declared shapes, and no verdict, outcome or refusal changes. +- 0e00d36: A locale bundle's page `label` no longer replaces a `page:header` title that says something else, such as a dynamic `'{name}'` + + Clause-②: no + + `translatePage` resolved a root-level `page:header` title as the bundle's `pages..title`, else the bundle's `pages..label`, and wrote it over whatever the header authored. A pack that translated the page label and left out the title therefore turned a record page's `'{name}'` heading into the page's static name in that locale, with no warning at build, lint or serve. + + The header title now resolves in this order, at a region root and a `slots.` root alike: + + 1. the bundle's `pages..title`; + 2. the title the header authors (a template, a plain string or an inline locale map), left as written; + 3. the bundle's `pages..label`, only where the header authors no title, or where its title restates the page's own `label`. That second case is the one `os i18n extract` already covers by offering `label` alone, so those headers keep their translation. + + The doors that serve the translated page are `GET /api/v1/meta/page/:name` and `GET /api/v1/meta/page`, through `translateMetadataDocument('page', …)`, and the console reads them. What changes for an author: a header whose title differs from the page label, in a locale whose pack carries `label` but no `title`, now shows the authored title. To translate that header, add `pages..title` to the pack (`os i18n extract` offers that key for such a header). No schema, key, accepted value or export changes. The `pages..title` describe and the reference page now state this order. +- d748ae8: A locale pack's page `label` no longer replaces the record-derived heading on a record page whose header authors no title + + Clause-②: no + + On a record page, a `page:header` with no `title` is the sanctioned spelling for "the renderer derives the heading from the record" (`PageHeaderProps.title`). Studio's record-page seed writes exactly that header. `translatePage` used to fill the absent title with the pack's `pages..label`, so the record's name was replaced by the page's static label. This happened in every locale whose pack carries the label, including the `en` skeleton that `os i18n extract` writes. Measured at `GET /api/v1/meta/page/:name` before this change: the header gained `title: "客户记录页"` in zh-CN, and `title: "Account Record"` in en with the extracted skeleton. + + The label now does not fill an untitled root `page:header` when all three of these hold: + + - the page's `type` is `'record'` as served (a document with no `type` is not a record page); + - the page's `object` is a non-empty string; + - the header keeps its record chrome (`properties.recordChrome` is not `false`). + + Everywhere else the label still fills an absent header title, as before. An authored header title follows the existing order, including one that restates the page's `label`. A pack's `pages..title` and `subtitle` apply as before. +- ed1de8c: The migration registry `src/migrations/registry.ts` is generated at build and no longer committed + + Clause-②: no + + `src/migrations/registry.ts` keeps its path and its exports, and the built package carries the same + `./migrations` entry as before. What changes is where the file comes from in this repository: it is + git-ignored and written whole by `gen:migration-registry` from two committed sources, + `src/migrations/registry.ts.template` (the hand-written step rationale, doc comments and table + skeleton) and `src/migrations/entries/` (one file per entry). The generator runs on `pnpm install` + (the package's new `prepare` script), as the first step of `build`, and before `typecheck`, `test` + and `test:repo`; its self-test runs inside every generation. `check:migration-registry` is removed, + since there is no committed copy left to compare. + + For a contributor: add a migration entry as a file under `src/migrations/entries/` and edit any other + registry prose in `registry.ts.template`. An edit to `registry.ts` itself is never committed, and the + next install or build overwrites it. + + Measured with `pnpm pack` against the previous build: 2,070 files each, and 4 differ. Those are + `package.json`'s `scripts` (`build`, `typecheck`, `test` and `test:repo` now start with the generation, + and `check:migration-registry` is gone; `pnpm pack` leaves `prepare` out of the packed manifest) and the + three `.build-input-hash` stamps that record the build's inputs. Every `dist` file, declaration, source + map and JSON Schema is byte-identical. No schema, type, export or runtime behaviour changes. +- 156ddfa: fix(spec): the error-code ledger records `@objectstack/service-analytics` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22634) + + The analytics door now answers the data door's two exposure codes for an object whose `enable` block refuses the aggregate operation, so `ERROR_CODE_LEDGER['@objectstack/service-analytics']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered under `@objectstack/rest`. No export, schema or generated artifact moves. +- 5081090: docs(spec): a list view's and a dashboard's `requiredPermissions` is enforced — the describe and the liveness ledger say so (#22639) + + Clause-②: no + + - The shared description of `ListViewSchema.requiredPermissions` and `DashboardSchema.requiredPermissions` drops its "not enforced yet" clause and says what the server does: a user who does not hold every capability is not listed the item and is refused it by name, a view container's list views are pruned the same way, and so are an object's own list views for a user who may not edit the object. + - The liveness ledger rows (`liveness/view.json` `list.requiredPermissions`, `liveness/dashboard.json` `requiredPermissions`) are `live`, citing the `/meta` read gate, and no longer carry `authorWarn`, so `os lint` / `os validate` stop warning `liveness-planned-property` when a view or a dashboard sets the key. + - The schemas themselves do not change: same shape, same accept set. +- b6eb25f: The `submitBehavior.url` docblock in `src/ui/view.zod.ts` no longer names a navigation mechanism + + Clause-②: no + + The docblock above the redirect `url` check said objectui's `FormPage` performs a verbatim + redirect on this value, and that the value reaching `window.location.assign` is the string with + its tokens substituted. Neither is how the Console behaves at the objectui pin this package + builds against (`20c6d351ad74`): it substitutes each `{{record.…}}` token, URL-escapes the + value, and navigates in-app through its router. The docblock now states the ruled contract + only: relative paths only, interpolation only from declared record fields with every value + URL-escaped, and the resolved relative path is the destination. It says that the navigation + mechanism belongs to each consumer, and marks the pre-ruling `FormPage` behaviour as history. + Comment text only: no schema, key, type, export or `.describe()` string changes. +- 165587e: New published package `@objectstack/skills`: the ObjectStack skills catalog (`skills/**` of the repository), shipped in the changeset `fixed` group so it always carries the version of the `@objectstack/*` packages it teaches. Its build copies the catalog into `dist/skills//…` byte for byte (the layout the skills CLI's `experimental_sync` reads from `node_modules`), and `files` lists only that tree. The repository's `skills/**` stays the one source of truth and the documented `next` channel; nothing a consumer writes changes. + + `@objectstack/spec`: `llms.txt`'s package-ecosystem count reads 69 with `@objectstack/skills` joining the scope. +- ea9fd99: `positionForm`: the `permissionSets` row now declares `widget: 'ref-multi:permission'` in place of `type: 'tags'`. ADR-0131 D4 resolves a reference to a declared item by machine name, registry-first, so the row names the permission-set registry (metadata type `permission`) for the designer to offer the declared sets and commit their names, instead of a free-text tag box where a name that does not exist can be typed. + + Clause-②: no + + - **The key.** `ref-multi:` followed by a metadata type is the list form of the forms' `ref:` pickers (`ref:object`, `ref:dataset`, `ref:component`, all single-valued). A distinct key, as `field-ref` / `field-multi` already are, because a single `ref:` picker writes one string and the widget registry declares one labelling per key. + - **Which renderer honours it.** A console whose metadata-admin widget registry has no `ref-multi:permission` entry renders this row as its announced fallback: a JSON editor with the note that no custom renderer is registered. The stored value is the same list of names either way. + - ⛔ Only this one form row changes. No schema, parse, export or accept-set change: `PositionSchema.permissionSets` stays a list of snake_case names, and `FormField.widget` stays a free string. +- e5899a6: `fileAccessDelegate` and the refused file marker now describe what the delegate decides on a record read, beside the download + + Clause-②: no + + Wording only: no key, type, schema shape or runtime behaviour changes. A record read now follows the download door's field-owned verdict for a file the record owns, so the texts an author reads understated what a declared delegate decides. + + - **`fileAccessDelegate`.** This covers the object schema's description and TSDoc, the object form's help text in `en`, `zh-CN`, `ja-JP` and `es-ES`, and the generated reference pages. The named service authorizes downloads of files owned by the object's media fields. It also decides whether a reader who may not read `sys_file` sees those files' name, size and type in a record read of the object. It is asked once per owning record on each such read, so an implementation runs on reads, not only on downloads. A reader who may read `sys_file` never reaches it on a record read. It fails closed on both paths. + - **`FileRefusedValueSchema` (`{ id, metadataRefused: true }`).** The TSDoc and the `metadataRefused` description now give two cases for a reader who may not read `sys_file`: the record being read does not own the file, or the download verdict on the owning record did not allow it. A file the record owns and the verdict allows is served as the full file object. + - **`IFileAccessDelegate`.** The interface's TSDoc and its `authorizeFileRead` doc now name both questions one verdict answers: the download, and whether a reader refused `sys_file` sees the file's name, size and type in a record read (asked once per owning record per such read). The warning now covers metadata too: a permissive implementation leaks the metadata as well as the bytes. + + **For authors.** Nothing to change in metadata. An existing delegate now also decides a refused reader's file metadata on every read. It should keep applying the same rule its service uses to read the record. +- ec7c7e0: fix(spec): the error-code ledger records `@objectstack/metadata-protocol` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22737) + + The generic data door now answers the two exposure codes for a nested-relation filter condition whose related object's `enable` block refuses a query of it, so `ERROR_CODE_LEDGER['@objectstack/metadata-protocol']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered under `@objectstack/rest`. No export, schema or generated artifact moves. +- 322ff1e: docs(spec): `RuntimeAuthoringIssueSchema.path` states the name-keying rule for every collection-resident write type, so a `dataset` write's name-keyed paths are no longer described as positional + + Clause-②: no + + The description of `path` on a runtime publish-gate finding (the 422 `issues[]` and the 2xx `advisories[]`) named `object`, `permission` and `book` as the only write types whose top-level collection entry is keyed by name, and called every other write type positional. A `dataset` write's findings have been name-keyed too (for example `datasets.acme_invoice_metrics.dimensions[0].field`), so a consumer that followed the text would have expected `datasets[N]` and never received it. + + The description now states the rule instead of a list: a write type is collection-resident when the gate's per-write snapshot holds the tenant's other stored items of that type beside the written one, and there the top-level entry is keyed by name. Every other write type is the only member of its collection in that snapshot, so its `[0]` stays positional. The set is derived inside `@objectstack/lint`'s runtime gate, and the description names that derivation rather than copying its members. It also states the fallback the gate already applies: an entry whose name cannot be spliced into a dotted path keeps its index. + + Description text only: the schema's shape, accepted values and types are unchanged. The generated `content/docs/references/api/protocol.mdx` is regenerated. +- 581ca7d: fix(spec): the bulk action param's rejection text names the promoted field-backed route instead of saying the bulk surface has none (#22780) + + Clause-②: no + + `BulkActionParamSchema` still refuses `field`, and the widget-config keys, on an authored `bulkActionDefs[].params` entry. An authored param reaches the bulk dialog as written, and nothing on that path resolves it against the object's field definitions. What changed is the reason and the remedy in two rejection messages, which said the bulk surface has no field-backed param route at all. The console now has one. An action the object declares, named in a list view's `bulkActions`, is promoted with its params, and a field-backed param on it (`{ field }`, plus `objectOverride`) is resolved through the single-record dialog's resolver when the bulk dialog opens. + + - **`field`.** The rejection now offers two fixes. Declare the param inline (`name` + `type`, plus `object` and optionally `labelField` for a picker). Or declare the action on the object with the field-backed param and name it in `bulkActions`. The promoted form runs the action once per selected record. + - **Widget-config keys** (`min`, `max`, `step`, `accept`, `lookupFilters` and the rest of that family). The rejection states what the promoted route carries from the field: `type`, `label`, `options`, `required`, `description` (as the help text), `placeholder`, `defaultValue`, `multiple`, `accept` and `maxSize`, plus a reference field's `reference`, `displayField` and `dependsOn`. Of the refused keys, only `accept` and `maxSize` reach the bulk dialog that way. `lookupFilters`, `lookupColumns`, `lookupPageSize` and `descriptionField` are dropped on the bulk path, and the rest are never read off the field. + + Nothing to migrate: the accepted and refused keys are unchanged. Only the text of the two rejection messages, and two source comments, moved. +- 3f0b6f2: `HookSchema.onError` and `HookEvent` now say where an `after*` hook's `abort` rolls the write back, and what it means elsewhere + + Clause-②: no + + Text only: no key, shape, default or enum changes, and every hook that parsed before parses the same. + + - **`onError`** (its TSDoc and `describe()`): `abort` refuses the operation. A blocking hook's abort rolls the write back whenever the write runs inside a unit of work. Those units are a caller's `transaction()`, an atomic batch or cascade, and the unit the write door now opens for a blocking `abort` `after*` hook on an object on the default datasource whose driver declares transactions (`@objectstack/objectql`, same release). On an object on another datasource, or a driver without transactions, an `after*` hook's abort refuses the caller while the row stays stored. + - **`HookEvent`'s JSDoc** listed three ways a write ends up inside a unit of work. It now lists four: the write door's own unit is the new one. +- b1d701b: `security/explain` decides whether a record-grained explanation may say a record exists by the read access of whoever asked for it, not by the explained principal's. + + Clause-②: no + + - **An asker who cannot read the record** gets, for every operation, exactly the answer an id no row carries gets: `record` is `{ recordId, visible: false }` with no `decidedBy`, and every layer's per-record attribution is the missing record's. + - An asker explaining another user (`manage_users`, or a delegated administrator's scope) cannot read a record its own by-id read finds no row for, or refuses (a declared 4xx, such as no read grant on the object). + - A principal explaining itself cannot read a record its own by-id read finds no row for. A refused read does not count, so the explanation shows what that principal's own doors answer: a principal that may edit a record it may not read is told what its edit does. + - Before, a principal explaining itself a `read` of a row its read finds no row for was told the row's verdict and why, and an administrator with no read grant on the object was told any row's story; the by-id write verbs already answered a self-explanation of such a row as missing. + - **An asker who can read the record** gets the explained principal's full record story. For a `read`, that is the principal's own verdict, decider included, so the `controlled_by_parent` read verdict on the master leg (not visible, decided by the `sharing` layer) is what such an asker is told. For a by-id `update`, `delete` or `transfer` of a row the principal cannot read, the verdict stays `visible: false`, the answer that principal's write gets, and now names the layer that decided the principal's read; each row-scoped layer's `record` carries that read's attribution, prefaced with the write's `404 RECORD_NOT_FOUND`. Before, such an asker was told the missing record's answer. + - A system asker reads every record. The batch form (`recordIds`) answers each id as the singular form does. + - `@objectstack/spec`: the `record` verdict's description and the `recordIds` "Missing records" clause in `explain.zod.ts` now say which ids an asker cannot read, by asker, and that those answer as missing. Description text only; no schema shape changes. + - Nothing you author changes, and no exported type or signature moves. Object-level explanations (no `recordId`) are unchanged. +- 20028df: fix(spec): the error-code ledger records `@objectstack/runtime` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22823) + + The runtime dispatcher's `callData` now answers the two exposure codes for a nested-relation filter condition whose related object's `enable` block refuses a query of it, on its `query` fallback and its `aggregate` arm, so `ERROR_CODE_LEDGER['@objectstack/runtime']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered. No export, schema or generated artifact moves. +- 69d4218: feat(plugin-approvals): an approval step whose approvers resolve to no one opens on the organization's administrators under the default `onEmptyApprovers: 'admin_rescue'`, as ordinary approvers in their inbox + + Clause-②: yes + + **FROM → TO: who receives an `admin_rescue` request.** A node whose approvers resolve to no concrete person (an unstaffed position, an empty team or department, an unset manager, an expression that yields no one) under the default `onEmptyApprovers: 'admin_rescue'`: + + - **FROM:** the request opened on a slate that named no one, such as the literal `position:sales_manager`. It was in no one's "My Pending", the bell counted 0, no one was told, and a `lockRecord: true` record stayed locked until a platform or tenant admin found it under **All** and decided it through the privileged override. + - **TO:** the request opens on the request organization's administrators: the people who hold the organization-admin capability (`organization_admin` or `organization_admin_no_bypass`, ADR-0095 D3) in that organization. That is the same authority that lets a tenant admin override a request, resolved to the people who hold it, through the same grant resolution every request runs. An organization's owners and admins receive the capability automatically. + + They are ordinary approvers. The request is listed and counted for each of them under their own `approverId`, each is told once by `approval.requested`, out-of-office delegation applies to them, and a decision by one of them is an ordinary approval or rejection, recorded with `via_override: false`. The node's `behavior` reads them as one group. A platform administrator is not added for being one; one who also holds the organization's capability, such as its owner, is on the slate for that. + + The request says why. Its timeline carries a note, for example "Routed to this organization's administrators because the approver slate named no one: position 'sales_manager' has no holder.", and its node-config snapshot keeps the abandoned slate and the reason under `__resolvedFrom` (`admin_rescue:abandoned`, `admin_rescue:reason`). The abandoned slate is replaced, not kept beside the administrators, so a holder staffed into the position after the request opened does not receive it; an administrator can reassign it to them. + + **Unchanged.** A slate that names a person; `fallback`, `auto_approve` and `fail`; a `fallback` whose own list resolves to no one still degrades to `admin_rescue`, which now opens on the administrators. The privileged override stays for what the administrators' slate cannot reach: a request whose named approvers have all left after it opened, and an organization with no administrator (or a request with no organization), where the request still opens on the slate that names no one and the server logs a warning. + + **To name the person who should decide instead of the administrators,** declare `onEmptyApprovers: 'fallback'` with a `fallbackApprovers` list. + + **`@objectstack/spec`:** the `onEmptyApprovers` description says that `admin_rescue` opens on the organization's administrators and that `fallback` names who decides instead. + + **`@objectstack/lint`:** `approval-approvers-may-resolve-empty` no longer says the request "waits forever": it says the request opens on the organization's administrators, its remedy names `onEmptyApprovers: 'fallback'` as the way to name who decides, and its `os explain` text says the same. +- 5ce6773: feat(trigger-api)!: the inbound hook door refuses the body-only `x-objectstack-signature` form, so only the timestamped, replay-protected form verifies + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a runtime door, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **What narrows, from → to.** + + - `POST /api/v1/automation/hooks/:flowName/:hookId`, through the self-hosted mount and through `ITriggerApiService.handleInboundHook` alike. A post whose `x-objectstack-signature` was the body-only value (`sha256=` and the hex HMAC-SHA256 of the raw body under the flow's secret) was accepted, `202` and enqueued, with a deprecation `warn` line once per armed hook. It now gets the same `401 INVALID_SIGNATURE` every post that does not verify gets, and nothing is enqueued, even when the value is the secret's HMAC of the body. The body-only form signs no time, so it carries no replay protection. + - The one accepted form is the timestamped one: `t=,v1=`, the HMAC-SHA256 of `.` under the flow's secret, refused when `t` is more than 300 seconds from the server clock. + - The deprecation line is gone. The refusal's `warn` line carries the cause `signature-body-only`, beside `unknown-flow`, `wrong-hook-id`, `signature-absent`, `signature-malformed`, `signature-mismatch` and `signature-outside-window`. It never carries the secret, the signature value or the body. + - `verifySignature(secret, rawBody, header)`, exported by `@objectstack/trigger-api`, answers as the door does: `true` only for the timestamped form inside its window, `false` for a body-only value. Its signature is unchanged. + + **Unchanged: the outbound contract.** `@objectstack/core`'s `verifyHttpSignature` still verifies both forms, and `signHttpBody` still writes the body-only value: the messaging outbox and the flow `http` node sign it for third-party receivers, as the outbound webhook contract publishes. Only this inbound door refuses it. + + **Who sees a difference.** A sender that signs the timestamped form, as the inbound-hook guidance teaches (the `objectstack-automation` skill's Inbound webhook section, `os explain flow-api-trigger-secret-missing`, `signHttpBodyAt` in `@objectstack/core`), sees none. A sender still on the body-only value now gets `401`, and the trigger's `warn` line for its post names `signature-body-only`, which tells an operator its secret is right and its signing form is the retired one. + + **`@objectstack/spec`.** The `ITriggerApiService.handleInboundHook` docblock names the timestamped form as the only one accepted and the body-only form as refused. The type is unchanged. +- 3f69471: fix(spec): the `ui-bulk-action-param-unknown-keys-refused` upgrade guidance names the promoted field-backed bulk route instead of saying the bulk surface has none (#22858) + + Clause-②: no + + The major-18 semantic entry `ui-bulk-action-param-unknown-keys-refused`, which `objectstack migrate meta` prints, said two things that stopped being true once the console resolved a promoted bulk action's params through the single-record dialog's resolver. It called `field` / `objectOverride` contracts "the bulk surface does not implement", and it told the reader that declaring a widget-config key on the object's field cannot help because "the bulk surface has no field-backed param route". The entry now uses the wording the `BulkActionParamSchema` rejection messages already use. + + - **`replacement`.** An authored `bulkActionDefs[].params` entry has no field-backed route: it reaches the dialog as written. The `field` rejection offers two fixes: declare the param inline, or declare the action on the object with the field-backed param and name it in the view's `bulkActions`. That second form is the promoted route, whose params are resolved when the bulk dialog opens. Of the widget-config keys, only `accept` / `maxSize` declared on the field reach the dialog, through a promoted action. `carryOver` / `defaultFromRow` / `requiresFeature` are still ACTION-param contracts the bulk surface does not implement. + - **`reason`.** Declaring the key on the field and keeping the authored def still does not reach the dialog. A promoted action is the one route that consults the field. Of the refused keys, only `accept` / `maxSize` arrive that way. `lookupFilters` / `lookupColumns` / `lookupPageSize` / `descriptionField` are resolved and then dropped on the bulk path, and the rest are never read off the field. + + Nothing to migrate: the accepted and refused keys are unchanged. Only the guidance text of one migration entry moved. +- 777eed2: The `mailAssociation` liveness row is `live`: the mailbox service's matcher reads the key, so authoring `mailAssociation: true` on an email field no longer draws the "no mailbox service reads this yet" author warning + + Clause-②: no (dropping an author warning widens no accept set and no export: `FieldSchema` accepts `mailAssociation` exactly as before; only the warning the shipped liveness ledger carried is gone) + + - `liveness/field.json`: the row moves from `planned` with `authorWarn` and `authorHint` to `live`, citing `MailRecordMatcher` in `@objectstack/service-mailbox` as its reader. The `field` state counts move from 94 live and 4 planned to 95 live and 3 planned. +- 1badd31: The description of `branding.logo` on an app now states the shape of the one asset it takes: a 1:1 square app icon, SVG preferred, at least 64 px, which each surface scales to the size it needs, with no second size and no wordmark slot. Text only; no schema changes: the key stays an optional string, the accept set is byte-identical, and a non-square value is not refused because a URL string does not carry its ratio. +- 4216796: Clause-②: no + + The docblock above `FieldSchema`'s lookup picker keys no longer says that objectui reads + `lookupColumns`, `lookupPageSize` and `allowCreate` with a snake_case alias first. objectui + retired those reads (objectui#12157), so the picker reads all seven keys (`displayField`, + `descriptionField`, `lookupColumns`, `lookupPageSize`, `lookupFilters`, `dependsOn`, + `allowCreate`) in camelCase only. A stored definition that spells one of them in snake_case is + folded onto the declared key when the console ingests it. The six lookup picker rows in + `liveness/field.json` carry the same reading, dated and pinned to the objectui commit it was taken + on. Where a reader outside the picker still falls back to a snake_case spelling, the row names + that reader. No key, schema or accepted value changes. +- 39e93ef: `PageHeaderProps.actions` now says what its list is: the whole set the header draws. An action that declares `record_header` (or `record_more`) in its `locations` but is not named in an authored `page:header`'s `actions` is not drawn in that header, and a header with no `actions` draws none of the object's own actions; a named id whose action declares neither location renders nowhere, and the host's own Edit / Share / Delete chrome is separate from the list. The `.describe()` was "Action IDs to show in header", which read as a filter over the declared placements. Text only: the accepted set of `actions` is unchanged, and no rule or renderer behaviour moves. +- 02bec6c: fix(spec): a page component's `visibleWhen` describe no longer says the renderer binds `data` — the shipping renderer mounts no `data` root there (#23036) + + Clause-②: no + + The `.describe()` on `PageComponentSchema.visibleWhen` said "The shipping renderer additionally mounts `features`, `os.user` and binds `data` to the data-source ADAPTER here", and the TSDoc above it said the same. The console's renderer stopped binding `data` on this surface (objectui ruling on the renderer seam's data scope, 2026-09-13): a `data.*` read there now faults, and a faulting `visibleWhen` renders the component, so a gate written from the old text, such as `data.total > 0`, shows the component whether it is true or false. + + The describe now names what the renderer mounts beyond the contract-bound roots (`features` and `os.user`), states that no `data` root is mounted, and says the one declared `data` read on this surface is the PLANNED API binding's (`dataSource: { provider: 'api' }`), once that binding's renderer ships. The `page:tabs` item-level `visibleWhen`, a different evaluator that binds `data` to the record row, is still called out. Nothing an author can write changes: the accept set is the same, and only the description text and the generated reference page move. + + **If you wrote a page component gate on `data.*`:** gate on the page's row as `record.*`, or on page state as `page.`. +- 719183f: The TSDoc on `MetadataConversion.retiredAfter` now says what a retirement stamps under a prerelease package label. Under a stable label `x.y.z` it is the label itself, as before. Under a prerelease label `x.y.z-PRERELEASE` (Changesets pre mode, such as `18.0.0-next.N`) it is the newest published stable release, which sits below `x.y.z`: never the prerelease label, which is not a stable `x.y.z`, and never `x.y.z`, the first stable release that carries the entry retired. The old wording, "the package's version label at the moment the retirement lands", had no value that type-checks under a prerelease label. A stable release published later that does not carry the retirement (a `17.x` maintenance release during the 18 prerelease) still accepted the old shape, so it becomes the value. Text only: `retiredAfter` keeps its `${number}.${number}.${number}` type, and the accepted set does not change. +- c40a0dd: The liveness ledger's three public-form rows cite the code that reads their keys where it now lives + + Clause-②: no (only the rows' evidence paths move and every status stays `live`: `ViewSchema` accepts `form.sections`, `form.sharing` and `formViews` exactly as before, and no state count moves) + + - `liveness/view.json`: the `form.sections` and `form.sharing` rows cited `packages/rest/src/rest-server.ts` for the two public form doors, `GET /forms/:slug` and `POST /forms/:slug/submit`. `@objectstack/rest` moved the body of the registrar that mounts them, `RestServer#registerFormEndpoints`, to `packages/rest/src/form-routes.ts` with no behaviour change, so their three path references follow it. + - `liveness/view.json`: the `formViews` row cited the doors' `findPublicFormView`, which moved with that body and reaches every form view through `anonymousFormIntakeCandidates`. The row now cites that rule where it is declared, `packages/spec/src/ui/anonymous-form-intake.ts#anonymousFormIntakeCandidates`, the code on the read path that names `formViews`. + - The quoted code, the verdicts and the notes are unchanged. +- bb7ccde: The liveness ledger's CRUD rows cite the code that reads their keys where it now lives + + Clause-②: no (only the rows' evidence paths move and every status stays `live`: `CrudEndpointsConfigSchema` accepts `operations.*` and `dataPrefix` exactly as before, and no state count moves) + + - `liveness/crud_endpoints.json`: the five `operations.*` rows and the `dataPrefix` row cited `packages/rest/src/rest-server.ts#registerCrudEndpoints` for the code that gates and prefixes the CRUD mounts. `@objectstack/rest` moved that registrar's body to `packages/rest/src/crud-routes.ts` with no behaviour change, so those six path references follow it. + - The quoted code, the producers, the verdicts and the notes are unchanged. +- df000d4: The liveness ledger's `/meta` type-list, per-type-list and maintenance switch rows cite the code that reads their keys where it now lives + + Clause-②: no (only the rows' evidence paths move and every status stays `live`: `MetadataEndpointsConfigSchema` accepts `endpoints.types`, `endpoints.items` and `endpoints.maintenance` exactly as before, and no state count moves) + + - `liveness/metadata_endpoints.json`: the `endpoints.types`, `endpoints.items` and `endpoints.maintenance` rows cited `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner` for the gates on their mounts. `@objectstack/rest` moved those three route groups to `packages/rest/src/meta-collection-routes.ts#registerMetaCollectionRoutes` with no behaviour change, so those three path references follow them. + - The quoted code, the producers, the verdicts and the notes are unchanged. The `prefix` row keeps its citation: `metaPath` is still built in the inner method's head. +- 646303e: The liveness ledger's `/meta` cache-flag, per-item switch, doc and book rows cite the code that reads their keys where it now lives + + Clause-②: no (only the rows' evidence paths and notes move and every status stays `live`: `MetadataEndpointsConfigSchema`, `DocSchema` and `BookSchema` accept exactly what they accepted before, and no state count moves) + + - `liveness/metadata_endpoints.json`: the `enableCache` row and the `if (metadata.endpoints.item !== false)` half of the `endpoints.item` row cited `packages/rest/src/rest-server.ts#registerMetadataEndpointsInner`. `@objectstack/rest` moved the per-item reads to `packages/rest/src/meta-item-read-routes.ts#registerMetaItemReadRoutes` with no behaviour change, so those references follow them. The `registerPerItemRoute` half of the `endpoints.item` row keeps its citation: that call still lives in the inner method's head. + - `liveness/doc.json`: the `content` row's single-item branch, and the `_note`'s book-tree and single-item branches, cite the new module. The `doc` list branch keeps its citation. + - `liveness/book.json`: the `_note` names the new module for the `book/:name/tree` branch. + - The quoted code, the producers, the verdicts and the row notes are unchanged. +- e5f9f01: fix(metadata-protocol)!: the metadata save door refuses a `protection` block with a located `400 VALIDATION_ERROR` instead of storing a lock that binds nothing (ADR-0010 §3.5, Phase 4.3) + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + `protection` is the package author's lock declaration. ADR-0010 states one door for it: a code package's loader translates it into the item's private lock envelope when the package loads, and strips it (Phase 4.3). An item saved through the metadata API is authored in the environment, and ADR-0010 §3.5 states that such an item takes no package lock. + + **This entry supersedes one sentence of the entry `15213-unmanaged-lock-strip`, in this same release.** That entry says a malformed `protection` block is not stripped and "the save door refuses that item as before, `422 INVALID_METADATA`, and the install is rolled back". What holds now: on a type whose schema declares `protection`, an item the unmanaged install hands on with its block still in it (a malformed block, or a block beside a `_lock*` value its declaration refuses) meets this entry's door refusal, `400 VALIDATION_ERROR`, and the install is still rolled back. An item carrying a refused `_lock*` value and no block, and a block on a type that does not declare one (`action`, `hook`, `page`), still answer `422 INVALID_METADATA`. Where the two entries disagree, this one is current. + + **FROM.** `PUT /api/v1/meta/{type}/{name}` with a `protection` block answered `200` and stored the block verbatim. The item was then served with `protection.lock: 'full'` while it read `lock: 'none'`, `editable: true` and `deletable: true`, and a second save and a delete both answered `200`. Measured on the showcase for an `app` and a `dashboard`. An author, or an AI, reading that item was told it was protected when anyone holding the authoring capability could change or delete it. + + **TO.** The same save answers `400 VALIDATION_ERROR` and writes nothing. The message names the block and the door that does honour it, and the envelope's `issues[]` carries `protection` as the path, so the Studio designer can highlight it. + + - **Every type that declares the block**, in draft and in publish mode, for a new item and for an overlay of a packaged one. A malformed block is refused the same way, because the block is not accepted at this door at all. On a type whose schema does not declare `protection`, the schema's own `422 INVALID_METADATA` answers, as before. + - **Every authoring write**: the REST and dispatcher saves and the domain doors that save through them. The unmanaged install already drops a block it can read before it saves a copy, and a malformed one is refused here. + - **Server-side rewrites strip it.** A row stored before this change may carry the block. When the server itself rewrites such a row (`os migrate meta --stored --apply`, the stored flow credential move, package duplication), it drops the block instead of failing the rewrite: the block never bound, so nothing changes but the stored bytes. + - **Unchanged.** A body without `protection` saves as before. A packaged item's lock still binds: its loader translated the block at load time, and a save against it still answers `403 ITEM_LOCKED`, or `403 NOT_OVERRIDABLE` on a type with no environment overlay, before this refusal is asked. A row stored before this change keeps its bytes. + - **Package export.** `GET /packages/:id/export` copies a writable base's rows into a manifest, so a block saved into a base row rode into that manifest, where the package loader translates it on install. A block can no longer be saved into a base row. Declare it in the manifest you install instead. + - **Remedy.** Delete the `protection` block from the body you send to the metadata API. To lock an item, declare `protection` on it in a code package's source. + + `@objectstack/spec`: the `protection` TSDoc on every type that mounts it (app, view, dashboard, report, dataset, object, flow, webhook, agent, tool, skill, position, permission set, email template) now says that only the package loader translates the block, and that the metadata API's save door refuses it. +- c056979: Retirement prescriptions for shapes `@objectstack/spec` 17.7.0 still accepts now name 18 as the release that removed them + + Clause-②: no + + 17.7.0, the newest 17.x release (npm `latest`), still accepts every shape in the two tables below. No 17.x release removes them: the 18 line does. `18.0.0-next.0` is the first release that refuses the shapes in this first table. Their refusals, and their `[REMOVED]` JSON Schema descriptions, named a different release: + + | Surface | Was | Now | + | --- | --- | --- | + | `element:number` `object` / `filter`; `element:record_picker` and `element:repeater` `object` / `filter` / `sort` / `limit` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | + | `object-grid` `defaultFilters` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | + | `email_template.category`, `email_template.isSystem` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | + | a package manifest's `permissions` written as a list of permission strings | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | + | `public` in `StorageScope` | removed in @objectstack/spec 17.8.0 | removed in @objectstack/spec 18 | + | `MetadataTypeRegistryEntry.allowOrgOverride` → `allowEnvironmentOverlay` | renamed (no release named) | renamed in @objectstack/spec 18 | + + The `18.0.0-next.0` release notes quoted the two `email_template` prescriptions with the earlier version, 17; from this release on they name 18. + + `18.0.0-next.0` still accepts the shapes in this second table, so the first release that refuses them is the one after it. Their refusals named 17, or no release at all: + + | Surface | Was | Now | + | --- | --- | --- | + | `view.data.provider: 'api'` | removed in @objectstack/spec 17 | removed in @objectstack/spec 18 | + | `'navigation'` as a page action row's executor (`actionType` on `action:button` / `action:icon`, member `type` on `action:group` / `action:menu`) | no release named | refused on page action rows since @objectstack/spec 18 | + | a `controlled_by_parent` object with no `master_detail` declared `required: true` | no release named | the one-required-master rule holds since @objectstack/spec 18 | + + One prescription was wrong the other way. 17.4.0 accepts the sub-day `TimeUpdateInterval` members `second`, `minute` and `hour`, and 17.5.0 refuses them, but the refusal said "retired in protocol 18". It now says "removed in @objectstack/spec 17.5.0". + + The `os migrate meta --from 17` sentence in each prescription is unchanged and was already right: `--from 17` replays the protocol-18 conversions. A prescription for a key that a 17.x release already refuses keeps naming 17. Text only: no key, value or shape is accepted or refused differently. +- 4f68ba1: Every finding of the component-props gate is now an error at `objectstack validate`, `objectstack build` and `objectstack lint`. A page component whose `properties` carry a key its `ComponentPropsMap` row does not declare, a value that row rejects, or no `dataSource.object` binding where the element reads only that binding drew a warning: the command printed "✓ Validation passed" and built the artifact, and the renderer ignored the key or drew nothing. Each is now a `component-props-unknown-key` or `component-props-invalid` error, and the stack does not pass until it is fixed. A retired prop was already refused, and still is. + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a published authoring surface (the `component-props-invalid` and `component-props-unknown-key` rules of `objectstack validate`, `objectstack build` and `objectstack lint`). + + **What is refused now.** Every verdict of the gate: + + - `component-props-unknown-key`: a key the component's props row does not declare, at the bag's own level, on a strict object or union arm below it, or on a strip-mode object below it. + - `component-props-invalid`: a value the props row rejects (a wrong type, a missing required prop, a closed-set refusal such as a url action's `target` scheme or a `record:details` section's `count`, a retired enum value), and a missing `dataSource.object` on `element:record_picker`, `element:number` and `element:repeater`. + + **Not judged here.** `defineStack` and the stack schema keep the open `properties` bag as written. A component `type` that `ComponentPropsMap` carries no row for (a `custom.*` or plugin type) is not judged. + + `ComponentPropsSeverity`, the type `@objectstack/lint` exports for these findings, narrows from `'error' | 'warning'` to `'error'`. + + ## FROM → TO + + | you wrote | write instead | + |:--|:--| + | `page:header` `properties: { titel: 'Deals' }` | `properties: { title: 'Deals' }`, the declared spelling the error names | + | `record:related_list` `properties: { limit: 'five' }` | `properties: { limit: 5 }`, the type the error names | + | `element:number` with no `dataSource` | `dataSource: { object: 'deal' }` on the component node, beside `type` | + | a key the component does not read | nothing: delete the key | + + **The one-line fix: apply what the error names.** An unknown-key error names the declared spelling when one is close, a value error names what the row expects, and the binding error names where the object goes. If the component really does read the key or value, the props row is what is wrong: correct the declaration in `@objectstack/spec`, not the page. + + The `@objectstack/spec` change is text only: the reason of each D3 migration entry named above said the gate reported its refusal as an advisory finding, and now says it is an error. + + **Who is affected, measured.** At objectstack `8323cad97`, this rule ran at its own input tier over `examples/app-crm`, `app-todo`, `app-multi-package` and `app-showcase`, and over the eight shipped pages outside them (`@objectstack/platform-objects` ×3, `@objectstack/plugin-approvals`, `packages/qa/downstream-contract`, `@objectstack/cloud-connection` ×2, `@objectstack/mcp`): 37 pages, 177 components judged. It also ran over `objectstack-ai/hotcrm` at `0f84c9c72`: 8 pages, 50 components judged. Result: 0 findings of every class in both. A planted undeclared key and a planted wrong-typed prop each drew one finding on every population. The `create-objectstack` template authors no page. Deployed metadata and other repositories were not measured. +- b4d573a: fix(spec,core): the rolling `last_7_days`, `last_30_days` and `last_90_days` presets close on today, at the spec's prescription table, the save door's refusal and the analytics preset arm + + Clause-②: no (no accept set widens: the three rolling windows gain the inclusive `{today}` end every calendar preset already has) + + The three rolling windows were the open arm of `DATE_RANGE_PRESET_MACRO_WINDOWS`: `['{30_days_ago}', null]`, prescribed as `{ $gte: '{30_days_ago}' }`. A `$gte`-only window has no upper bound, so on a column that carries future dates (a close date, a due date) "last 30 days" also counted every later day. Measured on a `date` field with one row dated today−5 and one dated today+5: that window counted 2, while the console's preset lowering and the analytics preset arm counted 1. + + - `DATE_RANGE_PRESET_MACRO_WINDOWS` now closes every rolling window on `{today}`: `last_7_days` is `['{7_days_ago}', '{today}']`, and `last_30_days` and `last_90_days` follow the same shape. That is the inclusive last-day end every calendar preset already uses, and the extent the console's lowering already selects. The table's declared type narrows to match, `readonly [start: string, end: string]` (it was `end: string | null`). A reader that compared `end` with `null` still compiles; that branch can no longer be taken. + - The save door's refusal of a bare preset comparand (`bareDateRangePresetComparandMessage`, and the `filter-preset-comparand` lint verdict that quotes it) now prescribes the closed window. For `last_30_days` it says: write `{ $between: ['{30_days_ago}', '{today}'] }`, where it used to say `{ $gte: '{30_days_ago}' }`. + - `@objectstack/core`'s `resolveAnalyticsDateRangePreset`, the preset arm behind `timeDimensions[].dateRange`, selects the same extent. A rolling window now stops before tomorrow begins in the reference timezone, exactly as `today` does, with `endExclusive: true`. It used to end at the reference instant, so it dropped rows stamped later today. Its ends keep this module's own "the day the window stops before" spelling (`tomorrow`). Every preset this resolver returns is now `endExclusive: true`, and a caller's explicit `[a, b]` window keeps its inclusive reading, as before. + - The semantic migration entry `filter-preset-ordering-comparand-refused`, which `spec-changes.json` and `os migrate meta` carry, now names `{ $between: ["{30_days_ago}", "{today}"] }` as the window for `last_30_days` in its replacement, where it named `{ $gte: "{30_days_ago}" }`. + + If you followed the old refusal and authored `{ $gte: '{N_days_ago}' }` for a rolling preset, add `$lte: '{today}'`, or write the `$between` pair above, to stop counting future-dated rows. +- 60f9baf: Clause-②: no + + The `manifest.contributes.routes` and `App.apis` retire messages (and the published + `plugin-manifest-contributes-routes-retired` migration text) no longer send app authors to an + imperative `http.server` route registered on `kernel:ready`. An app declares its endpoint under + `defineStack({ apis })` (`type: 'object_operation'` or `'flow'`, ADR-0121), computes in a flow, + and registers code as a stack function (`defineStack({ functions })`) or a connector. The + `http.server` service itself is unchanged: platform plugins keep mounting their own doors on it. + No key is added or removed. +- 6673c80: feat(cli)!: the v18 upgrade carries stored comment reactions — `os migrate comment-reactions` turns each reaction stored in the retired `sys_comment.reactions` column into a `sys_comment_reaction` record, and `os migrate apply --allow-destructive` refuses to drop that column until the conversion is verified + + Clause-②: no (narrowing) — `os migrate apply --allow-destructive` now refuses the `sys_comment.reactions` drop it accepted before, until a verified conversion is recorded; the conversion itself only writes records of an existing object, so nothing widens. + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + **What the upgrade now carries.** A database upgraded from 17.x keeps `sys_comment.reactions` and every emoji reaction stored in it (schema sync is additive). Those reactions are no longer lost. `os migrate comment-reactions` converts them into records: + + - **One record per stored reaction.** Each (comment, emoji, user) in a stored map becomes one `sys_comment_reaction` row with the same comment, emoji and user, in the comment's organization, owned by the user who reacted. That user can remove it like any reaction made on v18. A user id listed twice under one emoji is one reaction. + - **Nothing is guessed.** A stored value that is not JSON or not a map of emoji to user-id lists, an entry that is not a user id, an emoji longer than a reaction record holds, and a user id that names no `sys_user` row are each listed in the report with their comment id. Nothing is written for them. The readable entries of the same comment still convert. + - **Idempotent.** A reaction that already has its record is skipped, so a second run writes nothing, and re-running finishes an interrupted run. + - **Dry run by default.** `--apply` writes the records, reads the column back, and records the run in `sys_migration`. The run is recorded as verified only when every readable reaction has its record and no stored value is left unreadable. `--json` gives the full report. A run that lists an unreadable value or fails a write exits `1`, and so does an `--apply` run whose read-back finds anything unconverted. + + **The column drop waits for the conversion (the narrowing).** Until a verified run is recorded, `os migrate apply --allow-destructive` refuses to drop `sys_comment.reactions` and exits `1` (`--json`: `message: "refused_comment_reactions_unconverted"`, with the recorded run under `conversion`). Nothing else is applied on that run. This also covers the boot drift line, which names that command as the remedy for the orphaned column. What to do when it refuses: run the conversion below until it reports the run verified, then re-run the apply. + + **`os migrate apply --json` exits `1` when the `sys_account.issuer` retirement pre-flight refuses.** Its two refusals, `refused_account_issuer_collisions` and `refused_account_issuer_preflight_unreadable`, emitted their document with exit code `0`, so a caller gating on the status read a refused apply as a successful one. They now exit `1`, as the same refusals do without `--json`. The document is unchanged. + + **Upgrade order:** take a backup, run `os migrate comment-reactions`, then `os migrate comment-reactions --apply`. If it lists unreadable values, correct each one in the database so it reads as a map of emoji to user ids, or clear it if it holds nothing to keep, and run `--apply` again. Once the run is verified, run `os migrate apply --allow-destructive` to drop the column. Run these with the flags and environment the deployment boots with (an auth secret set), so the audit plugin's objects are composed. + + This supersedes the "stored reactions are not migrated" sentence in the entry that retired the column. `@objectstack/spec`: the protocol-18 semantic entry `sys-comment-reactions-column-retired` now names the conversion in its replacement, reason and acceptance criteria, and orders it before the drop. `os migrate meta` and the upgrade guide carry that text. Its id and surface are unchanged. +- e4ec6ec: fix(spec): the two step-18 flow-binding D3 entries name only the reads a renamed variable has on protocol 18, a text-slot hole and a CEL expression + + Clause-②: no + + The `replacement` text of two ADR-0087 D3 semantic entries, `flow-binding-name-dollar-refused` and `flow-binding-variable-dollar-name-refused`, listed three ways a flow reads the old variable name, for the author to rename. Protocol 18 has only the first two. The protocol upgrade guide and `spec-changes.json` are generated from these entries, so they now say what the two entries' release notes already say. + + | FROM | TO | + |:--|:--| + | Rename every read of the old name with it — a text-slot hole, a CEL expression, a single-brace token in a value position | Rename every read of the old name with it — a text-slot hole, a CEL expression | + + No protocol-18 value position reads a flow variable through a single-brace `{…}` token. A value slot takes a CEL value envelope or a literal, and a `{…}` token there is refused. The one `{…}` string a value slot hands on is a CRUD `filter` operand's whole-string query placeholder (`{current_user_id}`, `{today}`): the query engine reads it, and it names no flow variable. A text slot reads `{{ }}` holes. + + Text only. No schema, door, conversion or entry id changes, and each entry keeps its `fromVersion`, `toVersion` and every other field. +- 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. +- 3fed1eb: fix(spec): the liveness ledger reads `app.branding.logo` as `live`, citing the console's `AppMark` reader (#23394) + + Clause-②: no + + **What was wrong.** The liveness ledger shipped with `@objectstack/spec` graded `app.branding.logo` `planned`, with a note saying no mounted component rendered it. The console now does: objectui's `AppMark` draws `branding.logo` as the app's square icon on the top-bar app pill, on each App Switcher row and in the phone drawer header. + + **What changes.** + + - `liveness/app.json`: `branding.logo` reads `live`, citing `AppMark` and the two shell components that mount it at the `.objectui-sha` pin. The designer pages that write the key stay cited as its `producer`. The `branding` container's note says the same. + - `liveness/state-counts/app.md` is regenerated: `app` moves from `live 50 · planned 1` to `live 51 · planned 0`. + + Unchanged: what `AppSchema` accepts and refuses (an app-level `logo` is still refused, and the refusal still points at `branding`), and every other ledger row. +- 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. +- ee0e115: docs: the default-record-page remedies name the other package's page, not the whole package + + Clause-②: no + + A second `isDefault: true` record page for one object is refused with `DEFAULT_RECORD_PAGE_CONFLICT`. The package registry's refusal already told the author to keep one default or "compose the deployment without the other package's page". Three sibling texts still told them to drop the whole package, though a deployment can keep a package and leave out the one page it ships: + + - `@objectstack/metadata-protocol`: the metadata save door's refusal now reads "a page a package ships stays the default unless the deployment is composed without that package's page." Only the remedy clause changed; `code`, `status`, `object` and `holders` are unchanged. + - `@objectstack/spec`: `PageSchema.isDefault`'s TSDoc and the error-code ledger's comment on `DEFAULT_RECORD_PAGE_CONFLICT` say the same. + - `@objectstack/runtime`: `AppPlugin`'s private `bundle` field now documents the three readers outside the class that read it structurally, so a rename is not made blind. + + No behaviour changes; nothing to migrate. +- 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. +- 8684079: **Shipped in 18.0.0-next.2**, whose CHANGELOG missed this entry (#23818); listed here one prerelease late. + + fix(metadata-protocol,metadata): on a database the organization-ownership ceremony has not migrated, the paged family reads, `DatabaseLoader`'s count and the package rollback agree with the environment-only reads; the ceremony's apply is named with `--plan-file` (ADR-0131 D6/D7) + + Clause-②: no + + Every change below matters only where legacy organization rows still sit in the `sys_metadata` family, the database `os serve` refuses to start on until `os migrate organization-ownership` has verified. On a migrated or fresh database every answer is unchanged. + + - **`@objectstack/metadata-protocol`: a paged read leaves the legacy rows out before the page is cut.** `auditMetaItem` (`limit`), `listCommits` (`limit`), `revertStoredPackage`'s first-active-row probe and `SysMetadataRepository.list({ limit })` used to filter a legacy organization's rows out after the limit. A page they sat in came back short, and `revertStoredPackage` answered `RESOURCE_CONFLICT` (409) "has never been published" for a package the environment had published when its first active row was an organization's. When a page holds a legacy row, the read is now repeated with those rows excluded in the query. + - **`@objectstack/metadata-protocol`: `rollbackToPackageCommit` refuses a legacy organization's commit by name.** It answered `COMMIT_NOT_FOUND` (404) for a commit that is there. It now gives `revertCommit`'s refusal: `NOT_OVERRIDABLE` (403), the same sentence, naming the commit and its organization. + - **`@objectstack/metadata`: `DatabaseLoader` counts what it reads.** `exists()` answered true for an address only a legacy organization row held, while `load()` answered null, and `queryHistory`'s `total` counted legacy history rows no page serves. Neither counts them now. + - **`@objectstack/metadata-protocol`: internal cleanup.** The organization plumbing that only ever carried the environment (`null`) is removed from the list merge and the served-row resolution. No export changes. + - **`@objectstack/spec`: wording.** The four ADR-0087 entries `18.sys-metadata{,-audit,-commit,-history}-organization-column-retired` now name the ceremony's reviewed apply, `os migrate organization-ownership --out PLAN` and then `--apply --plan-file PLAN`, which `--plan-file`'s own entry requires. No id, category, version or field the migration engine reads changes. + - **`@objectstack/cli`: wording.** `os migrate apply --allow-destructive`'s refusal on an unmigrated database now reads "a drop the organization-ownership ceremony owns, and the ceremony has not verified on this database". Its `--json` payload is unchanged. +- 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. +- 23d0bc7: fix(spec): a page that declares `print` is no longer warned that nothing applies it, and the flow terminal-toast translations read `live` (#23710) + + Clause-②: no + + **What was wrong.** The console now prints a page that declares `print`: its `PageRenderer` mounts a print stylesheet built from every key of the declaration (paper size and orientation, the four margins, the running header and footer, page numbers, and the two page-break hints). But the liveness ledger shipped with `@objectstack/spec` still graded `page.print` `planned` with an author warning, so `os validate` / `os build` / `os lint` told every author of a print page that printing it prints the screen layout. The `print` describes said the same ("until it ships, nothing applies it"). + + **What changes.** + + - `liveness/page.json`: `print`, its eight keys and the four `margins` sides read `live`, citing the console reader at the `.objectui-sha` pin. The container's `authorWarn` / `authorHint` are dropped, so `lintLivenessProperties` no longer emits `liveness-planned-property` for a page that declares `print`. + - `liveness/translation.json`: `flows.successMessage` and `flows.errorMessage` read `live`. The console's flow runner shows the translated terminal toast through `translateFlow` and falls back to the flow's own text when the bundle has none. + - `PagePrintSchema` and `PageSchema.print`: the describes name the console's browser print rendering as the consumer that applies each key. The server-side PDF renderer is still to come. + + Unchanged: what a `print` declaration accepts and refuses, and every other ledger row. +- 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. +- af3873b: Clause-②: no + + The published TSDoc now sends a form section's translations to the right key. `FormSectionSchema.name` used to name only `metadataForms..sections.`, which is the key for a metadata-editing form (`defineForm({ schemaId })`, read by `resolveMetadataFormLabels`). A section of an object-bound form view is keyed at `objects.._sections.`, where `` is the form's own `data.object`, else its view's. The docblock now names both keys and which form each one serves. `TranslationDataSchema._sections` now names the three surfaces that author its key (form-view sections, `record:details` sections, object `fieldGroups`) and its readers (`walkObjectSections`, `collectViewRecord`, `translateView`). `resolveMetadataFormLabels`' docblock no longer says that a section without a `name` keeps its section-level text: such a section is looked up under a slug of its `label`, as the function has always done. These are comment changes only: no key, `.describe()` text or behaviour changes. An object-form translation bundle written from the old docblock under `metadataForms.…` never translated anything. Move its section entries to `objects.._sections.`. +- f846cc2: docs(spec): the step-18 upgrade text and the `exportOptions` TSDoc say a stored page's next save meets the component-props gate at the metadata save door + + Clause-②: no + + Text only; no accept set moves. Since the component-props gate (`validateComponentProps` / `validateComponentTypes`) runs on a `page` write at the metadata save door, three step-18 semantic entries that `os migrate meta` and the protocol upgrade guide print — `ui-page-action-row-operation-declared`, `ui-object-block-grouping-config-typed` and `element-text-variant-heading-subheading-retired` — and the `object-grid` `exportOptions` TSDoc in the `.d.ts` still said a page component's `properties` is not parsed on the save path. They now say what runs: a stored page still loads, because `properties` is not parsed on the metadata load path, and the next save of the page meets the component-props gate at the metadata save door (422 `INVALID_METADATA` on a finding), beside `os validate`, `os build` and `os lint`. +- 1ecb08a: Clause-②: no + + The `description` of four metadata types in `DEFAULT_METADATA_TYPE_REGISTRY`, which Setup shows under the + type's title, is now one plain sentence for an administrator instead of ADR vocabulary: + + - `api`: "A stable HTTP URL that other systems call to run a flow or work with records in the app" + - `doc`: "A help page written in Markdown that a package ships or you write yourself" + - `book`: "A table of contents that groups help pages into ordered sections" + - `capability`: "A permission a package defines; you grant it through permission sets" + + The ADR references (ADR-0121, ADR-0046, ADR-0046 §6, ADR-0066 D1) moved to the comments beside the + registry rows. The generated metadata-forms translations carry the new sentences in en, zh-CN, ja-JP + and es-ES. No key, type or accept/refuse result changes. +- d0fe050: Clause-②: no + + Six help texts an administrator reads in Setup no longer carry ADR numbers. On the Permission Sets edit form, + `isDefault`, `managedBy` and `packageId` are rewritten as plain sentences that say what the field does, and + `adminScope` drops its "(ADR-0090 D12)" prefix. On the Positions edit form, `delegatable` drops "(ADR-0091 D3)". + The view form's End-user controls description drops "(ADR-0047, Airtable Interface parity)". + + The ADR references moved to the comments beside the form rows. The generated metadata-forms translations carry + the new wording in en, zh-CN, ja-JP and es-ES. No key, type or accept/refuse result changes. +- 92fd343: Clause-②: no + + The docblock on `EVERYONE_POSITION` and `GUEST_POSITION` no longer says that packages suggest + bindings to both audience anchors. It now reads that packages suggest bindings to `everyone` at + install time and the admin confirms each one, never auto-bound, and that no door binds a + permission set to `guest` until ADR-0138 D3 item 5's bind door is built, an act of the + environment's administrator and never of a package. The docblock ships in the package's + `src/**/*.zod.ts` files and in the JSDoc of `dist/*.d.ts`, so it is the hover text an author or an + AI reads on the two constants. No key, schema, export or accepted value changes. +- 905f57b: docs(spec): `deriveRecordFlowSurface`'s docblock no longer calls its override list closed, and names `editMode` + + Clause-②: no + + - **The override sentence.** The shipped docblock said the explicit overrides of the derived default were `navigation.mode` / `navigation.size`, `FormView.type` / `modalSize` or an assigned page — "the sanctioned per-object overrides — no new authorable key". The same package declares `ObjectSchema.editMode` (`'modal' | 'page'`), and the renderer applies it. The docblock now says what the function reads (`def.fields`, `flow`, `opts`, nothing else) and lists only the explicit config the console renderer measurably lets win over this default: `navigation.mode` / `navigation.size`, an assigned page, and `editMode`. The function still does not read `editMode`. `FormView.type` / `modalSize` are no longer listed: an authored `FormView.modalSize` does not reach the console's create/edit modal, and `FormView.type` changes that modal's layout, not its surface. + - **Unchanged:** no behaviour, no schema, no export changes. `deriveRecordSurface` and `deriveRecordFlowSurface` return what they returned before. +- b0a1df0: `sys_organization` now declares an `invite_user` action at `record_header`, so an organization record page can offer "Invite User" as its primary header button. It calls `/api/v1/auth/organization/invite-member` with the record's id as `organizationId`, asks for `email` and `role` (the role picker offers only the roles the caller may grant), and is gated like the other three invite mirrors: the `organization` capability and the owner / admin / delegated_admin grades. Its `order: -1` puts it first in the header. + + `leave_organization` keeps `variant: 'danger'` and now declares `order: 1`, so it sorts last among the header actions and folds into the `⋯` overflow menu instead of taking the third inline slot. Its confirmation now names what is lost: the organization's environments and members (en, zh-CN, ja-JP and es-ES). + + `@objectstack/spec` books the new action in `PUBLIC_AUTH_FEATURES.organization.gatedInputs`, beside the other three invite mirrors. + + Clause-②: no +- 8a83ceb: docs(spec): `IHttpRequest.body`'s TSDoc says what a handler reads, and that reading it may refuse + + Clause-②: no + + - **What it is.** The parsed JSON document for `application/json`, or the parsed fields of a `multipart/form-data` or `application/x-www-form-urlencoded` form; `{}` when the request carries no body. + - **It may refuse.** An implementation that cannot parse the body, or does not read its media type, throws from the read instead of handing over an invented value. The standard transport (`@objectstack/plugin-hono-server`) does, and answers 400 `INVALID_REQUEST` itself, replacing whatever the handler wrote. So a handler must not catch the refusal and carry on. + - **Raw bytes.** A handler that needs them reads `rawBody()` and never reads `body`. + - **Unchanged:** the type (`body?: any`), every other member, and every runtime behaviour. This is prose in the shipped `.d.ts` only. +- 462d02f: Clause-②: no + + The `ActionParam` `options` docblock in `src/ui/action.zod.ts`, and the comment above that option + shape's `guidance` table, no longer say that objectui's `normaliseOptions` rebuilds each option a + field-backed param inherits as a bare `{ label, value }` pair. At the console pin (`.objectui-sha` + `f8267df93aba`) it keeps every key of an inherited option by spread + (`packages/fields/src/resolveActionParams.ts:515` in objectui). The comments now give the reason + that remains for the `guidance` texts not telling an author to make a param field-backed and + inherit `color`, `icon` or `disabled`: the keys survive, but no reader of them on that path is + measured. No key, schema, export, accepted value or message text changes. +- 2ee5861: The liveness ledger's `form.sharing` and `formViews` rows quote the code that reads their keys as it now reads + + Clause-②: no (only the two rows' evidence text changes and both statuses stay `live`: `ViewSchema` accepts `form.sharing` and `formViews` exactly as before, and no state count moves) + + - `liveness/view.json`, row `form.sharing`: the evidence quoted a `slugMatchesPublicLink(sharing.publicLink, slug)` call that no longer exists. It now cites `packages/rest/src/form-routes.ts#findPublicFormView`, which loops over `anonymousFormIntakeCandidates(view)`, and the two rules in `packages/spec/src/ui/anonymous-form-intake.ts` it reads: `anonymousFormIntakeCandidates`, which hands each form's `sharing` to the slug rule, and `anonymousFormIntakeSlug`, which reads all three keys (`enabled`, `allowAnonymous` and `publicLink`). The `resolveFormBySlug` clause now says where `publicFormGrant` is set: in the submit door, for the match that `resolveFormBySlug` returns. + - `liveness/view.json`, row `formViews`: the evidence quoted `const formViews = view.formViews`, which no longer exists. It now quotes the line of `anonymousFormIntakeCandidates` that makes every `formViews` entry a candidate. + - The verdicts, the notes and every other row are unchanged. +- e46ce7b: Clause-②: no + + fix(metadata-protocol): the global search palette titles a hit by its `nameField` before the deprecated `titleFormat` + + - **What changed.** `searchAll` (`GET /api/v1/search`) now resolves a hit's `title` in this order: the primary-title pointer (`nameField`, or the deprecated alias `displayNameField`), then the `titleFormat` template, then the conventional name fields, then first/last name, then the record id. Before, `titleFormat` came first. ADR-0079 D3 and the `titleFormat` schema description both say an explicit `nameField` takes precedence. + - **What it means for a deployment.** An object that declares both keys is now titled by its `nameField` value. For example, an approval request shows `Discount Sign-off · opp_45` instead of `flow:deal_desk · opp_45`. The registry also designates a `nameField` for any object that has a title-eligible field (a text-like field, or a formula returning text). So an object that declares only `titleFormat` is now titled by that designated field too. The template still titles a row when the object has no pointer, or when the pointer's value is empty on that row. + - **Fix for an author who wants the composite title back:** add a `formula` field with `returnType: 'text'` that builds the composite, set `nameField` to it, and drop `titleFormat`. This is the migration ADR-0079 prescribes (`os explain title-format-retired`). + - **`@objectstack/spec`:** the description of `SearchAllHitSchema.title` now states the same order. No schema shape changed. +- 1489952: docs(spec): `record:line_items` `columns` states that an identity-only column hydrates from the child object's field, as on the master-detail carriers, so its heading is the child field's label and translates with it (#24140) + + Clause-②: no + + `RecordLineItemsProps.columns` said that this block does not hydrate a column from the child object's field, so authors had to declare `label`, `type` and `options` on every column, and an identity-only `{ name }` column was headed by its field name. That contradicted the shared column contract the block takes by reference: `InlineGridColumnSchema.label` says it "defaults to the child field's label via hydration". It also left a declared column heading as a plain string that no translation bundle key reaches, so a non-English print of a line-items table read English headings. The describe now states the carriers' rule in the carriers' words. Identity-only entries (`{ name }`) hydrate everything else from the child object's fields, `label`, `type` and `options` included, so the heading is the child field's label, which translates through that field's own translation. Declaring `type` opts a column out of hydration. + + Text only: every input that parsed before parses now, and every refusal is unchanged. The renderer half is objectui's `LineItemsPanel`, which will reuse the same column hydration the master-detail grid uses. Until the console carries it, an identity-only `record:line_items` column still draws as a text cell headed by its field name, so keep explicit labels on columns you need headed today. +- 70cfb12: Clause-②: no + + fix(spec): the protocol 17 → 18 upgrade rationale states the element data-binding ruling in words instead of a tracker id + + - **What was wrong.** Two sentences of step 18's rationale, the one `os migrate meta` prints for the hop and the upgrade guide renders, cited their decision as a tracker number and a ruling letter. The sentence on the element layer's flat data-binding keys and the one on `object-grid`'s `defaultFilters` each carried that parenthetical, which tells an upgrading author nothing. + - **What changes.** Each parenthetical now says what was decided, with the ruling's date: the element layer loses its second data door while the `object-*` blocks keep their native keys, and `defaultFilters` retires together with the element layer's flat binding keys, in the shape of the `defaultSort` retirement. The rest of both sentences, every other fragment, and every conversion and migration entry are unchanged. +- 8bfb17d: Clause-②: no + + The `ActionParam` `options` docblock in `src/ui/action.zod.ts` no longer quotes objectui's inline + param branch as `options: param.options`. At the console pin (`.objectui-sha` `5e15928b4631`) that + branch of `resolveActionParam` reads `options: authoredOptions`, where `authoredOptions` is + `resolveOptionLabels(param.options, ctx.locale)`: the authored array itself when every `label` is + already a string, and otherwise only the entries with an inline per-locale `label` re-spread with + that label resolved. The docblock's point is unchanged: an inline param's options, `visibleWhen` + included, reach the widget as authored. No key, schema, export, accepted value or message text + changes. +- 4f11af5: The protocol 18 migration `page-template-closed-region-unplaced-refused` now says what the console shows for a page row stored before the refusal. Its reason used to say such a row "renders as before"; on the console that ships with 18 it does not. + + Clause-②: no + + A stored page row with an unregistered template or an unplaced region is still served unchanged, and saving it again is still refused until it is renamed. Until then the console draws a refusal in its place. An unregistered template draws no layout, only a refusal that names the template and the registered templates. On a `default`, `header-sidebar-main` or `three-column` page, an unplaced region is not drawn and is not moved into another slot. Where it used to be stacked, under `main`, a refusal names it and the slots that template places. A page that names no template takes its page type's layout, which still stacks such a region under `main`. + + The entry's acceptance criteria are unchanged: rename the template or region, and each renamed region draws in its slot. +- d537a31: docs(spec): the AI blueprint schema's composed-title example reads the lookup's name one hop through it + + The `expression` and `nameField` descriptions on `BlueprintFieldSchema` / `BlueprintObjectSchema` and their strict twins gave `"record.order_no + ' · ' + record.customer"` and `"{order_no} · {customer}"` as a composed title. `customer` is a lookup, so those compose the stored id into the title. They now give `"record.order_no + ' · ' + record.customer.name"` and say that a formula reads a lookup's related record one hop through it, that a second hop is refused, and that a formula title is shown everywhere but cannot be sorted by or searched. No schema shape changes. +- 1173017: `sys_organization` now declares a `rename_organization` action ("Rename Organization") at `record_header` and `list_item`, so an organization record page can rename the organization. It collects `name` (required) and `logo`, both pre-filled from the record, and no `slug`. It calls better-auth's `/api/v1/auth/organization/update` exactly as `update_organization` does (the record's id as `organizationId`, the fields under `data`), and it is gated on `multiOrgEnabled` like the other organization actions. Because the request carries no slug, the organization keeps its slug. It is declared before `change_slug`, so on the record header it takes the third inline slot and `change_slug` moves into the `⋯` overflow menu. + + `update_organization` (name, slug and logo, list row only) and `change_slug` are unchanged. + + `@objectstack/spec` books the new action in `PUBLIC_AUTH_FEATURES.multiOrgEnabled.gatedInputs`, beside `update_organization`. + + Clause-②: yes +- 2ce2e36: Clause-②: no + + The `forceCbpMasterDetailRequired` docblock in `src/data/object.zod.ts` no longer says that today only + the security gate (`assertControlledByParentWrite`) closes the null-master-FK shape of a + `controlled_by_parent` object. That was true before the builder's force landed, and the sentence is now + in the past tense. It also names what has refused the declaration at parse since protocol 18: + `refuseControlledByParentWithoutOneMaster` refuses a `controlled_by_parent` object none of whose + `master_detail` fields declares `required: true`. No key, schema, export, accepted value or message text + changes. +- 01fafde: A page element's `api` data source is described as rendered, and `visibleWhen` says where `data` is mounted + + Clause-②: no (describe and TSDoc text only: `ElementApiDataSourceSchema` and `PageComponentSchema` accept exactly what they accepted before, and every refusal keeps its issue `code` and `path`) + + - `ElementApiDataSourceSchema`'s describe dropped its `[PLANNED — not yet rendered]` marker. It now says what the console renders: the console reads the binding on every page element and binds the value `path` selects as `data`, on that element and on every element nested inside it. The element's `visibleWhen` is decided once the read answers. While the read is in flight, or when the binding is refused or the read fails, the element draws a loading or error panel instead. + - `PageComponentSchema.visibleWhen` (its describe and its TSDoc) no longer says that no renderer reads the binding. The renderer mounts no `data` root on an element that no `api` data source binds, where a `data.*` read still faults and the gate never gates. On an element that one binds, `data` is the bound value. + - The `page:tabs` item `visibleWhen` describe's parenthetical now reads that the component-node evaluator mounts no `data` root "save on an element an `api` data source binds", where `data` is that binding's value. That surface's own evaluator still binds `data` to the record row. + - The binding's refusal and unknown-key messages no longer name a page element. For example, "`read.url` of a page element's `api` data source must be …" now reads "`read.url` of an `api` data source must be …", and the unknown-key surface is "this `api` data source". The `object` guidance names the object binding only where the `dataSource` takes one. Each message keeps its issue `code` and `path`. +- ba5bfe9: Clause-②: no + + The `source` describe on `HistoryMetaItemResponseSchema`'s events, the body of + `GET /api/v1/meta/:type/:name/history`, no longer lists `"fs"`, `"studio"`, `"rest"`, `"ai"` and + `"git-import"` as its labels: none of the write paths in `@objectstack/metadata-protocol` whose history + rows this door reads stamps any of them. It now says what the field is: an open code-path label the + server stamps, not an enum an author or client picks from, for example `protocol.saveMetaItem`, + `protocol.publishMetaItem` or `migrate-stored`, with `sys-metadata-repo` as the repository's own default + and the answer for a row that stored none. The field stays `z.string()`, so no key, type, export or + accepted value changes, and the door answers exactly what it answered before. +- 94e60c0: `defaultRowScope`'s describe, TSDoc and liveness row now say what the security plugin's reader does with the key + + Clause-②: no — only describe, TSDoc and liveness-ledger text move; `ObjectSchema` and `DefaultRowScopeSchema` accept and refuse exactly what they did. + + - `ObjectSchema.defaultRowScope`'s describe no longer says the key is not applied. It states what the row-policy collection does: the default applies when no permission set the caller holds carries a row policy for the object and the operation (a `'*'` policy counts; one switched off, outside the caller's positions, or for another operation does not), it is judged like a held policy, a `using` the compiler cannot lower makes the reads it governs return no rows with one server warning, and authoring checks do not judge that predicate. The TSDoc on the key and on `DefaultRowScopeSchema` say the same. + - `liveness/object.json`: the `defaultRowScope` row and its eight members are `live`, citing the security plugin's collection, compiler and explain as their readers. Authoring the key no longer raises the `liveness-planned-property` warning. +- 741aa85: Clause-②: no + + The `runAutomations` and `treatAsHistorical` describes on `ImportRequestSchema` (and so `CreateImportJobRequestSchema`, the same schema) now say who may send the opt-out: only the system principal, an in-process caller such as a seed or migration job. The import doors refuse `runAutomations: false` and `treatAsHistorical: true` from every other caller, every signed-in user and administrator included, with `403 PERMISSION_DENIED` before any row is read. The `runAutomations` describe no longer tells a caller that it gets a silent bulk load by sending `false`, and the schema's example body no longer sends it. The schema shape and both defaults are unchanged. +- ff48407: Clause-②: no + + fix(spec): published copy that names the running system says "this system", or drops the noun, not "this environment" + + - **What changed.** Inside the running system, user-facing copy names the system the reader is in as "this system", or drops the noun. A permission set made here, not shipped by a package, is "authored in this system". + - The permission-set `packageId` refusal on the save, restore and publish doors now reads "a set written into no package is authored in this system, which an absent `packageId` states". + - The permission set's `packageId` help text in Setup ends "Leave empty for a set authored in this system." The English Setup forms bundle of `@objectstack/platform-objects` is regenerated from it. + - The `permission.managedBy` tombstone prescription, and the `permission-set-managed-by-retired` protocol-18 upgrade-guide entry, say "a set authored in this system carries neither". + - The `AudienceConfig.posture` and `AuthConfig.audience` describes say "who may self-register", with no noun. Their TSDoc says "who may become a user of this system's apps", the sentence the Setup Authentication group already reads. + - `AiModelsResponse.models` describes "Models this system offers". The `IMetadataService.listDiagnosed` TSDoc example reads "this system contains N items". + - **Kept "environment", where the word names a deployment or configuration scope.** `SeedLoaderConfig.env` ("Only load datasets matching this environment", the `prod | dev | test` tier), the `package-uninstall-environment-wide` upgrade-guide entry (the ADR-0131 environment-wide scope, as against an organization), and the environment artifact's consent-record TSDoc. + - **Unchanged.** Every error code, status, issue path, accepted value and export. Only the human-readable text moves. +- de70e97: Retirement prescriptions name `os migrate meta --write`: "… to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand." + + Clause-②: no + + Wording only: no schema, key, type, export or error code changes, and every input that parsed or was refused before gets the same verdict at the same path. Only the closing sentence of the message changes. + + - A prescription covered by an ADR-0087 conversion used to close with "Run `os migrate meta --from N` to list the mechanical edits for existing sources; apply them by hand." It now closes with "Run `os migrate meta --from N` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand." The default run still only lists. `os migrate meta --write` rewrites in place each edit it can trace to one literal in one project file and lists every other edit with the reason it was not written. The sentence never says the tool rewrites your sources automatically, because `--write` does not write every edit. Text up to and including "for existing sources;" is unchanged, so a caller matching that prefix still matches. + - The three prescriptions for conversions that cover only part of a value (dashboard `compareTo.offset`, the script flow node's `config.actionType` and the package manifest `permissions` case) carry the same `--write` clause before their second clause. + - The `record:chatter` / `record:discussion` `position` value prescriptions (`'sidebar'`, `'inline'`, `'drawer'`) used to tell the author to run a bare `os migrate meta`, which the command refuses because `--from` is missing. They now name `os migrate meta --from 17` and close with the same sentence. + - `@objectstack/lint`: the script node's retired dispatch-key diagnostic and the dataset widget's `chartConfig.xAxis.field` hint close with the new sentence. + - `@objectstack/driver-turso`: the `timeout`, `localPath` and `wasm` config tombstones close with the new sentence. +- 74c4b34: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `088e0f705520`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `e5311165a8d6..088e0f705520`: 54 paths over 3 commits (objectui#12110's rich-text hint lines, objectui#12174 and objectui#12175). Files that three records cite changed on the hop, all for objectui#12175, and every cited line in them held or moved with its text byte-identical: + + - `form.tsx` (+3, registering the `position-ref` / `permission-ref` widget hints): `spanLadderFor` held at `:275-302`, and its one call site moved `:3326` -> `:3329` with the call unchanged. So `FormField.span: 'full'` still takes the whole row at every multi-column tier. + - `data-objectstack/src/index.ts` (+33 at `:5335`, the adapter's `listMetadataItems`): this lands below both reads the curated-public-blocks record cites, so `serializeOrderBy` `869-895` and the `translateFilterArray` arm `5066-5077` are unmoved. + - The `en` / `zh` / `de` packs (`fields.registryRef` keys, +4 each): the calendar record's `calendar.configRequired` strings moved by +4 and did not change. + + Every other record cites only files that are byte-identical across the hop (`git diff --quiet`, checked file by file), and each gains a hop sentence that says so. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `e5311165a8d6` number. The corpus is now 8519 tracked files, and every retired name each entry counts still reads 0. The lit controls moved with the corpus, for example `objectstack` from 18355 to 18400 and `useState` from 2659 to 2661. + + No key, default, enum member or export moves. +- e040b8c: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `0df67f237c5a`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `20c6d351ad74..0df67f237c5a`: 293 paths over 21 commits, none deleted and one renamed (`resolveActionParams.ts`, which no record cites). Seventeen files a record cites changed on the hop, plus the `en` / `zh` / `de` packs (whose `calendar.configRequired` strings did not change): `ObjectGrid.tsx`, `ListView.tsx`, `ObjectCalendar.tsx`, `plugin-calendar/src/index.tsx`, `ObjectTimeline.tsx` and plugin-timeline's `renderer.tsx`, `ObjectKanban.tsx`, `KanbanBoardCore.tsx`, `KanbanImpl.tsx`, `plugin-kanban.mdx`, `MePermissionsProvider.tsx`, `PermissionProvider.tsx`, `record-details.tsx`, `basic/data-list.tsx`, `data-objectstack/src/index.ts`, `form.tsx` and plugin-view's `ObjectView.tsx`. Every cited line in them moved or held with its text byte-identical, and each record that cites one is re-pointed with a hop sentence that says by how much. The records that cite only byte-identical files gain a hop sentence that says so. + + Three readings changed around a cited read, and each hop sentence says what it found: + + - `basic/data-list.tsx`: the `element:repeater` registration no longer publishes the flat `object` / `filter` / `sort` / `limit` inputs; the node-level `dataSource` binding is its one published query input (objectui#12085). The renderer still reads the four as the binding's fallback. The row's two sentences that named the published inputs are rewritten where they stand. + - `ObjectGrid.tsx`: the `delete` verdict the implicit bulk-delete is gated on now comes from the affordance-to-grant map's `rowDelete` row (objectui#12082). It still asks the managed-object policy and the object's effective API operations, now with the caller's delete grant beside them, so the `sys_api_key` reading holds. The `inlineEditable` verdict reads the same map's `listInlineEdit` row. + - `ListView.tsx`: the calendar arm gained objectui#12081's fetch window. Its date-field reads are byte-identical and still declared-only, with no floor. + + The re-read also corrects two anchors the previous hop left on the `object-tree` flat-config record: its opening still named the `case 'tree'` arm at its `47b1f0bb7` span, and its hop cited the `labelField` line one line low. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `20c6d351ad74` number. The corpus is now 8424 tracked files. Every token an entry counts as zero still reads zero (98 tokens). The controls moved with the corpus, for example `objectstack` from 18047 to 18248 and `timeout` from 1694 to 1705. + + No key, default, enum member or export moves. +- f451a59: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `1fb1ced36243`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `7578323a544e..1fb1ced36243`: 116 paths over 11 commits. One set of cited files changed on the hop, and every cited line in it held its text: + + - The `en` / `zh` / `de` packs (two commits each: objectui#12248, objectui `51c661e38`, and objectui#12268, objectui `7af0ab378`) gained keys below line 1100 only (`en.ts` +9, `zh.ts` +6, `de.ts` +6). Their `calendar.configRequired` strings did not change and held at `en.ts` `:953`, `zh.ts` `:726` and `de.ts` `:706`. The functional-completeness `calendar` row says so. + + Every other asserting record cites only files that are byte-identical across the hop (`git diff --quiet`, checked file by file), and each gains a hop sentence that says so. That covers `form.tsx`, `autoLayout.ts` and `field-type-alias.ts`, so `FormField.span: 'full'` still takes the whole row at every multi-column tier, and `ObjectGrid.tsx` and `data-table.tsx`, so the `keyboardNavigation` read still finds 15 hit lines against 3 for the control (no line of the hop's diff names `keyboardNavigation`). + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `7578323a544e` number. The corpus is now 8642 tracked files, and every retired name each entry counts still reads 0 (a superset of 99 names: the 13 lifecycle, 31 metrics and 37 tracing exports, and 18 key names the entries retire or count; the unrelated `Span` 519 and `SpanSchema` 57 are unchanged). The lit controls moved with the corpus, for example `objectstack` from 18552 to 18601, `timeout` from 1686 to 1700 and `tenant` from 1404 to 1415. + + No key, default, enum member or export moves. +- ba95607: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `20c6d351ad74`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `47b1f0bb7174..20c6d351ad74`: 320 paths over 24 commits, 2 of them deleted test files, none renamed. Ten files a record cites changed on the hop, plus the `en` / `zh` / `de` packs: `ListView.tsx` and `ObjectTimeline.tsx` (objectui#6152 rounds 14 and 15), plugin-view's `ObjectView.tsx`, `objectql.ts` and `objectql.zod.ts` (the same rounds and objectui#12063), `KanbanImpl.tsx` and `plugin-grid/src/index.tsx` (objectui#6349 batch 7), `ActionRunner.ts` (objectui#6349 batch 4), `data-objectstack/src/index.ts` (objectui#5144) and `previews/block-config.ts` (objectui#12056). Every cited line in them moved or held with its text byte-identical, except in the four readings below, and each record that cites one is re-pointed with a hop sentence that says by how much. The records that cite only byte-identical files gain a hop sentence that says so. + + Three readings changed around a cited read, and each hop sentence says what it found: + + - The calendar, gantt and tree arms of `ListView.tsx` changed inside (objectui `3fd862510` retired their raw block spreads, the calendar's `defaultView` lift and the tree's `titleField` label rung). The calendar and gantt rows of `functional-completeness.ts` still hold: their date bindings stay conditional, with no floor. + - `previews/block-config.ts` now writes the repeater's object and limit into the node-level `dataSource` (objectui#12056); the two inspectors span `311-351`. + - `ObjectTimeline.tsx`'s start-date chain lost its nested `timeline.dateField` rung; it still ends without a literal rung, and the component's `schema.*` read set is unchanged. + + The fourth reading: one record names a read that is gone at this pin. The `OBJECT_TREE_FLAT_CONFIG_GUIDANCE` docblock keeps `titleField` in the flat tree key set because `ListView`'s flatten resolved `treeCfg.titleField` into `labelField`. objectui `3fd862510` removed that rung. The hop sentence records it; the paragraph and the key set are left as they are, because changing the key set is a schema decision this re-read does not make. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `47b1f0bb7174` number. The corpus is now 8351 tracked files. Every token an entry counts as zero still reads zero (97 tokens). The controls moved with the corpus, for example `objectstack` from 17980 to 18047 and `timeout` from 1674 to 1694. + + No key, default, enum member or export moves. +- b53b949: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `47b1f0bb7174`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `f0268ad78485..47b1f0bb7174`: 129 paths over 14 commits, none deleted or renamed. Two files a record cites changed on the hop, each for objectui#11865 (a picker drawn with the shared `Select`): `ObjectGrid.tsx` gained one import line and redrew its grouped pager's rows-per-page picker, and `ListView.tsx` redrew its "Color by field" and rows-per-page pickers. Every cited line in those two files moved with its text byte-identical, so the nine records that cite them are re-pointed, and each hop sentence says by how much. The other 34 records cite only files that are byte-identical across the hop, and each gains a hop sentence that says so. + + No record says anything false at the new pin, so no reading is rewritten. + + The `FormField.span` describe changes its sha only: `WIDE_FIELD_TYPES`, the field-type alias table and `spanLadderFor` are byte-identical at the new pin. The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `f0268ad78485` number. The corpus is now 8281 tracked files. Every token an entry counts as zero still reads zero: none of them occurs on a line the hop adds or removes. The controls moved with the corpus, for example `objectstack` from 17956 to 17980 and `timeout` from 1658 to 1674. + + No key, default, enum member or export moves. +- ea9fd99: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `4997995022f8`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `0df67f237c5a..4997995022f8`: 80 paths over 7 commits, none deleted and one renamed (a schema-catalog example, which no record cites). Three files a record cites changed on the hop, plus the `en` / `zh` / `de` packs (whose `calendar.configRequired` strings did not change): `ObjectGrid.tsx` (objectui#12081 item 3 and objectui#12082), `ObjectGantt.tsx` (objectui#12082) and `form.tsx` (objectui#12121). Every cited line in them moved or held with its text byte-identical, and each record that cites one is re-pointed with a hop sentence that says by how much. The records that cite only byte-identical files gain a hop sentence that says so. + + Two readings sit beside a cited read, and each hop sentence says what it found: + + - `ObjectGrid.tsx`: a bulk `update` def is now withheld unless the edit verdict allows it (objectui#12082), which narrows the declared defs and adds no implicit one, so the `sys_api_key` reading holds. The selection block still hashes to `c88443302d40`. + - `ObjectGantt.tsx`: the chart's in-chart write handlers are withheld from a caller without the grant (objectui#12082). No `schema.*` read moved; the renderer reads the same 49 names at both pins. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `0df67f237c5a` number. The corpus is now 8441 tracked files. Every token an entry counts as zero still reads zero (96 tokens; `Span` and `SpanSchema` read 517 and 57 at both pins, as the tracing entry records). The controls moved with the corpus, for example `objectstack` from 18248 to 18271 and `timeout` from 1705 to 1704. + + No key, default, enum member or export moves. +- 1e44436: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `7578323a544e`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `9646e2ed2bf3..7578323a544e`: 288 paths over 25 commits. Three files that records cite changed on the hop, and every cited line in them held its text; where it moved, it moved byte-identical and is re-pointed: + + - `plugin-list/src/ListView.tsx` (+34/-79, objectui#12217, objectui `f5b66bf97`: an empty gallery draws the grid empty state). One import line was added at `:16` and a four-line docblock near `:1040`; the other hunks change lines in place (`:10`, `:12`, `:1044`) or sit at `:5677` and below. So `resolveListMapConfig` moved `:209` -> `:210`, `FLAT_MAP_CONFIG_SPELLING` `:95` -> `:96` and `resolveTimelineDateBinding` `695` -> `696`. The `kanban` / `calendar` / `timeline` / `gantt` / `map` / `tree` arms moved by five, to `3767` / `3818` / `3907` / `3964` / `4084` / `4125-4149`. The functional-completeness `calendar`, `gantt`, `timeline` and `map` rows, the `object-gantt` and flat-`TreeConfig` records and the map-config record say so, and the tree record's `4120-4144` and the map-config record's two quoted anchors are re-pointed. The `kanban` row-cap record names `ListView.tsx` and `ObjectView.tsx` only as an earlier re-read's record, and its sentence says so. + - `core/src/actions/ActionRunner.ts` (+39/-23, objectui#12191, objectui `334f0cff9`): eleven import lines were added above `composeSuccessMessage`, which moved `1498` -> `1509` with its body byte-identical, and every other hunk lies below it. The `outcomeMessages` record in `action.zod.ts` re-points its anchor, and it and the header of `action-outcome-messages.test.ts` say so. The four action renderers are byte-identical. + - The `en` / `zh` / `de` packs (five commits each): the `calendar.configRequired` strings did not change and moved `en.ts` `:949` -> `:953`, `zh.ts` `:725` -> `:726` and `de.ts` `:705` -> `:706`. + + Every other record cites only files that are byte-identical across the hop (`git diff --quiet`, checked file by file), and each gains a hop sentence that says so. That covers `form.tsx`, so `FormField.span: 'full'` still takes the whole row at every multi-column tier. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `9646e2ed2bf3` number. The corpus is now 8600 tracked files, and every retired name each entry counts still reads 0. The lit controls moved with the corpus, for example `objectstack` from 18438 to 18552, `timeout` from 1707 to 1686 and `tenant` from 1393 to 1404. + + No key, default, enum member or export moves. +- 2acff87: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `9646e2ed2bf3`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `088e0f705520..9646e2ed2bf3`: 64 paths over 9 commits. One file that any record cites changed on the hop: `plugin-list/src/ListView.tsx` (+9/-9, objectui#12189, objectui `26b0bd2d1`, which renames the FilterBuilder group type to `FilterBuilderRowGroup`). All nine lines were replaced in place, the last at `:1558`, so no line in the file moved and no cited range contains a changed line: + + - the `kanban` / `calendar` / `gantt` / `timeline` / `map` / `tree` arms of its per-type props builder held at `3762` / `3813` / `3959` / `3902` / `4079` / `4120-4144`; + - `resolveListMapConfig` (`:209-214`) and `resolveTimelineDateBinding` (`:695-699`) held, and each ends before the next changed line below it (`:324`, `:756`). + + That file is cited by the four view-type rows of the functional-completeness table (`calendar`, `gantt`, `timeline`, `map`) and by the `object-gantt` and flat-`TreeConfig` records. Each of those gains a hop sentence naming it. The `kanban` row-cap record names `ListView.tsx` only as an earlier re-read's record, and its sentence says so. + + Every other record cites only files that are byte-identical across the hop (`git diff --quiet`, checked file by file), and each gains a hop sentence that says so. That covers `form.tsx`, so `FormField.span: 'full'` still takes the whole row at every multi-column tier. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `088e0f705520` number. The corpus is now 8540 tracked files, and every retired name each entry counts still reads 0. The lit controls moved with the corpus, for example `objectstack` from 18400 to 18438, `tenant` from 1365 to 1393 and `useState` from 2661 to 2662. + + No key, default, enum member or export moves. +- 299a2c6: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `a58626c88dc8`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `0abd4f9f8769..a58626c88dc8`, 252 paths over 36 commits. Fifteen of those paths are files an asserting record cites: `ObjectKanban.tsx`, `KanbanImpl.tsx`, `ObjectTree.tsx`, `ObjectTimeline.tsx`, `record-details.tsx`, `action-group.tsx`, `action-menu.tsx`, `static-params.ts`, `MetricWidget.tsx`, `MetricCard.tsx`, `form.tsx`, `plugin-kanban.mdx` and the `en` / `zh` / `de` packs. In each, every cited line is byte-identical at the new pin, so each anchor that moved was re-pointed to its new line and the record says by how much; no read point an asserting record cites changed content or died. Every other cited file is byte-identical across the hop (`git diff --quiet`). The seven quoted anchor lines verify against objectui at the new pin. Three records carry a count, and each count was re-taken by its record's own method with the same reading: the `keyboardNavigation` hit lines (15, against 3 for the `schema.editable` control), `ObjectKanban.tsx`'s `quickAdd` / `onQuickAdd` (2 each, against 11 for `onCardClick`), and the `ElementDataSourceGate` occurrences in five `src/index.tsx` shells (0, 3, 3, 3 and 4). + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that first reproduced every `0abd4f9f8769` number. The corpus is now 7754 tracked files. All 99 checked tokens (the export lists of `plugin-lifecycle-advanced.zod.ts`, `tracing.zod.ts` and `metrics.zod.ts`, plus every named key) still read zero, except `Span` / `SpanSchema`, which read 509 / 57: the one new `Span` hit is a `colSpan`. + + No key, default, enum member or export moves. +- 325a92e: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `b9814b334d8a`. Four descriptions this pin makes true change with it. + + Clause-②: no + + The four descriptions, each now read by the console at this pin: + + - `EmptyStateSchema.link`, the one link an empty state carries, no longer opens with `[PLANNED — not yet rendered]`. The console draws it in a list's empty state (the `grid` and `gallery` visualizations) and in an `object-grid` block's empty state, and follows it through its action runner (objectui#12270). + - A list page's own `interfaceConfig.emptyState` no longer opens with `[PLANNED — not yet rendered]`. The console's list page prefers it, whole, over its source view's empty state (objectui#12270). + - A `record:details` section's `count` no longer opens with `[EXPERIMENTAL — not enforced]`. The record detail page fills the heading's `{{ count }}` hole (objectui#12192). + - The `object-grid` block's `emptyState` description names `link` among the members the grid draws. + + The two liveness-ledger notes that excluded `emptyState.link` and `interfaceConfig.emptyState` from their rows' `live` verdict now say both are covered. + + Every asserting citation (55, across 2047 spec sources) was mapped through the objectui diff `1fb1ced36243..b9814b334d8a`: 397 paths over 36 commits. Where a cited file changed, each cited line was re-read and its new number recorded; none changed what it reads. The moved files are `ObjectGrid.tsx`, `data-table.tsx`, `ListView.tsx`, `ObjectCalendar.tsx`, the `en` / `zh` / `de` packs, `record-details.tsx`, `record-highlights.tsx`, `record-quick-actions.tsx`, `data-objectstack/src/index.ts`, `objectql.ts`, `plugin-kanban/src/index.tsx`, `plugin-grid/src/index.tsx`, `plugin-calendar/src/index.tsx` and `block-types.ts`. Every other record cites only files that are byte-identical across the hop, and each gains a hop sentence that says so. Three quoted anchors of the `map` block record moved and were re-pointed; they are `case 'map':` in `ObjectView.tsx`, and `ObjectMapConfigSchema` and `LIST_VIEW_LOCAL_OVERRIDES` in `objectql.zod.ts`. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `1fb1ced36243` number. The corpus is now 8743 tracked files. A superset of 98 names reads 0: the 13 lifecycle, 31 metrics and 37 tracing exports, and 17 further names the entries count (their retired key names and the two logging defs). The unrelated `Span` 519 and `SpanSchema` 57 are unchanged. The lit controls moved with the corpus, for example `objectstack` from 18601 to 18854, `timeout` from 1700 to 1750 and `tenant` from 1415 to 1420. + + No key, default, enum member or export moves. +- e23eccb: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `e5311165a8d6`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `4997995022f8..e5311165a8d6`: 287 paths over 24 commits, four deleted (`StudioHomePage.tsx`, its two tests and `permission-set-clone-dispatch.ts`, none of which a record cites) and none renamed. Five files a record cites changed on the hop, plus the `en` / `zh` / `de` packs (whose `calendar.configRequired` strings did not change): `ObjectGrid.tsx` (objectui#12110 and objectui#12143), `data-table.tsx` (objectui#12124 and objectui#12145), `form.tsx` (objectui#12110, objectui#12130 and objectui#12155), `record-related-list.tsx` (objectui#12082) and `data-objectstack/src/index.ts` (objectui#12112). Every cited line in them moved or held with its text byte-identical, and each record that cites one is re-pointed with a hop sentence that says by how much. The records that cite only byte-identical files gain a hop sentence that says so. + + Two readings sit beside a cited read, and each hop sentence says what it found: + + - `record-related-list.tsx`: the list's fallback remove now reads the affordance-to-grant map's `relatedRowDelete` row (objectui#12082). The hunks sit above and below the `requiredPermissions` gate; the gate and the related object's read gate are byte-identical, 17 lines down. + - `form.tsx`: the built-in branches now settle the widget a field hint renders and the props they hand their control (objectui#12110, objectui#12155, objectui#12130). `spanLadderFor` and its one call site are byte-identical, so the `span` describe's reading holds. + + The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that reproduces every `4997995022f8` number. The corpus is now 8506 tracked files. Every token an entry counts as zero still reads zero: 124 candidate tokens (every export of the four spec files the entries name, and every key they name) read zero at both pins. `Span` reads 519 against 517, both new hits `colSpan`, and `SpanSchema` reads 57 at both pins, as the tracing entry records. The controls moved with the corpus, for example `objectstack` from 18271 to 18355 and `timeout` from 1704 to 1706. + + No key, default, enum member or export moves. +- bf492c8: The spec's objectui citations, and the shipped description text that names the `.objectui-sha` pin (the `FormField.span` describe and six migration-entry descriptions), are re-measured against the new console pin, objectui `f0268ad78485`. + + Clause-②: no + + Every anchor was mapped through the objectui diff `a58626c88dc8..f0268ad78485`, 1061 paths over 170 commits. Where a cited line moved with its text byte-identical, the record is re-pointed and its new hop sentence says by how much. Where objectui rewrote a cited line around the same read, the range is re-read and the hop sentence says so. + + Three records said something that is false at the new pin, and only those sentences are rewritten: + + - `element:repeater` reads the node-level `dataSource` binding first since objectui#11880 (objectui `aaba8655a`), with its flat `object` / `filter` / `sort` / `limit` keys as the fallback. The row's docblock said the binding was not read. It now states the precedence the renderer applies, and that a node naming its object only through `dataSource` renders while this row still requires `object`: that requirement can follow the renderer only by a schema change, which is objectstack#11509's. + - `action:button` now publishes `undoable` and hands the runner the record in scope as its Undo baseline (objectui#11168, objectui `f0496bdf1`). The row's docblock said the input stayed unpublished because the block wrote no row stash. + - `action:icon` reads `label` six times, not five: the tooltip of a predicate-disabled icon takes it as its heading (objectui#11839, objectui `cef0eeec0`). + + The `FormField.span` describe changes its sha only: `WIDE_FIELD_TYPES`, the field-type alias table and `spanLadderFor` are byte-identical at the new pin. The six migration entries' corpus counts were re-taken with `git grep -o -F`, the method that first reproduced every `a58626c88dc8` number. The corpus is now 8234 tracked files. All 99 checked tokens still read zero, except `Span` / `SpanSchema`, which read 517 / 57: the eight new `Span` hits are each a `colSpan`. + + No key, default, enum member or export moves. + ## 18.0.0-next.5 ### Minor Changes diff --git a/packages/spec/package.json b/packages/spec/package.json index 5494d8e468a..a89d50fff9e 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "18.0.0-next.5", + "version": "18.0.0", "description": "ObjectStack Protocol & Specification - TypeScript Interfaces, JSON Schemas, and Convention Configurations", "license": "Apache-2.0", "main": "dist/index.js", diff --git a/packages/triggers/trigger-api/CHANGELOG.md b/packages/triggers/trigger-api/CHANGELOG.md index 1b6933cf216..4e15ee7ee6f 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,437 @@ # @objectstack/trigger-api +## 18.0.0 + +### Major Changes + +- 5ce6773: feat(trigger-api)!: the inbound hook door refuses the body-only `x-objectstack-signature` form, so only the timestamped, replay-protected form verifies + + Clause-②: no (narrowing) + + + + **BREAKING**: an accept-set narrowing on a runtime door, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **What narrows, from → to.** + + - `POST /api/v1/automation/hooks/:flowName/:hookId`, through the self-hosted mount and through `ITriggerApiService.handleInboundHook` alike. A post whose `x-objectstack-signature` was the body-only value (`sha256=` and the hex HMAC-SHA256 of the raw body under the flow's secret) was accepted, `202` and enqueued, with a deprecation `warn` line once per armed hook. It now gets the same `401 INVALID_SIGNATURE` every post that does not verify gets, and nothing is enqueued, even when the value is the secret's HMAC of the body. The body-only form signs no time, so it carries no replay protection. + - The one accepted form is the timestamped one: `t=,v1=`, the HMAC-SHA256 of `.` under the flow's secret, refused when `t` is more than 300 seconds from the server clock. + - The deprecation line is gone. The refusal's `warn` line carries the cause `signature-body-only`, beside `unknown-flow`, `wrong-hook-id`, `signature-absent`, `signature-malformed`, `signature-mismatch` and `signature-outside-window`. It never carries the secret, the signature value or the body. + - `verifySignature(secret, rawBody, header)`, exported by `@objectstack/trigger-api`, answers as the door does: `true` only for the timestamped form inside its window, `false` for a body-only value. Its signature is unchanged. + + **Unchanged: the outbound contract.** `@objectstack/core`'s `verifyHttpSignature` still verifies both forms, and `signHttpBody` still writes the body-only value: the messaging outbox and the flow `http` node sign it for third-party receivers, as the outbound webhook contract publishes. Only this inbound door refuses it. + + **Who sees a difference.** A sender that signs the timestamped form, as the inbound-hook guidance teaches (the `objectstack-automation` skill's Inbound webhook section, `os explain flow-api-trigger-secret-missing`, `signHttpBodyAt` in `@objectstack/core`), sees none. A sender still on the body-only value now gets `401`, and the trigger's `warn` line for its post names `signature-body-only`, which tells an operator its secret is right and its signing form is the retired one. + + **`@objectstack/spec`.** The `ITriggerApiService.handleInboundHook` docblock names the timestamped form as the only one accepted and the body-only form as refused. The type is unchanged. + +### Minor Changes + +- 7b0a9c9: feat(core,trigger-api): a timestamped `X-Objectstack-Signature` form with a five-minute tolerance window, accepted by the inbound flow hook beside the body-only form + + Clause-②: yes + + - **The new form.** `x-objectstack-signature: t=,v1=`, where the hex is the lowercase HMAC-SHA256 of `.` under the flow's secret. The timestamp is inside the signed material, so it cannot be changed without the secret. The inbound hook (`POST /api/v1/automation/hooks/:flowName/:hookId`) refuses the value once `t` is more than 300 seconds from the server's clock, in either direction, so a captured post stops verifying five minutes after it was signed. + - **Refusals.** A value that starts with `t=` is judged by the timestamped form alone. If it is malformed, carries the wrong HMAC, carries the body-only HMAC, or has a `t` outside the window, the answer is the same `401 INVALID_SIGNATURE` a bad signature gets, and nothing is enqueued. + - **Body-only signatures keep working.** `sha256=` is still accepted, so no existing sender breaks. It signs no time, so a captured post can be replayed, and it is now deprecated: the trigger logs one `warn` line per armed hook that names the flow and the hook, the first time that hook accepts a body-only post. + - **`@objectstack/core` exports.** `signHttpBodyAt(body, secret, timestampSeconds)` signs the new form. `verifyHttpSignature(body, secret, header)` verifies both forms and returns an `HttpSignatureVerdict` (`{ valid: true, scheme: 'v1' | 'body-only' }`, or `{ valid: false, reason }`). `HTTP_SIGNATURE_TOLERANCE_SECONDS` is `300`. They sit beside `signHttpBody` and `HTTP_SIGNATURE_HEADER` in the one definition of the scheme. `@objectstack/trigger-api`'s `verifySignature` keeps its signature and now verifies through it, so it accepts both forms. + - **Unchanged.** ObjectStack's own senders, the messaging HTTP outbox (webhooks) and the flow `http` node, still send the body-only form. Receivers outside ObjectStack verify exactly that today. + + **To adopt it as a sender.** Sign at send time, not at enqueue: `t = Math.floor(Date.now() / 1000)`, then send `t=,v1=.">` in `x-objectstack-signature`. From TypeScript, call `signHttpBodyAt(rawBody, secret, t)`. +- 75b6d6a: feat(trigger-api): the `trigger-api` service serves the inbound hook from a web-standard `Request` (`ITriggerApiService.handleInboundHook`), and the self-hosted route is found under `http.server` too (#22774) + + Clause-②: yes + + - **What is new.** Wherever `ApiTriggerPlugin` installs its `api` trigger, it now registers the `trigger-api` service and implements its optional `ITriggerApiService.handleInboundHook(request, hook)` member. That member is the inbound hook, `POST /api/v1/automation/hooks/:flowName/:hookId`, taking a `Request` and returning a `Response`, with no raw app, no Hono context and no `http.server`. A hosted tenant kernel owns no socket, so this is how the door reaches it. Its one caller is the runtime HTTP dispatcher's exact `POST /automation/hooks/:flowName/:hookId` domain, which lands separately. The flow name and the hook id come from `hook`, the route the caller matched, and are never parsed again from the URL. The body is read once, from the request itself, and verified as the sender's own bytes. + - **One door, two faces.** The member and the self-hosted route run the same code: the same hook match, the same per-hook secret, read at that moment, the same constant-time verification of `x-objectstack-signature` (the timestamped form and the deprecated body-only form alike), done before anything is enqueued, and the same answer for every outcome. For the same request they answer the same status, the same `Content-Type: application/json` and the same bytes: `401 INVALID_SIGNATURE` for every post that does not verify, `503 SERVICE_UNAVAILABLE` when the hook's secret cannot be read or no queue service is registered, `400 INVALID_REQUEST` for a body that is not a JSON object, `503 ENQUEUE_FAILED`, or `202` with `{ accepted: true, messageId }`. + - **No trigger, no service.** Where the automation service is absent, no trigger is installed, so the `trigger-api` slot stays empty (as no route is mounted). The member's caller then answers its typed absence. The boot `warn` line says so. + - **The route is found under `http.server` too.** The plugin reads `http.server` first, then the deprecated `http-server` alias. A host that registers its server only as `http.server` (the runtime's `config.server` path) now gets the self-hosted route. One that registers a single server under both names still gets it once. Where the two names hold two servers, the route goes on the `http.server` one. + - **The self-hosted route is otherwise unchanged.** Where it was mounted before, it answers the same status, headers and bytes for the same request, and the path is never mounted twice. +- efcbac7: fix(trigger-api,spec): every inbound hook post that does not verify gets one answer, `401 INVALID_SIGNATURE`, whatever its flow and hook id + + Clause-②: yes + + - **FROM → TO.** On `POST /api/v1/automation/hooks/:flowName/:hookId`, a post that named no armed flow, or the wrong hook id, answered `404 RESOURCE_NOT_FOUND`, while a post naming an armed flow and its hook id with an absent or bad `x-objectstack-signature` answered `401 INVALID_SIGNATURE`. Now all four answer `401 INVALID_SIGNATURE` with the same body, so the answer no longer tells an armed flow name from an unarmed one, whichever hook id a flow is armed with. + - **Who sees a difference.** A correctly configured sender (the flow's URL, its hook id, its secret) sees none: it still gets `202`. A sender posting to a flow that is not armed, or with a hook id that was rotated away, now gets `401` where it got `404`. A sender with a wrong secret or signing code still gets `401`, as before. + - **The cause is in the server log.** The trigger logs one `warn` line per refused post, with the cause and the flow name the post carried as structured fields: `cause` is one of `unknown-flow`, `wrong-hook-id`, `signature-absent`, `signature-malformed`, `signature-mismatch` or `signature-outside-window`. The line never carries the secret, the signature value or the body. + - **Unchanged: the `503` residual.** A post that names an armed flow and its hook id while that hook's secret cannot be read still answers `503 SERVICE_UNAVAILABLE`, signed or not, without verifying. It stays a `503` on purpose: it is the deployment's fault, a sender retries it, and it lasts only while the secret cannot be read. A wrong hook id never reaches the secret read, so it gets the uniform `401` even then. + - **`@objectstack/spec`.** The `ITriggerApiService.handleInboundHook` docblock states the one refusal and names the `503` as its residual. The type is unchanged. + + **To adopt it.** A sender or monitor that read `404` from this route as "wrong URL" can no longer tell that from a bad signature by the answer: read the trigger's `warn` line for the post instead, whose `cause` names which one it was. + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index 91aa943a0b4..c04d9af598e 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Inbound HTTP/webhook flow trigger for ObjectStack — per-flow HMAC-verified endpoints with queue-backed ingestion (ADR-0041)", "main": "dist/index.js", diff --git a/packages/triggers/trigger-record-change/CHANGELOG.md b/packages/triggers/trigger-record-change/CHANGELOG.md index eb522feb8b3..1d75740e0b8 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,380 @@ # @objectstack/plugin-trigger-record-change +## 18.0.0 + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index dea4981a4d0..ef9eca7ddd1 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.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Record-change flow trigger for ObjectStack — auto-launches flows on object insert/update/delete via ObjectQL lifecycle hooks (ADR-0018)", "main": "dist/index.js", diff --git a/packages/triggers/trigger-schedule/CHANGELOG.md b/packages/triggers/trigger-schedule/CHANGELOG.md index 2a8838c5853..a9f2b46b779 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,399 @@ # @objectstack/plugin-trigger-schedule +## 18.0.0 + +### Patch Changes + +- Updated dependencies [5a871e4] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [c28f317] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [a45d5d8] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [874a38d] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [7b0a9c9] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [5677add] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [c5b43b6] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [daafae6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [c2bb302] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [30ce56e] +- Updated dependencies [df2c551] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [4ffdebe] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [077b75f] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [2ac0616] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] + - @objectstack/core@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/metadata-core@18.0.0 + - @objectstack/types@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index 4dfdcb391b2..65b53f3f982 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.5", + "version": "18.0.0", "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 7ae975d9c82..bd39a323682 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,419 @@ # @objectstack/types +## 18.0.0 + +### Minor Changes + +- 5cfd866: feat(sharing): a share-link password can be sent in the `X-Share-Password` header whatever its characters, under a declared encoding (`X-Share-Password-Encoding: utf-8`) + + Clause-②: yes (widening) + + - **What was missing.** A browser cannot put a character above U+00FF in a request header (`Headers` throws a `TypeError` before the request leaves), and it strips leading and trailing spaces. `createLink` accepts any password, so a link whose password has a CJK character or an emoji could not be opened through the header. + - **What is now accepted.** A new companion request header, `X-Share-Password-Encoding`, declares how `X-Share-Password` is encoded. Its one value is `utf-8`, compared case-insensitively. Under it, `X-Share-Password` carries the password's UTF-8 bytes percent-encoded, as `encodeURIComponent(password)` produces them, and both public routes (`GET /api/v1/share-links/:token/resolve` and `/:token/messages`) decode it on both mounts: the sharing plugin's routes and the runtime dispatcher's `/share-links` domain. Both read the pair through one helper, `readSharePasswordHeader`, exported from `@objectstack/types` with the header-name constants. + - **Unchanged.** Without `X-Share-Password-Encoding`, `X-Share-Password` is read raw, exactly as before, so every value a client sends today resolves as it did. That includes a Latin-1 password and a raw password containing `%`; the server never percent-decodes a value nobody declared encoded. The `?password=` query parameter is still read first, and when it is present the header pair is not read. + - **What is refused.** `X-Share-Password-Encoding` naming any other value, or a password header that is not percent-encoded UTF-8 under `utf-8` (a `%` without two hex digits, octets that are not well-formed UTF-8, a character outside visible ASCII), answers `400 VALIDATION_FAILED` before the token is looked up. It is never compared raw instead. The message names the headers and the rule, never the presented value. + - **Response headers.** Both public routes now answer `Vary: X-Share-Password, X-Share-Password-Encoding`, still beside `Cache-Control: no-store`. + - **Cross-origin clients.** `X-Share-Password-Encoding` is in the default CORS preflight allow-list (`DEFAULT_CORS_ALLOW_HEADERS` in `@objectstack/plugin-hono-server`, which the `@objectstack/hono` adapter also applies). A deployment that passes its own `allowHeaders` must add `X-Share-Password-Encoding` beside `X-Share-Password` to let a cross-origin client send an encoded password. +- 8f2e808: feat(meta): the `/meta` item read serves the ADR-0008 version token, `If-None-Match: *` pins a save that expects no row, and the 409 `METADATA_CONFLICT` body carries the current version as data + + Clause-②: yes (narrowing) + + **BREAKING** for two request shapes on `PUT /api/v1/meta/:type/:name` that were answered `200` before this release and are now refused `400 VALIDATION_ERROR`, with nothing written: an `If-None-Match` header whose value is anything but `*` (an entity-tag, a list, a weak `W/"*"`, an empty value), and an `If-None-Match` header sent beside `If-Match`. Before, the door did not read `If-None-Match` at all and wrote the body, unguarded or under the `If-Match` alone. The remedy: send `If-None-Match: *` alone to save only where no row exists, or `If-Match` with the version you read alone to save only over that version. No first-party client sends `If-None-Match` on a `PUT`: the SDK sends it only on its cached `GET`, and objectui's ETag hook has no caller. + + ADR-0008's optimistic lock on `PUT /api/v1/meta/:type/:name` takes the version a client read as `If-Match`, but a client can only send a token it was served, and only a save receipt served one. So the first save after a load could not be pinned: two editors who each loaded an item and saved once overwrote each other. Three additions close that. + + - **The read serves the token.** `GET /meta/:type/:name` now carries `version` (declared on `GetMetaItemResponseSchema`): the keyed token of the stored row a save to this item would compare against, at the read's scope (the caller's organization partition and `?package=`) and lifecycle (`?state=draft` for the draft row, the plain read for the active row). It is the same producer and the same row as the save receipt's `version`, so a read after a save serves the receipt's token byte for byte. Send it back as `If-Match`. `null` means no stored row is there (an item served from code or a package artifact, or from a row in a scope the save does not write), so the next save is a create. The cached published-value branch (the default plain read) and `?preview=draft` publish no `version`, as they publish no `lock`; their `ETag` stays the cache validator and is not the token. + - **"Expect no row" can be said.** `If-None-Match: *` on `PUT /meta/:type/:name` (with `?mode=draft` for a draft) saves only where no row of that lifecycle exists, and is refused `409 METADATA_CONFLICT` once one does. This is the `parentVersion: null` pin `SaveMetaItemRequestSchema` already declared, now reachable over HTTP. The header takes `*` alone: any other value, or `If-None-Match` beside `If-Match`, is refused `400 VALIDATION_ERROR` and nothing is written. No first-party client sends `If-None-Match` on a `PUT`. A save with neither header is last-writer-wins, as before. + - **The conflict body names the current version as data.** The 409 of the `/meta` item write doors (save, publish, rollback, reset) now carries `currentVersion` beside the unchanged `error` sentence: the token the sentence names, or `null` when no row of the target lifecycle exists (declared as `MetadataConflictErrorSchema`). On the save door it equals the token the next read at the same address serves, so a client offering "reload" or "overwrite" re-pins without parsing prose. The SDK already exposes it as `err.details.currentVersion`. + - **The receipts' `version` describes are corrected.** `SaveMetaItemResponseSchema.version`, `PublishMetaItemResponseSchema.version` and the package publish door's `published[].version` said "Content hash … currently emitted as `sha256:`" and "409 `metadata_conflict`". The token has been the keyed `hmac-sha256:` digest of the stored content hash since the doors stopped serving the raw hash, and the conflict code on the wire is `METADATA_CONFLICT`. Only the description text moves. + + +- a45d5d8: fix(auth): a server-side session read no longer renews a browser session behind its cookie (#22258) + + **What was wrong.** better-auth's `getSession` renews a session older than `session.updateAge`: it moves `sys_session.expires_at` to `now + expiresIn` and stages the renewed session cookie on that call's own response. Ten doors read the session in-process (`auth.api.getSession`) and answer with their own response, so the renewal landed in the database and its cookie was discarded. The browser kept its old cookie, later `GET /api/v1/auth/get-session` calls found a fresh row and re-issued nothing, and the cookie expired first: a dead cookie beside a live bearer, and every cookie-only path then saw a signed-out user. Measured on a fresh dev stack (better-auth 1.7.3, `expiresIn` 604800 s, `updateAge` 86400 s, a session aged to `now + expiresIn − updateAge − 60 s`): `GET /api/v1/data/:object`, `GET /api/v1/auth/me/permissions`, `GET /api/v1/meta/object`, `GET /api/v1/i18n/locales`, `GET /api/v1/packages`, `GET /api/v1/marketplace/install-local` and the MCP door each moved `expires_at` by +86460 s and set no session cookie. + + **The rule now, decided by what the request carries.** + + - **A session cookie** (a browser, including a console that sends its cookie beside its bearer): the in-process read passes `query.disableRefresh`. The session renews only through `GET /api/v1/auth/get-session`, which re-issues the cookie with `Max-Age = expiresIn`, so cookie and session expire together. Measured after the change: every door above leaves `expires_at` unchanged on a cookie request and sets no cookie. + - **No session cookie** (a bearer-only client: `@objectstack/client` outside a browser, the `os` CLI): unchanged. A data read past `updateAge` still renews the session (+86460 s, measured on the same doors), so an active bearer client keeps sliding forward without calling `get-session`. No cookie is ever set on a response to a request that sent none. + + **Upgrading.** Nothing to change. A browser session renews whenever the app calls `GET /api/v1/auth/get-session`; a tab that never calls it now signs out at the session's real expiry instead of keeping a live bearer beside a dead cookie. + + `@objectstack/types` gains `inProcessSessionReadInput(headers)` (the `getSession` input for an in-process read: the request's own headers, plus `query: { disableRefresh: true }` when they carry a better-auth session cookie), `carriesSessionCookie(headers)` and the `InProcessSessionReadInput` type. A host that calls `auth.api.getSession` itself should read through `inProcessSessionReadInput` for the same reason. + + The same rule is applied to the in-process `auth.api.getSession` readers in `@objectstack/plugin-auth`, `@objectstack/plugin-webhooks`, `@objectstack/plugin-sharing`, `@objectstack/service-storage`, `@objectstack/service-settings` and `@objectstack/service-datasource` by their own changeset. +- 6d5191e: An import row for the sandbox's own fault reads the create door's fault sentence, not the runner's diagnostic text (#22741) + + Clause-②: yes (widening) + + The QuickJS runner raises some `SandboxError`s itself: a hook or action body that runs past its CPU budget or past the wall-clock ceiling, calls a capability it was not granted, or meets a failed install or marshal. These carry no `innerMessage`. `POST /api/v1/data/:object` and `/createMany` answer them `500 INTERNAL_ERROR` `Internal server error`. The sync `/import` row and the `/import/jobs` stored row answered the runner's text instead, for example `hook 'guard' exceeded CPU budget of 50ms (after 3 pump iterations)`. + + - **`@objectstack/types`** exports `isSandboxOwnFault(error)`. It is `true` for an error whose `name` is `'SandboxError'` and whose `innerMessage` is not a non-empty string, which is how the runner marks the faults it raises itself. A body's refusal or crash carries `innerMessage` and answers `false`. + - **`@objectstack/core`**'s import row applies the rule a crashed body already gets: when the sandbox raised the error and the create door answers it with a 5xx, the row's `error` is the door's sentence. The row's `code` is unchanged (`IMPORT_ROW_FAILED`). A driver error, and an error a code-defined hook threw, keep the row text they had. +- 3c1a046: fix: a route's catch-all `500` answers its own generic text, never the caught error's message, and `@objectstack/types` exports the writer that does it, `sendServerFault` + + Clause-②: yes (widening) — `@objectstack/types` gains one export, `sendServerFault`; nothing is removed, renamed or narrowed, and no response field changes shape. + + A declared 5xx does not carry the text of the error it caught: that text can name server internals, such as a path, a host or a table. The REST layer already withheld it. The storage, settings and i18n route families and the share-link routes answer in the nested `{ success: false, error: { code, message } }` envelope and did not: their catch-all `500` sent the caught error's message to the caller. + + - **What a caller now reads.** The same `status` and `code` as before, with the route's own generic text as `error.message`: `Internal error` on the storage and i18n routes, the route's message on the settings routes (`Failed to list manifests`, `Failed to read namespace`, `Failed to write namespace`, `Action failed`), and the route's `Failed to …` message on the share-link routes. On the storage routes this includes the metadata store's outage sentence, which an operator now reads in the server log. + - **What the operator reads.** The original error, stack included, in one `[5xx]` server-fault line. The storage byte routes, which already withheld the text, logged two lines per fault and now log one. A line names the mounted route pattern when the route passes one, never the request URL. + - **Unchanged.** Every `4xx` answer. The settings `SETTINGS_CRYPTO_UNAVAILABLE` refusal, which keeps its prescription. The authorization-store outage `503`, which keeps its declared code and message. The storage `501 NOT_IMPLEMENTED` answers. On the share-link routes, any error that declares its own `status` keeps the envelope it declares. + - **`sendServerFault(res, status, code, message, thrown, request?)`** (new, `@objectstack/types`): writes `message` in the declared envelope, and hands `thrown` to the server-fault log with its stack, as the fault's one line. Use it for an arm that answers a caught fault. Keep `sendError` for a `4xx` and for a `5xx` whose text you wrote for the reader. `request` is the mounted route pattern, never the request URL. + +### Patch Changes + +- ec7c7e0: fix(types): a thrown exposure refusal about a second object names that object on the REST body (#22737) + + The shared REST error classification gains one arm for `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED`. Both REST error doors ask it: `mapDataError`, and `resolveErrorResponse` behind `sendThrownError`. + + When the thrown error names an object other than the one the route addressed, the body's `object` is that object, and a `405` carries the effective operation set as `allowed`. The generic data door throws this when a nested-relation condition's related object is not served by the API. The new body is the same one the REST door already sends for these codes about the addressed object. Before, the body named the route's object beside a code saying that object was switched off. + + An error naming the route's own object, or a door given no route object, answers exactly as before. No code is added. +- 8bc7836: fix(types): the data door answers the sandbox's own fault as a fault, not as "this object does not exist" (#22868) + + Clause-②: no + + **What was wrong.** The QuickJS runner raises some `SandboxError`s itself. These carry no `innerMessage`. Two of them name the object the hook body addressed: a denied capability on `ctx.api.object(...)` (`capability 'api.read' not granted to hook 'guard' (called ctx.api.object('account').find)`) and a `ctx.api` method the host does not implement (`ctx.api.object('account').find not implemented`). When that object was the one the request named, `mapDataError` read the quoted object name plus the word `not` as an unknown object. `POST /api/v1/data/account` then answered `404 OBJECT_NOT_FOUND` with `Object 'account' is not registered`. Every other runner fault already answered `500 INTERNAL_ERROR`, which is what the runner's contract says these errors are. + + **What changes.** `mapDataError` checks `isSandboxOwnFault` first, next to the existing check for a sandboxed body that crashed. An error the sandbox raised itself now answers `500 INTERNAL_ERROR` with `Internal server error`, whatever its text names. The runner's text goes to the server log only. The sync `/import` row and the `/import/jobs` stored row read that same sentence. + + **Unchanged.** A driver's unknown-object text (`no such table: account`, `relation "account" does not exist`) still answers `404 OBJECT_NOT_FOUND`. So does the same wording thrown by anything other than the sandbox: the check reads the error's producer, not its words. A hook body's refusal keeps its sentence and status, and a crash keeps its `500`. +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [8eac321] +- Updated dependencies [a5a781b] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [1e8b628] +- Updated dependencies [14ccb1d] +- Updated dependencies [a789148] +- Updated dependencies [317cddd] +- Updated dependencies [fec87e7] +- Updated dependencies [dac99c2] +- Updated dependencies [0d3ea29] +- Updated dependencies [0253192] +- Updated dependencies [bfc15d2] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [c7d6037] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [ac9f8bd] +- Updated dependencies [04e776b] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [93125ae] +- Updated dependencies [56c8844] +- Updated dependencies [d4680d2] +- Updated dependencies [cdeabec] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [2b61f2d] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [9ad3d24] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [4578c56] +- Updated dependencies [c512c25] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [d8d0711] +- Updated dependencies [b15f8a1] +- Updated dependencies [40a6ee5] +- Updated dependencies [5910b5e] +- Updated dependencies [081e6a0] +- Updated dependencies [1884948] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [440bed6] +- Updated dependencies [b2549a8] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [557ae7c] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [ed1de8c] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [a800912] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [5081090] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [e5899a6] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [322ff1e] +- Updated dependencies [5fc57b3] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [9814035] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [efcbac7] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [9432bac] +- Updated dependencies [3f69471] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [777eed2] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [147a0d1] +- Updated dependencies [1959aac] +- Updated dependencies [1badd31] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [8464380] +- Updated dependencies [252a582] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [4cc3ff9] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [87d0619] +- Updated dependencies [4e0838d] +- Updated dependencies [60f9baf] +- Updated dependencies [584e737] +- Updated dependencies [c857a93] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [775ed65] +- Updated dependencies [e4ec6ec] +- Updated dependencies [b1e9280] +- Updated dependencies [05ab9ef] +- Updated dependencies [1d79556] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- 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 [a35f7a6] +- Updated dependencies [5fd2c76] +- Updated dependencies [be48c5e] +- Updated dependencies [57994c3] +- Updated dependencies [23e689d] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [ee0e115] +- Updated dependencies [4feb2d0] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [462dff3] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [23d0bc7] +- Updated dependencies [069fd40] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9cb9d95] +- Updated dependencies [f846cc2] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [6422da5] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [bbdfc05] +- Updated dependencies [d050a8f] +- Updated dependencies [d731b89] +- Updated dependencies [905f57b] +- Updated dependencies [66d2757] +- Updated dependencies [a65a59b] +- Updated dependencies [b0a1df0] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [9f024d8] +- Updated dependencies [1489952] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [70cfb12] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [af284b0] +- Updated dependencies [14a8bd2] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [1173017] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [3a734e0] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [ff48407] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] + - @objectstack/spec@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/types/package.json b/packages/types/package.json index 325e27d8927..f61447d18e7 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "18.0.0-next.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Shared interfaces describing the ObjectStack Runtime environment", "main": "dist/index.js", diff --git a/packages/verify/CHANGELOG.md b/packages/verify/CHANGELOG.md index 4ad2aa4e1f5..c66474bfd1c 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,1303 @@ # @objectstack/verify +## 18.0.0 + +### Minor Changes + +- 34dba5a: `bootStack` boots the production `single` shape: the Default Organization exists from the boot, every sign-up is its member, and the harness admin is its owner + + Clause-②: yes (narrowing) + + + + **BREAKING** for fixtures that relied on an organization-less `single` boot, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3, D11). + + - **What `bootStack` boots now.** Under `single`, `@objectstack/plugin-auth` creates the Default Organization before the seeds load, so every boot has one, and `bootStack` no longer turns off the platform admin's owner bind. The harness admin is the Default Organization's `owner`, every user a fixture signs up is its `member`, and their sessions carry it as the active organization. This is the shape `objectstack dev` and `objectstack serve` boot. Before, `bootStack` pinned `autoDefaultOrganization: false` and booted a `single` stack with no organization at all, a shape no deployment runs after this release. + - **`orgContext` asserts, it no longer switches.** `orgContext: true` keeps its refusal: the boot fails when the harness admin holds no membership, and it still refuses to compose with `multiTenant`. The bind itself happens on every boot. + - **Unchanged.** `multiTenant` boots a walled posture as before, and the open default-organization bootstrap still abstains under it. + + **The remedy.** A fixture that needs a principal outside the organization's `org_member` domain removes that user's membership (an administrator's act a real deployment performs) instead of booting without an organization. A fixture that minted its own `slug: 'default'` organization reads the one the boot created. A fixture that ran on `databaseDriver: 'memory'` and signs a user in meets the memory driver's tenant-scope refusal (`503`) until ADR-0131 C8; run that leg on the SQL in-memory driver. +- aa71c4d: `sys_user_permission_set` gains `permission_set`, the name of the permission set a grant holds, written beside `permission_set_id` (ADR-0131 D4) + + Clause-②: yes (widening) + + - **The column.** `permission_set` is a read-only text column, at most 100 characters, holding the `name` of the `sys_permission_set` row that `permission_set_id` points at. It is readable everywhere the grant row is readable. A grant written before this release has `NULL` here until the backfill stage rewrites it. No reader uses the column yet: the grant is still resolved from `permission_set_id`, which stays until it is dropped in a later major (ADR-0131 D10). + - **The platform writes it, on every write that carries `permission_set_id`, for every caller.** Two `@objectstack/plugin-security` engine hooks (`beforeInsert` and `beforeUpdate` on `sys_user_permission_set`) look up the set by id and store its name. A write that sends only the id, which is how the data door and the Setup forms write, gets the name filled in. + - **A name that names a different set is refused** with `400 VALIDATION_FAILED`, `invalid_value` at `permission_set`. This covers a name that disagrees with the id written beside it, or with the id already stored when only the name is written. For a non-system caller it also covers a name beside an id that names no set this caller's organization can see. A name that agrees is accepted. A cleared name (`null`) is not stored as a clear: the derived name is written back. Before this change the column did not exist, so a write naming it was refused with `400 INVALID_FIELD`. No write that was accepted before is refused now. + - **Every platform grant writer writes both columns:** the organization-admin reconcile and the platform-admin promotion in `@objectstack/plugin-security`, the self-registration grant in `@objectstack/plugin-auth`, and the RLS probe persona in `@objectstack/verify`. + - **Nothing to migrate.** No principal's grants change. To fill the column on grants written by your own code, write the set's name as `permission_set`, or leave it out and the platform fills it in. Do not write any other value there. +- 54f062b: feat(verify): the dashboard date-window family — every widget a dashboard's `dateRange` binds counts, under each preset of the picker, the rows that window selects, runnable as `os verify --dashboard-windows` (#15953) + + Clause-②: yes + + A seventh derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs`, `runDeleteBehaviorProofs`, `runUniqueScopeProofs` and `runParentWriteReachProofs`. It needs no hand-written test: from the app's own dashboards and datasets, read through the spec's own parse (`DashboardSchema`, `DatasetSchema`), it takes every dashboard that declares a `dateRange` and every widget that window binds, and asks the dataset door what each one counts. + + Where each half of the promise is read from: + + - the field a widget's window lands on is `DashboardWidgetSchema.filterBindings`' contract: `filterBindings.dateRange` names it, `false` opts the widget out (reported `unbound`), and absent means the dashboard's `dateRange.field` (`created_at` when unauthored). It resolves against the object behind the widget's dataset, as the stack registers it, and must be a `date` or `datetime` field; + - a preset's window is `DATE_RANGE_PRESET_MACRO_WINDOWS`, the `{date-macro}` pair each of the 13 `DATE_RANGE_PRESETS` names. It is handed to `POST /analytics/dataset/query` as written (`{ $gte: '{7_days_ago}', $lte: '{today}' }`, `{ $gte: '{month_start}', $lte: '{month_end}' }`), ANDed with the widget's own filter as `runtimeFilter`, and the server resolves the tokens; + - the days a window selects, for the ground truth, are that same window resolved by `@objectstack/core`'s `resolveFilterTokens` on the caller's reference timezone (UTC unless the `ExecutionContext` carries one), first and last day inclusive. + + Per bound widget and count measure, fixture rows are written through the seed door on every preset's first and last day and the day either side (never after today), each stamped at UTC noon (a `date` field takes the day itself), owned by the caller, with every column the widget's filter, the dataset's filter and the measure's filter compare held to a value they select, and read back before anything is judged. The widget is asked as the signed-in caller with no window and under every preset, before and after the fixtures, so each count is a difference and the app's own rows cancel out. With no window it must count every fixture row before any window is judged. Under each preset it must count exactly the fixture rows that window selects: a widget that shows nothing there is `blank`, one that counts another number is contradicted, and one whose every preset answers what no window answers is named inert. + + A widget is `proven`, `contradicted` (a preset went blank or counted other rows), `failed` (the door refused or faulted on a windowed query while the widget's own query answered), `inconclusive` (a fixture could not be written or did not read back as written, the widget with no window did not count every fixture row, its own query is refused, the day turned mid-probe, or the caller's timezone is not UTC), `unbound` or `excluded` (`excludedKind` names why: no count measure, a filter the ground truth cannot model, a window on a missing or non-temporal field, an undeclared or unparsed dataset or dashboard, an object the app does not declare or the CRUD derivation cannot synthesize). `contradicted + failed` widgets are the report's `hardFailures`. An average, a sum or a derived measure is counted as excluded; a held column is an equality, an `$in`, a `$nin` or `$ne` on a field that declares its options, or `{current_user_id}`. + + **`@objectstack/verify` — new exports.** + + - `runDashboardWindowProofs(stack, token, config, { now? })` → `DashboardWindowReport`. Run it on a fresh stack of its own, as a principal that reads the dashboards' objects: it writes fixture rows. + - `deriveDashboardWindowCases(config)`, `formatDashboardWindowReport(report)`. + - Types: `DashboardWindowExclusion`, `DashboardWindowMeasureCase`, `DashboardWindowCase`, `DashboardWindowDashboard`, `DashboardWindowPreset`, `DashboardWindowAnswer`, `DashboardWindowPresetProbe`, `DashboardWindowMeasureResult`, `DashboardWindowWidgetStatus`, `DashboardWindowWidgetResult`, `DashboardWindowReport`, `DashboardWindowProofOptions`. + + ```ts + import { bootStack, runDashboardWindowProofs, formatDashboardWindowReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp); + const report = await runDashboardWindowProofs(stack, await stack.signIn(), myApp); + console.log(formatDashboardWindowReport(report)); // report.presets names what each preset handed the door + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --dashboard-windows`.** Opt-in, like `--unique-scopes` and `--parent-write-reach`, on a fresh boot of its own, as the signed-in admin. A widget that went blank or counted other rows than its window selects, or a windowed query the door refused or faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `dashboardWindows` when the flag is given; without the flag the run and its document are unchanged. +- 06dbc4e: fix(verify)!: `runDashboardWindowProofs` takes no options, so the dashboard date-window run can no longer be pinned to an instant the server does not resolve on, and a run that crosses UTC midnight after it starts reports `not-run` instead of a false red + + `runDashboardWindowProofs(stack, token, config)` loses its fourth parameter, and `DashboardWindowProofOptions` (its only member was `now`) is retired. The server resolves a dashboard's date preset per request on its own clock, so any `now` other than the current instant made every window read `contradicted`: the option could only produce false reds, and nothing passed it. + + Clause-②: no (narrowing) + + + + **BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + + - FROM `DashboardWindowProofOptions` TO nothing (no replacement): drop the fourth argument, so `runDashboardWindowProofs(stack, token, config, { now })` becomes `runDashboardWindowProofs(stack, token, config)`. + + Fix: the presets resolve when the run starts, and each count measure places its fixture rows on the day it is probed. A run that crossed UTC midnight between the two judged that measure against the previous day's edges and could read it `contradicted`. That measure is now `not-run`, and its detail names both days; the widget reads `inconclusive`. The guard that catches a day turning while a measure is being probed is unchanged. +- 9f4b652: feat(verify): the delete-behaviour family — every `deleteBehavior` a reference field declares is what a delete of the referenced record does, runnable as `os verify --delete-behaviors` (#15953) + + Clause-②: yes + + A fourth derived proof family beside `runCrudVerification`, `runRlsProofs` and `runReferenceShapeProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`) and reads what the declaration states through the spec's own parse of the field (`FieldSchema`), which is the value a built artifact carries: a `lookup` or `tree` that authors nothing states `set_null`, an authored value is kept, and a bare `master_detail` states none. For each field it creates a fresh target record and a holder referencing it, deletes the target by id, and reads back what the engine left, through two doors: the REST data route as the signed-in caller, and the engine's own delete as the system principal. + + - `cascade` holds when the holder is gone with the target. + - `set_null` holds when the holder survives and no longer references the deleted record. + - `restrict` holds when the delete is refused with `DELETE_RESTRICTED` naming the holder's object, and both records are still there. + - In every case a second holder, referencing another record of the same target object, must be untouched. + + A refusal for any other reason (a hook's veto, a validation rule, a permission) is `unattributed`, so an engine that refused every delete would not read as a proven `restrict`. A field is `proven`, `contradicted` (a delete did something else), `failed` (a door faulted), `inconclusive` (an unattributed refusal, or a fixture the app refused; the report says why), `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. + + Two shapes are `excluded` rather than given an expectation, because an engine rule decides what a delete does to them, not the declaration: a `set_null` on a required reference (it cannot be cleared), and a field whose parse carries no `deleteBehavior` (a bare `master_detail`, a `user`). A field is `not-derivable` when no fixture can be built from the app's metadata: its target is not an object the app declares (such as `sys_user`), it is `readonly` or `system`, or the CRUD derivation cannot synthesize its holder or target. Only by-id deletes are asked; a predicate or multi-row delete is outside the family. + + **`@objectstack/verify` — new exports.** + + - `runDeleteBehaviorProofs(stack, token, config, { doors? })` → `DeleteBehaviorReport`. Run it on a fresh stack of its own: every `restrict` probe leaves its records behind. + - `deriveDeleteBehaviorCases(config)`, `formatDeleteBehaviorReport(report)`. + - Types: `DeclaredDeleteBehavior`, `DeleteBehaviorCase`, `DeleteBehaviorDoor`, `DeleteBehaviorAnswer`, `DeleteBehaviorProbeResult`, `DeleteBehaviorFieldStatus`, `DeleteBehaviorFieldResult`, `DeleteBehaviorReport`, `DeleteBehaviorProofOptions`. + + ```ts + import { bootStack, runDeleteBehaviorProofs, formatDeleteBehaviorReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp); + const report = await runDeleteBehaviorProofs(stack, await stack.signIn(), myApp); + console.log(formatDeleteBehaviorReport(report)); + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --delete-behaviors`.** Opt-in, like `--reference-shapes`, on a fresh boot of its own. A contradicted declaration, or a door that faulted, counts in `hardFailures` and fails the run. A `tree` field's delete is not honoured today (deleting the parent leaves the child's reference in place), so an app that declares one fails the run with the flag. The `--json` document carries the report as `deleteBehaviors` when the flag is given; without the flag the run and its document are unchanged. +- 965a1bf: feat(verify): the parent-derived write reach family — a write on a `controlled_by_parent` detail is admitted exactly when the caller holds that verb on the detail and can edit its master, runnable as `os verify --parent-write-reach` (#15953) + + Clause-②: yes + + A sixth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs`, `runDeleteBehaviorProofs` and `runUniqueScopeProofs`. It needs no hand-written test: from the app's own objects it takes every object the spec's own parse (`ObjectSchema`) declares `sharingModel: 'controlled_by_parent'` with exactly one `master_detail` field, walks the chain of masters above it to the first one that governs its own rows, and asks the engine what a detail write meets as a signed-in caller: the insert, update and delete by id the REST data route makes, with the caller's context, where a refusal keeps the object and the operation it names (the REST envelope names the route's object whatever was refused). The system door bypasses authorization and is not asked. + + The promise is ADR-0055 §3 and its chain amendment: editing, deleting or creating a detail requires edit access to its master, and the detail's own object permission is the first door. So each write is expected admitted exactly when both halves hold: + + - the caller holds the verb on the detail's object — read from the server's effective permissions for the caller (`GET /auth/me/permissions`), folded by `objectPermissionGrants`; + - the caller can edit the master — measured, never re-derived: an empty update of the master as the caller, which runs every write gate the master has, the gate above a master that is itself controlled by its parent included. + + The writes are made by three verifier personas whose permission sets the family authors from the app's own objects — the root master by the all-records grant (`modifyAllRecords`) with every verb on the detail; the root master readable but not editable with every verb on the detail; the all-records grant with no delete on the detail — and by one persona per position the app declares, whose capability is what the app binds to the position. Each writes under a master chain another principal owns (so the all-records grant, not ownership, is exercised) and one it owns, on a detail another principal owns and one it owns. A refusal holds only as the platform's `403 PERMISSION_DENIED` naming the detail and the operation; any other refusal (a rule, a hook's own code, a refusal on another object) is `unattributed`. An object is `proven`, `contradicted` (a write met the other answer), `failed` (a door faulted), `inconclusive`, `excluded` (no one master: none, two, an undeclared one, a cycle, or a chain at the write gate's depth bound) or `not-derivable`. `contradicted + failed` objects, plus personas that could not be provisioned, are the report's `hardFailures`. + + A detail two levels below its root master (a line under a quote under an account) is answered the same way: its write is admitted exactly when the caller holds the verb on the detail and may update the middle master directly, which also needs edit access to the root, and refused otherwise, so the family reports such a chain `proven`. + + **`@objectstack/verify` — new exports.** + + - `runParentWriteReachProofs(stack, config)` → `ParentWriteReachReport`. Run it on a fresh stack of its own, booted with `parentWriteReachSecurity(config)`: it signs up personas and writes, deletes and creates details. On a stack booted without it the verifier personas are reported not run, with the reason, and counted. + - `parentWriteReachSecurity(config)` → the `SecurityPlugin` that registers the verifier personas' permission sets beside the app's declared default profile. + - `deriveParentWriteReachCases(config)`, `formatParentWriteReachReport(report)`. + - Types: `ParentWriteReachHop`, `ParentWriteReachCase`, `ParentWriteReachVerb`, `ParentWriteReachOwner`, `ParentWriteReachAnswer`, `ParentWriteReachProbeResult`, `ParentWriteReachReach`, `ParentWriteReachObjectStatus`, `ParentWriteReachObjectResult`, `ParentWriteReachPersona`, `ParentWriteReachReport`. + + ```ts + import { bootStack, runParentWriteReachProofs, formatParentWriteReachReport, parentWriteReachSecurity } from '@objectstack/verify'; + + const stack = await bootStack(myApp, { security: parentWriteReachSecurity(myApp) }); + const report = await runParentWriteReachProofs(stack, myApp); + console.log(formatParentWriteReachReport(report)); // report.personas names who wrote + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --parent-write-reach`.** Opt-in, like `--delete-behaviors` and `--unique-scopes`, on a fresh boot of its own with the verifier personas' sets registered. A write whose answer disagrees, a door that faulted, or a persona that could not be provisioned counts in `hardFailures` and fails the run. The `--json` document carries the report as `parentWriteReach` when the flag is given; without the flag the run and its document are unchanged. + + **The unique-scope family — one change and one correction.** `runUniqueScopeProofs` is new in this same release, and two sentences of its own entry in these notes no longer hold: + + - **Attribution changed.** A unique refusal that names no field is now `unattributed` where a unique `indexes[]` entry covers the probed column. That entry refuses the same second holder, naming no field either, and admits the twin, so there the refusal may be the entry's rather than the field's own declaration. This is a change to the family's attribution rule: a field-less refusal is attributed to the field only when its twin with only the probed value changed is admitted AND no unique `indexes[]` entry covers the field. + - **How the second organization is provisioned, corrected.** The family provisions it by `signUp`, then `POST /auth/organization/create`, not by a sign-up that creates its own organization as that entry's parenthetical says. +- eab7236: feat(verify): the reference-value shape family — every client-writable reference field refuses a value that is not a record id (ADR-0104 D1), runnable as `os verify --reference-shapes` (#15953) + + Clause-②: yes + + A third derived proof family beside `runCrudVerification` and `runRlsProofs`. It needs no hand-written test: from the app's own objects it takes every field whose type is in `REFERENCE_VALUE_TYPES` (`lookup`, `master_detail`, `user`, `tree`), reads what the declaration promises from `valueSchemaFor(field, 'stored')` (a record-id string, or an array of them when the field is multi-valued), and asks the engine whether three wrong-shaped values are refused: a boolean, a number, and an embedded record object (the expanded read shape written back). A multi-valued field gets each one as an array element. Every probe is written on insert and on update, through two doors: the REST data routes as the signed-in caller, and the engine's own write as the system principal. + + A probe counts as refused only when the engine names the probed field in its refusal. A refusal that names something else is reported `unattributed`, so an engine that refused every write would not read as proven. A field is `proven` (every probe refused), `holes` (a probe was admitted), `failed` (a door faulted), `inconclusive` (a probe could not be attributed or written; the report says why) or `excluded` (declared `readonly` or `system`, which the caller does not write). `holes + failed` is the report's `hardFailures`. + + **`@objectstack/verify` — new exports.** + + - `runReferenceShapeProofs(stack, token, config, { doors? })` → `ReferenceShapeReport`. Run it on a fresh stack of its own: a door that admits a probe lands a row. + - `deriveReferenceShapeCases(config)`, `referenceShapeProbeValue(kind, multiple, id)`, `formatReferenceShapeReport(report)`, `REFERENCE_SHAPE_PROBE_ID`. + - Types: `ReferenceShapeCase`, `ReferenceShapeDoor`, `ReferenceShapeOperation`, `ReferenceShapeProbeKind`, `ReferenceShapeProbeResult`, `ReferenceShapeFieldStatus`, `ReferenceShapeFieldResult`, `ReferenceShapeReport`, `ReferenceShapeProofOptions`. + + ```ts + import { bootStack, runReferenceShapeProofs, formatReferenceShapeReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp); + const report = await runReferenceShapeProofs(stack, await stack.signIn(), myApp); + console.log(formatReferenceShapeReport(report)); + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --reference-shapes`.** Opt-in, like `--rls`, on a fresh boot of its own. It is opt-in because the engine still admits wrong-shaped elements on a `multiple: true` `lookup` or `user` field (#22908), so an app that declares one would fail the run until the engine refuses them. A field with holes, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `referenceShapes` when the flag is given; without the flag the run and its document are unchanged. +- f93bf12: feat(verify): the unique-scope family — every field declared `unique` collides as its declared scope says, runnable as `os verify --unique-scopes` (#15953) + + Clause-②: yes + + A fifth derived proof family beside `runCrudVerification`, `runRlsProofs`, `runReferenceShapeProofs` and `runDeleteBehaviorProofs`. It needs no hand-written test: from the app's own objects it takes every field whose `unique` the spec's own parse of the field (`FieldSchema`) declares — the value a built artifact carries, so an `autonumber` that authors no `unique` states `'organization'` — and asks the engine what a second holder of one value meets, through two doors: the REST data route as a signed-in caller, and the engine's own write as the system principal. + + - **Within one organization** — by insert and by update — the second holder must be refused, whatever the scope. On a `single`-posture boot that organization is the install's one: the untenanted install, where every write lands in it. + - **Across two organizations** — on an org-scoped boot only — the family provisions a second organization the way a user makes one (a sign-up that creates its own) and offers it the value. A `true` or `'organization'` field must admit it; a `'global'` field, or any field of an object with no organization column (`tenancy.enabled: false`), must refuse it. On a boot with one organization this leg is reported not run, with the reason, on the report's `boot`. + + A refusal proves the collision only when it is the platform's unique refusal (`UNIQUE_VIOLATION` at the REST door, `DUPLICATE_RECORD` at the system door) and it is this field's: it names the field, or it names none and the same record with only the probed value changed is admitted. A per-organization constraint is an expression index, which the driver reports by index name, so its refusal carries no `field`, and that twin is what attributes it. Any other refusal (an app hook's own dedupe, a validation rule, a permission, a constraint on another field) is `unattributed`, so an engine that refused every write would not read as a proven scope. A field is `proven`, `contradicted` (a second holder met something other than its scope), `failed` (a door faulted), `inconclusive`, `excluded` or `not-derivable`. `contradicted + failed` is the report's `hardFailures`. + + A field the caller cannot set (`readonly`, `system`, a runtime-issued `autonumber`) is probed through the system door only, which keeps an explicit value. A derived value (`formula`, `summary`) is `excluded`; a field with no fresh values to offer (a `boolean`, a `select`, a multi-valued reference, a target the app does not declare) is `not-derivable`. The population is the field-level declaration: a unique `indexes[]` entry is a different surface, with its own meaning of bare `true`, and is not in it. + + **`@objectstack/verify` — new exports.** + + - `runUniqueScopeProofs(stack, token, config, { doors? })` → `UniqueScopeReport`. Run it on a fresh stack of its own: it writes duplicates on purpose. Boot the stack with `{ multiTenant: true }` to run the cross-organization leg. + - `deriveUniqueScopeCases(config)`, `formatUniqueScopeReport(report)`. + - Types: `DeclaredUniqueScope`, `UniqueScopeCase`, `UniqueScopeDoor`, `UniqueScopeLeg`, `UniqueScopeOperation`, `UniqueScopeAnswer`, `UniqueScopeProbeResult`, `UniqueScopeFieldStatus`, `UniqueScopeFieldResult`, `UniqueScopeReport`, `UniqueScopeProofOptions`. + + ```ts + import { bootStack, runUniqueScopeProofs, formatUniqueScopeReport } from '@objectstack/verify'; + + const stack = await bootStack(myApp, { multiTenant: true }); + const report = await runUniqueScopeProofs(stack, await stack.signIn(), myApp); + console.log(formatUniqueScopeReport(report)); // report.boot names the legs this boot ran + expect(report.summary.hardFailures).toBe(0); + await stack.stop(); + ``` + + **`@objectstack/cli` — `os verify --unique-scopes`.** Opt-in, like `--reference-shapes` and `--delete-behaviors`, on a fresh boot of its own; with `--multi-tenant` it runs both legs. A second holder met wrongly, or a door that faulted, counts in `hardFailures` and fails the run. The `--json` document carries the report as `uniqueScopes` when the flag is given; without the flag the run and its document are unchanged. +- 7098aca: `bootStack` mounts the always-on capability slate `objectstack serve` mounts for every app, and builds each provider from the app's configuration the way `serve` builds it + + Clause-②: yes (narrowing: a configuration whose mail or SMS settings, or the OS_EMAIL_* / OS_SMS_* environment, name a transport that cannot deliver, now fails bootStack where it booted; widening: bootStack mounts the always-on slate objectstack serve mounts for every app and hands each provider the app's configuration, so the app's analytics cubes now reach the registry, where it mounted fewer and built them with defaults) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — the composition `serve` mounts, built the way `serve` builds it.** For one configuration, `bootStack(config, opts)` already mounted the providers the app's `requires` names and the plugins in its own `plugins` array. It now also: + + - **Mounts the always-on slate** `serve` mounts for every app, whether or not `requires` names it: `queue`, `job`, `cache`, `settings`, `email`, `storage`, `sms`, `sharing`, `messaging`, `analytics` and `package-registry`. Before, a slate provider was mounted only where a mounted plugin hard-depended on it, so an app's tests ran without the inbox delivery, the mail service, the file storage and the package registry its users' server has. + - **Builds each provider from the app's configuration**, by the same rule `serve` reads: the analytics service gets the app's `analyticsCubes` (the top level, then each package body's), the email service the app's `email` block with `OS_EMAIL_*` over it, the SMS service the `sms` block with `OS_SMS_*` over it, and the storage service the `OS_STORAGE_LOCAL_ROOT` root (`.objectstack/data/uploads` under the working directory by default). Before, each was built with its own defaults: no cubes, no mail configuration. So the app's cubes now reach the analytics registry; a cube the service refuses (one over an object the API does not serve) is warned and skipped by the service, under `bootStack` as under `serve`, and no boot fails on it. + - A caller's `extraPlugins`, `security` and `analytics` instances still take precedence by identity, and are not handed the configuration. A suite that needs a provider configured its own way (a temporary storage root, a mail transport) passes its instance in `extraPlugins`. + - Not mounted, because they are decisions about a server process rather than about the configuration: `serve`'s MCP endpoint (`OS_MCP_SERVER_ENABLED`) and its pinyin search (`OS_SEARCH_PINYIN_ENABLED`). A suite that exercises either passes the provider in `extraPlugins`. + + **What now fails that booted before (the narrowing).** A provider `bootStack` constructs and cannot build fails the boot, naming the capability token and the package — whether the app declared the token or the slate appended it. `serve` logs a slate provider it cannot build and boots on; a test boot does not, so an app's tests never pass on a composition its server does not run. In practice: + + - a configuration whose `email` or `sms` settings, or the `OS_EMAIL_*` / `OS_SMS_*` environment of the test process, name a transport that cannot deliver (`provider: 'smtp'` with no host, `resend` / `postmark` with no API key, an unknown provider tag) is refused with the reader's own remedy. Set `OS_EMAIL_PROVIDER=log` / `OS_SMS_PROVIDER=log` in the test environment if it is not meant to send mail or SMS. + + **`@objectstack/core`** exports the rule both boots read: `resolveServedCapabilities` (given the tokens a stack declares, which capability tokens a served boot mounts providers for — those, `email` for a declared `auth`, the host's own defaults, the always-on slate unless the preset is `minimal`, and `job` / `queue` ahead of what schedules background work) and `resolveCapabilityArgument` (what each provider is constructed with), with their `ServedCapabilities`, `CapabilityArgumentInput` and `CapabilityArgument` types; and `resolveStorageCapabilityArg`, `resolveStorageLocalRootEnv` and `StorageCapabilityArg`, moved here from the CLI. + + **`@objectstack/plugin-email`** exports `resolveEmailCapabilityArg`, `resolveDeploymentAppName` and `EmailCapabilityArg`, and **`@objectstack/service-sms`** exports `resolveSmsCapabilityArg` and `SmsCapabilityArg` — the readers that build each provider from the deployment's configuration, moved from the CLI to sit beside the transport vocabulary they refuse against. `resolveCapabilityArgument` reads each off the provider module it is handed. + + **`@objectstack/cli`**: `serve` reads both rules from `@objectstack/core` and composes exactly what it composed before. `os migrate plan` / `apply`'s declaration boot reads the same token rule for the providers it composes (the same set as before; a declared `auth` now places `email` right after the declared tokens, as `serve` does). `os verify` boots through `bootStack`, so it now mounts the always-on slate and builds the providers from the app's configuration. The moved functions stay importable from the `serve` command module. +- 97610a5: `bootStack` composes what `objectstack serve` composes from the same configuration: the providers the app's `requires` names, and the plugins in the app's own `plugins` array + + Clause-②: yes (narrowing) + + + + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + + **`@objectstack/verify` — one composition rule.** For one configuration, `bootStack(config, opts)` now mounts what `objectstack serve` mounts from it, so an app's tests boot the composition its users get: + + - **The providers the app's `requires` names**, by `serve`'s own reader and table (top-level `requires`, otherwise each package body's), plus the always-on providers a mounted plugin hard-depends on. An app no longer lists them in `extraPlugins` by hand. + - **The plugins in the app's own `plugins` array**, by `serve`'s rule for an entry: an instance is mounted as written, a plain bundle is wrapped into `AppPlugin`, a package name is loaded from the app's root (`hostRoot`). + - **The caller wins by identity.** An `extraPlugins` instance takes precedence over a `requires` provider it is (exact `name` or class name) and over an app plugin with the same `name`; that app plugin is not mounted. `security` and `analytics` instances take precedence over an app plugin of the same `name` the same way. + - **`hostRoot` is the app's root** in the two places `serve` uses the config's directory: the automation service's `packageRoot` (where a declarative connector's package-relative file ref is read) — now also when `automation: true` asks for the service — and the root a string `plugins` entry is resolved from. A suite that does not run from the app's directory passes it. + - **Offline CI.** An app whose `plugins` array wires the marketplace-facing `@objectstack/cloud-connection` plugins gets them mounted, pointed at `OS_CLOUD_URL` (by default the public catalog). Set `OS_CLOUD_URL=off` in the test environment, before the configuration module is imported, to keep the suite offline. + + **What now fails that booted before (the narrowing).** + + - **A second live boot of the same configuration object is refused** with `code: 'RESOURCE_CONFLICT'`, `status: 409`, and so is a copy (`{ ...config }`) that carries an app-plugin instance a live boot mounted: the instances in a `plugins` array are module-level, and two kernels must not share them. Live means until `stop()` resolves. Remedy: `stop()` the first stack before booting again; or share one boot with `bootStackOnce(config, opts)`; or, to keep two stacks of one app live at once, boot the second on a configuration built again (call its builder once more, or import a fresh module instance of it). A `{ ...config }` spread is not a configuration of its own: a live boot keeps references into the configuration's nested definitions. + - **An app `plugins` entry that cannot be loaded or registered fails the boot**, naming the entry (`plugins[i]`) and its remedy. `serve` logs such an entry and boots on; a test boot does not, so a plugin the app declares is never silently absent from its tests. + - **A provider or app plugin that refuses to start fails the boot** where the fixed plugin set never mounted it — for example a declarative connector whose package-relative file ref does not resolve from `hostRoot`. + + **`@objectstack/core`** exports the pieces both boots read: `CAPABILITY_PROVIDERS`, `CapabilitySpec`, `CapabilityIdentities` and `providesCapability` (the `requires` token → provider table and its exact identity match); `stackDeclaredCapabilities`, `resolveStackCollection`, `declaredPackageEntries`, `stackPackageBodies` and `collectFromPackageBodies` (the package-owned collection reader); and `materializeStackPlugin` with `StackPluginLoaders` (what a `plugins` entry becomes). + + **`@objectstack/cli`**: `os verify` boots the app anchored at the directory holding its config (`hostRoot`), as `serve` anchors it, so `os verify --app path/to/objectstack.config.ts` run from another directory reads the app's package-relative files (a declarative connector's spec, a string `plugins` entry) and resolves `--multi-tenant`'s organizations package from the app, not from the working directory. `serve` itself does not change: `Serve.CAPABILITY_PROVIDERS` and `Serve.providesCapability` are handles over the `@objectstack/core` declarations, the stack-collection readers are re-exported from there, and `serve`'s `plugins` loop reads the entry rule from there. +- fbb065f: feat(verify): the handle gains a door to the automation engine's condition evaluator + + Clause-②: yes (widening) + + An app's tests can evaluate a flow condition on the booted stack's own evaluator through the in-process handle, so a suite no longer calls the booted kernel's `automation` service by hand for a condition's truth table. + + - **`stack.automation.evaluateCondition(condition, variables)`** calls the booted `automation` service's `evaluateCondition`, the method a flow's start gate, its edges and its `decision` branches are decided by. It resolves with the engine's boolean, unchanged. It is for a truth table over variable shapes no write produces. + - **`condition`** takes the shapes the engine takes, and is handed over as given: an envelope (`{ dialect: 'cel', source }`) or a string, which the engine reads as CEL; a `{var}` hole is refused as the brace trap, and a `template` or `flow` envelope is refused too (the legacy template dialect is deleted in this release). The engine's own flow sites hand the evaluator an envelope (a start node's string `condition` and a `decision` branch's `expression` are wrapped as `{ dialect: 'cel', source }`, and an edge's condition already is one), so pass the envelope to model any of them. + - **`variables`** is a plain object. Each own key becomes one entry of the `Map` the engine takes: `{ record, previous }` binds `record` and `previous`. + - **Refusals are not the handle's.** A condition the engine cannot evaluate rejects with the engine's own error, never `false`. On a stack with no automation service it rejects with the kernel's own `SERVICE_NOT_REGISTERED` error, whose `serviceName` is `'automation'` (`isServiceNotRegisteredError` from `@objectstack/core` answers `true`). Boot with `automation: true`, or have the app declare `requires: ['automation']`. No error code is added. +- 27f0d83: feat(verify): `bootStack(config, { artifact })` boots the compiled artifact as the app, so a test runs each hook's and action's lowered `body` in the sandbox, as a deployment does + + Clause-②: yes (widening) + + `objectstack build` lowers each inline hook `handler` and action `target` it can into a metadata-only `body`, and a deployment runs that body in the runtime's QuickJS sandbox, against the sandbox's `ctx`. `bootStack` mounted the source configuration only, so a handle test ran the function in-process, and a handler that refuses once lowered passed every test. + + - **`BootOptions.artifact`** names the compiled `objectstack.json`: a path, resolved against `hostRoot` (so `'dist/objectstack.json'` names the app's own build), or an `http(s)://` URL. The artifact is read by the runtime's own artifact loader (`loadArtifactBundle`, with the options every artifact boot reads it with) and mounted as the app, in place of `config`. `AppPlugin` binds each hook's `body` ahead of its `handler`, through the same sandbox runner every artifact boot uses, so every door (`hooks.run`, `actions.run`, `api`, …) runs what the build shipped. + - **A refused body answers as the body-only path answers.** The handle rejects with the sandbox's `SandboxError`, which carries a `code` and `status` the body declared on its throw. A body that crashes (a `TypeError`) carries none, and REST answers it `500` `INTERNAL_ERROR`. For example, a handler that writes to `ctx.dispatch.scope` passes in-process and refuses every insert once lowered, because the sandbox hands a body `ctx.dispatch` without `scope`. + - **The rest of the stack is still composed from `config`**: its `requires`, its own `plugins`, its declared default profile, its datasources. `objectstack serve ` composes the same way. So `config` is the configuration the artifact was built from. + - **A local artifact gets its `objectstack-runtime.*.mjs` merged**, so a callable the build could not lower runs in-process from it, as under `objectstack start`. `objectstack build --strict-body` refuses such a callable at build time, and its artifact is body-only. + - **Refused before anything boots**, each with its `code` and `status`: an artifact that cannot be loaded (`RESOURCE_NOT_FOUND`, 404; the boot never falls back to the source), an artifact built from another app (`RESOURCE_CONFLICT`, 409), and a configuration carrying `onEnable` (`INVALID_REQUEST`, 400). No artifact carries `onEnable`; for a configuration that mounts no plugin instance of its own, `objectstack serve ` grafts it from the configuration module, a `@objectstack/cli` rule this package does not re-derive. No error code is added. + - The handle lowers nothing: build the artifact first. A stale artifact boots stale bodies. +- a8f24b0: feat(verify): the handle gains a door that observes the writes the engine receives, a hook's refused write included + + Clause-②: yes (widening) + + An app's tests can see what a hook handed the engine through the in-process handle, so a suite no longer wraps the booted engine's write methods in a spy to find a refused write, to wait for a fire-and-forget hook, or to stage a refusal. + + - **`stack.observeWrites(fn)`** runs `fn` and resolves with every insert, update and delete the booted engine received meanwhile, in the order each reached it. Each entry (`ObservedWrite`) carries `object`, `operation`, the payload (`data`), the update or delete selection (`where`), `multi`, the execution context the write carried (`context`), and the engine's answer (`outcome`): `pending`, then `resolved` with its result, or `refused` with the error the engine threw, unchanged, and that error's own `code`. + - **What it is.** One global middleware on the engine's own chain (`registerMiddleware`), added on the first call. It passes every operation on unchanged and decides nothing. So it sits after the write gates the boot registered. Every write those gates let through is recorded, from any door (`hooks.run`, `seed`, a flow, a route, a hook's `ctx.api`), and so is every refusal the engine raises past them: a declared validation rule (`VALIDATION_FAILED`), a hook's throw, a unique conflict (`DUPLICATE_RECORD`). A write a gate refuses, the permission check among them, never reaches it and leaves no entry. Its refusal still reaches whoever made the write. + - **An outcome is the engine's answer to that call, not a statement about stored rows.** A write an `after*` hook refuses was refused after the driver wrote, and its row can stay. Read rows back with `stack.rows`. + - **The window is time, not cause.** A write the stack makes for its own reasons while `fn` runs is recorded too, so filter by `object`. + - **An `async: true` hook.** Its write reaches the engine after the write that fired it has returned. `fn` is handed the live observation (`WriteObservation`), and `observation.settled(match)` resolves with the first write `match` accepts once the engine has answered it. It rejects after `timeoutMs` (default 2000), naming every write observed, or when the observation closes first. A hook that writes nothing leaves nothing to observe. + - **Staging a refusal.** Arrange one the engine makes itself: seed the row that already holds a unique value the hook will write, or set up the state a declared validation rule refuses. The handle has no switch that refuses a write in the engine's place. + - A `fn` that is not a function, a `match` that is not a function, and a `timeoutMs` that is not a positive number are refused with `code: 'INVALID_REQUEST'`, `status: 400`. A rejection of `fn` is rethrown unchanged once the observation has closed. No error code is added. + - New exported types: `ObservedWrite`, `ObservedWriteOutcome`, `WriteObservation`. +- b4ce5e2: feat(verify): `hooks.run` takes the system principal on an insert and a delete, so a user-less record trigger can be driven + + Clause-②: yes (widening) + + An app's tests can drive a record-change flow fired by a write with no user (an integration's or a system job's insert or delete) through the in-process handle, so a suite no longer hands a flow a record through the booted kernel's `automation` service by hand. + + - **`hooks.run(object, 'insert' | 'delete', input, { system: true })`** is the engine's own `insert` / `delete` under `{ isSystem: true }`, the same call the person's write makes with only the context changed. `{ system: true }` on `update` is unchanged. No permission gate applies. The bound hooks still run (they see `session.isSystem` and no `userId`), declared validations still refuse, and the record-change trigger fires the object's flows with no trigger user. + - **A record the engine no longer holds.** A system (or a person's) delete hands a `record-after-delete` flow the row's pre-image as `record` and as `previous`, and by then the engine no longer holds the row: the flow's `get_record` of that id finds nothing. A `record-before-delete` flow on the same delete still finds it. + - **Not `seed`.** `seed(object, rows)` also runs as the system, and it sets `skipTriggers`, so it fires no record-change flow: use it for end-state fixture data, and `{ system: true }` for a write the app should react to. + - A call naming both callers (`{ as: token, system: true }`) is still refused with `code: 'INVALID_REQUEST'`, `status: 400`, before anything is written. Every other refusal is the engine's own error, unchanged: assert on its `code` and `status`. +- 5910b5e: feat(verify): the handle gains a system-context update door and a predicate update door + + Clause-②: yes (widening) + + An app's tests reach two more engine write paths through the in-process handle, so a suite no longer calls the booted kernel's `objectql` service by hand for them. + + - **The system principal on an update.** `hooks.run(object, 'update', { id, ...fields }, { system: true })` writes one row by id under `{ isSystem: true }`, the context a system job or an integration writes under. No permission gate applies. The bound hooks still run (they see `session.isSystem` and no `userId`), declared validations still refuse, and the record-change trigger fires its flows with no trigger user. The new type is `AsSystem` (`{ system: true }`), exported beside `AsUser`. + - `{ system: true }` beside an `as` token is refused with `code: 'INVALID_REQUEST'`, `status: 400` before anything is written. Fixture rows are still `seed(object, rows)`, which also skips record triggers. (`insert` and `delete` take `{ system: true }` too: see the system insert and delete entry.) + - **The predicate update.** `hooks.updateWhere(object, where, data, opts)` is the engine's own predicate path, `update(object, data, { where, multi: true, context })`. It writes one payload to every row `where` selects and resolves with the affected-row count. The engine dispatches `beforeUpdate` and `afterUpdate` once per matched row, each bound to that row's own pre-image as `previous`, so record-change flows evaluate and fire per row. No REST door reaches this path: `POST /data/:object/updateMany` writes by id. The caller is `{ as: token }` or `{ system: true }`. + - It refuses with `code: 'INVALID_REQUEST'`, `status: 400`, before the engine is touched, a `where` that is not an object, and a call the engine's own update dispatch would write by id: a `where` that names only an `id`, or an `id` in `data` beside a `where` that selects by nothing else. Write one row by id through `hooks.run(object, 'update', { id, ...fields }, opts)`. An `id` inside a larger predicate (`{ id, status: 'open' }`) stays a predicate update. An `id` in `data` beside a real predicate is refused by the engine itself, as it is on every caller. + + Every other refusal from either door is the engine's own error, unchanged: assert on its `code` and `status`. +- a6f3a13: fix(verify)!: `hooks.run(object, 'delete', …)` resolves with what the engine's `delete` answers, not with a row + + Clause-②: no (narrowing) + + + + **BREAKING** for TypeScript code that reads the answer of a `hooks.run` delete. It ships as `minor` under the launch-window convention for breaking changes. No runtime behaviour changes. + + `hooks.run(object, 'delete', { id }, opts)`, for a person (`{ as }`) and for the system (`{ system: true }`), was declared to resolve with a row (`Promise`). The engine's `delete` answers `true` for the row it removed. So a test that read a field of the answer type-checked, and then read `undefined`. + + **What `hooks.run` declares now.** Both overloads are generic over the operation. + + - An `insert` or an `update` resolves with the row, as before. + - A `delete` resolves with the engine's own declared delete answer, `boolean | number`. It is read off `ObjectQL['delete']` rather than re-declared, and a `hooks.run` delete, which is always by id, answers `true`. + - A call whose operation is the union `'insert' | 'update' | 'delete'` still type-checks, and resolves with the union of the answers. + + **If your code read a field of a delete's answer.** That read was always `undefined` at run time, and it is now a compile error. Assert the answer itself, `expect(await stack.hooks.run(object, 'delete', { id }, opts)).toBe(true)`. If the test needs the deleted row, read it with `stack.rows(object, { id })` before the delete. +- 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 + +- f7eb860: fix(verify): the CRUD stage reads a refused create's whole answer before it calls the create a hard failure + + Clause-②: no + + - **A long validation message is `needs-fixture` again.** The stage used to cut a refused create's body to 280 characters and then look for the validation code in that cut. The `/data` door answers a validation refusal as `{ error, code, fields, object }`, with the app-authored message first, so a message long enough pushed `"code":"VALIDATION_FAILED"` past the cut. The object was then reported `create-failed`, a hard failure that makes `os verify` exit 1, although the app's own rule had refused the auto-derived record. + - **The verdict reads the whole body.** When the body is JSON, the stage reads the envelope's `code`: `VALIDATION_FAILED` on a 400 is `needs-fixture`. A body that is not JSON, or has no string `code`, is matched against the full text with the stage's original pattern. + - **Unchanged:** the stored `detail` still holds at most 280 characters, and the rule is the same: a 400 validation answer is `needs-fixture`, and anything else is `create-failed`. + + A patch: the stage reads the same answer correctly. No export, accepted value or published shape changes. +- 0253192: Grants name their permission set, with no `sys_permission_set` row read (ADR-0131 D4) + + - `@objectstack/plugin-auth`: the self-registration grant names the declared set alone. Admission asks the security catalog and the activation ledger whether the set grants, and the settlement writes `{ user_id, permission_set }` with no row read, so a deployment with no `sys_permission_set` row for the set still grants it. The last-administrator guard reads which set a grant holds from its name only; writing `permission_set_id` moves no standing. + - `@objectstack/verify`: the RLS probe persona's grant names the registered probe set alone; it no longer needs, or refuses for want of, a boot-seeded `sys_permission_set` row. + - `@objectstack/platform-objects`: Setup's user page grants a permission set by name: the Add picker stores the set's machine name (`valueField: 'name'`, `linkField: 'permission_set'`), and the list shows the name column. The picker still lists `sys_permission_set` rows; listing the security catalog itself is a console change. +- e078506: fix(verify): the RLS probe persona is provisioned through the registry — `provisionRlsProbePersona` no longer mints a `sys_permission_set` row + + Clause-②: no + + `provisionRlsProbePersona` used to find-or-create a `sys_permission_set` row named `verify_rls_probe` and grant it. Under ADR-0131 D3/D4 a permission set's one home is the registry: the authorization resolver reads the grant's `permission_set` name and takes the definition from the security catalog, never from a row. So a row this function minted was a copy of a definition nothing read. + + It now: + + - asks the security catalog (`securityCatalogReaderOf`, the read the resolver itself uses) whether `verify_rls_probe` is registered. `rlsProbeSecurity(config)` registers it at boot; + - reads the id of the row the boot already seeded for that registered set, because `sys_user_permission_set.permission_set_id` is still a required column; + - writes the one grant, carrying both columns. + + It throws, writing nothing, when the catalog does not serve the probe set or the boot seeded no row for it. `objectstack verify --rls` reports either as a degraded probe persona and fails the run, as it already does for any provisioning failure. Fix for a caller that hits it: boot the stack with `rlsProbeSecurity(config)` as the `security` option of `bootStack`. +- 1eae21f: fix(verify): the dashboard date-window family also proves sums and derived measures, and says why an average stays out + + Clause-②: no (no export, option or type moves: the family reads the measures it already read, and a widget it used to exclude can now be proven or contradicted) + + `runDashboardWindowProofs` (and `os verify --dashboard-windows`) now judges every verdict in fixture rows, so it compares every measure whose difference can be read back as rows: + + - **A `sum`** is probed. Each fixture row holds the summed field to the value the CRUD derivation synthesizes for it, or to the value the widget's own selection already holds there. The sum's difference over that value is the rows the window took in. FROM: a sum-only widget was `excluded` (`no-count-measure`), and a sum beside a count was an `excluded` measure (`not-a-count`). TO: `proven` or `contradicted` like a count. A sum of a relationship path, a summed field with no synthesizable value, and a held value that is not a non-zero number are `excluded` as `count-field-unsynthesizable`, and the reason names which. + - **A `derived` measure** (`ratio`, `sum`, `difference`, `product`) is probed through its operands, which the widget's own answer carries beside it. Each operand must be a count or a sum of the same dataset, and the fixture rows meet every operand's filter. Under each preset every operand must take in exactly the rows the window selects. The value the widget shows must equal `evaluateDerivedMeasures` (the server's own evaluator, from `@objectstack/service-analytics`) over that row's operands, or the preset is `contradicted` and the detail names both values. A derived measure over a derived one, or over a measure that is not a count or a sum, stays `excluded` (`not-a-count`), and the reason names the operand. + - **An `avg`, `min`, `max` or `count_distinct`** stays `excluded` (`not-a-count`, or `no-count-measure` for a widget with nothing else), with a reason that says why. What a fixture row does to it depends on rows the app already holds in the window: their number for an average, their extreme for a minimum or maximum, their values for a distinct count. Nothing the widget answers reveals that, so deriving it would mean guessing the data. The reason text changes: FROM `"avg_effort" aggregates avg of effort, not a row count` TO `"avg_effort" averages effort: an average is not additive — …`. + - The widget-level `no-count-measure` reason reads `no measure the widget selects can be compared (…)`. The `excludedKind` values are unchanged. + - **A new `not-run` cause.** The family gives up on a preset when a sum's own total is so large that its floating-point difference cannot resolve one fixture row. That needs 2^34 times the held value, and the detail says so. + - **Upgrading:** on an app whose dashboards bind sums or derived measures, `os verify --dashboard-windows` now probes those widgets. A window that does not reach them now fails the run (`hardFailures`), where before they were only counted as `excluded`. +- 7958bdf: No flow position reads the single-brace `{…}` template dialect any more, and its resolver is deleted (ADR-0032 Sequencing 6: "delete the single-brace resolver"). `AutomationEngine.evaluateCondition` evaluates every condition as CEL: a condition carrying a `{var}` hole is refused as the CEL brace trap, naming its source, and a `template` or `flow` envelope is refused before evaluation, where the deleted path substituted the variables and compared the text. A literal in a value slot is handed on as written: a `Date` in a code-defined flow reaches the write as the `Date`, where the deleted interpolator wrote `{}`. + + Clause-②: no (narrowing) + + + + **BREAKING**: a narrowing of a published runtime method and of published lint exports, shipped as `major` on the v18 line (`.changeset/pre.json` is open on `main` in `next` pre mode, so the release is `18.0.0-next.*`). + + **Why.** ADR-0032 Decision 3 keeps one template delimiter, `{{ }}`, and its Sequencing 6 deletes the single-brace resolver. The earlier changes on this line retired the dialect position by position — every value slot, the text slots, each CRUD filter operand — so the resolver's last readers were two calls that saw literals only and a condition path that only a direct call reached. + + **What changes.** + + - **Conditions.** `AutomationEngine.evaluateCondition(condition, variables, context?)` reads every condition as CEL — bare text, an envelope with `dialect: 'cel'`, or one naming no dialect. The template path it kept for a string carrying a `{var}` hole is deleted: such a string is refused as a CEL fault that names its source and says not to wrap field references in `{…}`, and an envelope with `dialect: 'template'` or `dialect: 'flow'` is refused before evaluation. `registerFlow` already refused both at every condition slot (a start node's `condition`, an edge `condition`, a `decision` branch's `expression`, a screen field's `visibleWhen`), so no registered flow changes. What changes is a direct caller: a plugin calling the method, or `@objectstack/verify`'s `stack.automation.evaluateCondition`, which hands the condition over as given. + - **Value-slot literals.** A literal in a value slot — a `fields`, `input` or `templateData` value, an `assignment` value in any of its three shapes, a `notify` recipient, an `http` `body`, a screen's `defaults`, `defaultValue` or `recordId` — is handed on as a copy of what was written (arrays and plain objects copied, every other value as it is). The deleted interpolator walked every object's own entries, so a `Date` written in a code-defined flow reached the write as `{}`; it reaches it as the `Date`. + - **`@objectstack/lint`.** `validateFlowTemplatePaths` reads the `{{ }}` holes of a node's strings and no single-brace token; a CRUD node's `filter` is not scanned, since each operand is a value slot that renders no template. So `flow-template-unknown-field` and `flow-template-lookup-traversal` report only `warning` now: their `error` at a filter operand, and every finding on a single-brace token, are gone, and the value-slot and text-slot judges refuse those tokens at the same doors as `expression-invalid`. Messages and hints spell a hole `{{ record.x }}`. `flow-double-brace-interpolation` and `flow-bare-dollar-reference`, at a string outside every value slot, text slot and declared literal (a plugin node's own config, a `connector_action` input), say the value is read as written, where they prescribed a `{var}` token nothing reads. The lint's mirror of the retired grammar is removed with its exports. + - **`@objectstack/spec`.** The `FlowSchema` example's `connector_action` input holds a literal, since nothing reads a token there. The upgrade guide's step-18 text-slot entry says no other flow string reads the single-brace dialect, where it said every other one kept it, and its value-slot entry names every value slot the retirement reached on this line, where it named only the `assignment` values and the CRUD `fields` map. + + ## FROM → TO + + | you wrote | write instead | what changes | + |:--|:--|:--| + | `engine.evaluateCondition('{amount} > 100', vars)` | `engine.evaluateCondition('amount > 100', vars)` | the template substituted text and compared it; CEL compares the values and fails the run on an absent variable | + | `"{status} == 'open'"`, `'{status} == open'` | `"status == 'open'"` | a string literal is quoted | + | `{ dialect: 'template', source: "{flag} == true" }`, `dialect: 'flow'` | `{ dialect: 'cel', source: 'flag == true' }` | — | + | `classifyFlowTemplateToken(inner)` (`@objectstack/lint`) | `valueSlotTemplateRefusals(value)` / `textSlotTemplateRefusal(text)` (`@objectstack/spec/automation`), which refuse a token with the spelling that replaces it | the grammar it classified is read by nothing | + | `FLOW_TEMPLATE_DATE_FUNCTIONS`, `FLOW_TEMPLATE_VALUE_FUNCTIONS` | the CEL stdlib: `isoDate(today())`, `isoDatetime(now())`, `round`, `floor`, `ceil`, `abs`, `min`, `max` | — | + | `FlowTemplateTokenVerdict` | nothing: the judges' reading of the retired grammar is internal to `@objectstack/spec` | — | + + **The one-line fix: write a condition as CEL, without `{…}` around a reference.** + + **Who is affected, measured.** In this repository at the base: no braced condition and no `template` or `flow` condition envelope in `examples/`, the docs or any package source; the deleted path's own pins in `@objectstack/service-automation` (`engine.test.ts`, `nested-region-parity.test.ts`) and `@objectstack/verify`'s door test are re-pinned to the refusal here. No authored `Date` literal in a flow was found; a compiled artifact serialises one to text, so only a flow handed to `registerFlow` in code carried one. Neither repository imports the removed lint exports. hotcrm at `0f84c9c` carries no braced condition and no `connector_action` input; its flows still author the single-brace dialect at value and text positions earlier changes on this line refuse, which its own upgrade migrates. + + ### The kit + + - **The engine.** `AutomationEngine.evaluateCondition` (`@objectstack/service-automation`): the template path, its hole discriminator and its text comparison are deleted. The value-slot resolvers' literal arm and the `assignment` executor copy a literal (`copyLiteral`, `builtin/value-slot-map.ts`). The single-brace interpolator (`interpolate`, `interpolateString`, `resolveToken`, its function table and `FlowExpressionFunctionError`), which the package did not export, is deleted with its tests. + - **The lint.** `validateFlowTemplatePaths` and the two wrong-syntax hints (`lint-flow-patterns.ts`); `classifyFlowTemplateToken`, `FLOW_TEMPLATE_DATE_FUNCTIONS`, `FLOW_TEMPLATE_VALUE_FUNCTIONS` and `FlowTemplateTokenVerdict` are removed. + - **The verify handle.** `stack.automation.evaluateCondition`'s documentation states the engine's reading: CEL, a `{var}` hole refused. + - **ADR-0087.** No new entry: the step-18 D3 entries `flow-value-slot-template-dialect-refused` and `flow-text-slot-single-brace-refused` record the retirement. +- 37c7114: docs(verify): `hooks.run` names the anonymous public-form door a booted stack already serves + + Clause-②: no + + An app's web-to-lead or web-to-case branch runs on an anonymous public-form submission, and a booted stack already serves that write: it is the form's own route, `api('/forms/:slug/submit', { method: 'POST', body })` with no token. `bootStack` mounts that route, and the in-process handle has no method for it, by design: its one owner is `@objectstack/rest`. Nothing changes in what the package does. `hooks.run`'s documentation now names the door, so a suite reaches it instead of calling the engine with a hand-built guest context. + + What the door does, as the package's own tests now pin it on a booted stack: + + - It hands the engine the route's own context: the form's one-object grant, the `guest_portal` permission set, and `anonymous`. There is no user and no system principal. + - A bound hook sees no session and no user, so an app's guest branch runs, and the record-change trigger fires the object's flows with no trigger user. + - It keeps only the fields the form collects and answers `201` with `{ id }`. Read the row back with `rows(object, { id })`. + - An unknown slug, or a form that is shared but not anonymous, is answered `404` with `code: 'FORM_NOT_FOUND'`, and nothing is written. +- 1e62f71: Inbound mail lands as a capability: `requires: ['mailbox']` registers the mirrored message `sys_mail_message`, its record links `sys_mail_association` and the mailbox directory `sys_mailbox`, with an unassociated pool restricted to mailbox owners and the new `manage_mailboxes` capability + + Clause-②: yes (widening) + + - **`@objectstack/spec`.** Two closed vocabularies each gain one entry; every stack and permission set that parsed before parses unchanged. + - **`mailbox`** is a platform capability token (`PLATFORM_CAPABILITY_TOKENS`, provider `@objectstack/service-mailbox`, open edition). It is not on the always-on slate: an app that wants mailboxes declares `requires: ['mailbox']`. + - **`manage_mailboxes`** is a curated organization capability (`PLATFORM_CAPABILITIES`), held by default by platform administrators (`ADMIN_FULL_ACCESS_CAPABILITIES`). Its holder reads the mailbox directory, and every mirrored message and association of its organization past the mail read gates, the unassociated pool included. Object grants and field-level security still apply to a holder. + - **`@objectstack/service-mailbox`** (new). `MailboxServicePlugin` owns the three objects (ADR-0029 D1/D2: registered only where the capability is required) and installs two read gates on the data middleware, so a list's `total` agrees with its rows: + - `sys_mail_message`, the RFC 5322 mirror of one inbound message (participants, Message-ID and threading headers, subject, text and HTML bodies, attachments as `sys_attachment` rows). A row is served to a caller who can read a record one of its associations names, to the owner of the mailbox it arrived in, or to a `manage_mailboxes` holder. A message no record matched (`association_state: 'unassociated'`) is therefore refused to an ordinary member as not found (`404 RECORD_NOT_FOUND`), never shown organization-wide. + - `sys_mail_association`, one row per (message, record) link with the participant address and email field that matched. A row is served to a caller who can read the record it names, or to a `manage_mailboxes` holder. + - `sys_mailbox`, the mailbox's address and owner, served only to a `manage_mailboxes` holder (`requiredPermissions`). + - All three are `engine-owned` and the data API serves `get` and `list` only. The HTML body is plain text (`textarea`), never rendered markup. Nothing writes these rows yet: the IMAP connector and the ingest engine, which stamp associations from the email fields an app flags with `mailAssociation`, ship next in this package. + - **`@objectstack/core`.** `resolveReadableParentIds` ("which of these parent records can the caller read", one caller-scoped engine read per parent object) is exported, so every parent-derived read gate asks the one evaluator. `CAPABILITY_PROVIDERS` gains the `mailbox` row. + - **`@objectstack/plugin-security`.** `organization_admin` (and its derived no-bypass variant) holds `manage_mailboxes`: an organization administrator reads its own organization's mailboxes and pool. + - **`@objectstack/plugin-audit`, `@objectstack/service-storage`.** The comment, activity, ledger and attachment gates import `resolveReadableParentIds` from `@objectstack/core` instead of carrying their own copies. The two copies were byte-identical, so no gate's answer changes. + - **`@objectstack/cli`, `@objectstack/verify`.** Both depend on `@objectstack/service-mailbox`, so `objectstack serve` and `bootStack` mount the provider an app's `requires: ['mailbox']` names. The ADR-0131 organization-ownership inventory carries a row for each new object. +- de24373: fix(verify): `bootStack` composes the artifact-less `MetadataPlugin` that `objectstack serve` gives a host configuration, so a stack's declared `apis` endpoints answer under the harness as they answer under `serve` (#23088) + + Clause-②: no + + `bootStack`'s `metadata` slot was the kernel's in-memory fallback, which has no endpoint matcher (`matchEndpoint`). So every `apis` endpoint a stack declared answered `404 ENDPOINT_NOT_FOUND` in a test boot while `objectstack serve` of the same stack served it, and the boot logged `AppPlugin`'s "no endpoint matcher" warning. The harness now composes `@objectstack/metadata`'s `MetadataPlugin` the way `serve` composes it for a host configuration: no artifact, `bootstrap: 'lazy'`, and `rootDir` set to `BootOptions.hostRoot`. It reads none of the stack, so `AppPlugin` stays the single registrar of the stack's metadata. + + - A declared endpoint answers as it does under `serve`: `401` without a session where it is `authRequired`, and its declared answer with one. An undeclared path under the same mount keeps the bare `404`. + - A `MetadataPlugin` you pass in `BootOptions.extraPlugins`, or one your app declares in its own `plugins`, takes the composed one's place. Pass your own to boot an artifact-backed one. + - Two other answers of a test boot move with the slot: `GET /api/v1/discovery` reports the `metadata` service as `available` instead of `degraded`, and `GET /api/v1/dev/metadata-events` (the development-only metadata reload stream, mounted because `bootStack` boots in the development posture) answers `200` instead of `404`, as under `objectstack dev`. The `GET /api/v1/meta/TYPE` answers are unchanged, measured over 22 types on the showcase and CRM examples. +- b4d573a: fix(spec,core): the rolling `last_7_days`, `last_30_days` and `last_90_days` presets close on today, at the spec's prescription table, the save door's refusal and the analytics preset arm + + Clause-②: no (no accept set widens: the three rolling windows gain the inclusive `{today}` end every calendar preset already has) + + The three rolling windows were the open arm of `DATE_RANGE_PRESET_MACRO_WINDOWS`: `['{30_days_ago}', null]`, prescribed as `{ $gte: '{30_days_ago}' }`. A `$gte`-only window has no upper bound, so on a column that carries future dates (a close date, a due date) "last 30 days" also counted every later day. Measured on a `date` field with one row dated today−5 and one dated today+5: that window counted 2, while the console's preset lowering and the analytics preset arm counted 1. + + - `DATE_RANGE_PRESET_MACRO_WINDOWS` now closes every rolling window on `{today}`: `last_7_days` is `['{7_days_ago}', '{today}']`, and `last_30_days` and `last_90_days` follow the same shape. That is the inclusive last-day end every calendar preset already uses, and the extent the console's lowering already selects. The table's declared type narrows to match, `readonly [start: string, end: string]` (it was `end: string | null`). A reader that compared `end` with `null` still compiles; that branch can no longer be taken. + - The save door's refusal of a bare preset comparand (`bareDateRangePresetComparandMessage`, and the `filter-preset-comparand` lint verdict that quotes it) now prescribes the closed window. For `last_30_days` it says: write `{ $between: ['{30_days_ago}', '{today}'] }`, where it used to say `{ $gte: '{30_days_ago}' }`. + - `@objectstack/core`'s `resolveAnalyticsDateRangePreset`, the preset arm behind `timeDimensions[].dateRange`, selects the same extent. A rolling window now stops before tomorrow begins in the reference timezone, exactly as `today` does, with `endExclusive: true`. It used to end at the reference instant, so it dropped rows stamped later today. Its ends keep this module's own "the day the window stops before" spelling (`tomorrow`). Every preset this resolver returns is now `endExclusive: true`, and a caller's explicit `[a, b]` window keeps its inclusive reading, as before. + - The semantic migration entry `filter-preset-ordering-comparand-refused`, which `spec-changes.json` and `os migrate meta` carry, now names `{ $between: ["{30_days_ago}", "{today}"] }` as the window for `last_30_days` in its replacement, where it named `{ $gte: "{30_days_ago}" }`. + + If you followed the old refusal and authored `{ $gte: '{N_days_ago}' }` for a rolling preset, add `$lte: '{today}'`, or write the `$between` pair above, to stop counting future-dated rows. +- d65826f: fix: framework writes on tenant-column objects name the organization they can derive (ADR-0131 D9) + + Clause-②: yes (widening) + + A write on a tenant-column object with no organization is derived under `single` and refused under a wall (ADR-0131 D9). Before the engine stops letting such writes through, each of these producers now names the organization it can derive itself. Under `single` that is the Default Organization, which is the owner the engine would derive anyway, so no reader changes. Under a wall the row names an organization only where the producer has one. The rows it cannot attribute are written exactly as before. + + - **`@objectstack/plugin-auth`.** The self-registration grant (`sys_user_permission_set`) and the auth mails about a user (verification, password reset, account invitation, magic link, change-email notice → `sys_email`) read one derivable organization: the `tenancy` service's `defaultOrgId()`. Under `single` that is the Default Organization. Under a wall it is none, because the registrant belongs to no organization yet. The verification and reset mails used to persist with no organization even under `single`. + - **`@objectstack/plugin-audit`.** An activity row (`sys_activity`) about a record with no organization of its own (a `sys_user`, a platform setting), written with no session organization (a sign-up), takes the Default Organization under `single`. The record's own organization and then the session's still win. The ledger's `tenant_id` attribution is unchanged. New optional `AuditWriterOptions.getDefaultOrganizationId()`; `AuditPlugin` wires it to the `tenancy` service. + - **`@objectstack/service-settings`.** The `sys_secret` ciphertext and the `sys_setting_audit` history row of a `tenant` or `user` value carry that value's organization, which is the same owner as its `sys_setting` row. A `global` value's rows name none. New optional `organization_id` on `SettingsSecretStore.insert`'s row and `organizationId` on `SettingsAuditWriter.write`'s entry; the built-in writers persist them. A failed `sys_setting_audit` write is now reported at `error`, not `warn`: the settings write succeeds while its history row is lost. The first report names the consequence and the fix. `buildSettingAuditWriter`'s optional logger argument keeps every sink it accepted (none, `{}`, or one with an optional `warn`) and additionally takes a sink that carries `error`. It reports through `error` when the sink has it, else through `warn`. + - **`@objectstack/verify`.** The RLS probe and position personas' grants (`sys_user_permission_set`, `sys_user_position`) belong to the organization the persona is a member of (the Default Organization under `single`). They used to be written with an explicit `organization_id: null`. A persona that belongs to no organization, or to several, names none. +- Updated dependencies [5a871e4] +- Updated dependencies [f8ef144] +- Updated dependencies [5b14b7f] +- Updated dependencies [ffba02e] +- Updated dependencies [29d0ed3] +- Updated dependencies [29d0ed3] +- Updated dependencies [1b40780] +- Updated dependencies [666283a] +- Updated dependencies [ca426cb] +- Updated dependencies [4ad3ce5] +- Updated dependencies [f72ce93] +- Updated dependencies [8016a9d] +- Updated dependencies [df9d07e] +- Updated dependencies [913fd6d] +- Updated dependencies [8eac321] +- Updated dependencies [de43524] +- Updated dependencies [a5a781b] +- Updated dependencies [04669a4] +- Updated dependencies [92ce3af] +- Updated dependencies [b61353f] +- Updated dependencies [9a7838e] +- Updated dependencies [191e043] +- Updated dependencies [b12e16a] +- Updated dependencies [fbafb85] +- Updated dependencies [35450f0] +- Updated dependencies [23dae89] +- Updated dependencies [c395405] +- Updated dependencies [29527f2] +- Updated dependencies [7c7741d] +- Updated dependencies [1e8b628] +- Updated dependencies [65a8eeb] +- Updated dependencies [5018fc3] +- Updated dependencies [31e0735] +- Updated dependencies [bfc1539] +- Updated dependencies [3b8e2b1] +- Updated dependencies [14ccb1d] +- Updated dependencies [8fd3944] +- Updated dependencies [7879391] +- Updated dependencies [a789148] +- Updated dependencies [d61ed6a] +- Updated dependencies [317cddd] +- Updated dependencies [b2e7870] +- Updated dependencies [fec87e7] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [34dba5a] +- Updated dependencies [959c209] +- Updated dependencies [c28f317] +- Updated dependencies [17e4425] +- Updated dependencies [7d7943d] +- Updated dependencies [aa71c4d] +- Updated dependencies [19bb55e] +- Updated dependencies [19bb55e] +- Updated dependencies [ba8af87] +- Updated dependencies [6a49efb] +- Updated dependencies [99801d8] +- Updated dependencies [2e10c9a] +- Updated dependencies [dac99c2] +- Updated dependencies [5c9bfb0] +- Updated dependencies [0d3ea29] +- Updated dependencies [67b669e] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [0253192] +- Updated dependencies [48cca4a] +- Updated dependencies [45e206e] +- Updated dependencies [bfc15d2] +- Updated dependencies [2ff0825] +- Updated dependencies [e7e6544] +- Updated dependencies [e7e6544] +- Updated dependencies [cd3d391] +- Updated dependencies [149294c] +- Updated dependencies [990341a] +- Updated dependencies [9646991] +- Updated dependencies [7aa8d51] +- Updated dependencies [95a8435] +- Updated dependencies [c8a9783] +- Updated dependencies [9e9e6f9] +- Updated dependencies [2fbe1fd] +- Updated dependencies [60bcfe7] +- Updated dependencies [66949b7] +- Updated dependencies [d112087] +- Updated dependencies [d112087] +- Updated dependencies [4806b18] +- Updated dependencies [1802315] +- Updated dependencies [06cc45d] +- Updated dependencies [8c5aa50] +- Updated dependencies [7de0265] +- Updated dependencies [7895671] +- Updated dependencies [b389e43] +- Updated dependencies [c8b062f] +- Updated dependencies [117d34d] +- Updated dependencies [6729e10] +- Updated dependencies [1920cf3] +- Updated dependencies [1920cf3] +- Updated dependencies [41d0d40] +- Updated dependencies [c52bfb4] +- Updated dependencies [f9b4217] +- Updated dependencies [eebaf3b] +- Updated dependencies [9847f87] +- Updated dependencies [78652c0] +- Updated dependencies [c7d6037] +- Updated dependencies [240890c] +- Updated dependencies [72b26ed] +- Updated dependencies [2d21b9c] +- Updated dependencies [f66fdc7] +- Updated dependencies [9fdb127] +- Updated dependencies [615c696] +- Updated dependencies [968acc8] +- Updated dependencies [111ffff] +- Updated dependencies [12b9daf] +- Updated dependencies [7958bdf] +- Updated dependencies [d85615d] +- Updated dependencies [6a06e3b] +- Updated dependencies [0e9e7b7] +- Updated dependencies [0af4f66] +- Updated dependencies [9a0401f] +- Updated dependencies [9c93ce6] +- Updated dependencies [77a94d8] +- Updated dependencies [ac9f8bd] +- Updated dependencies [879bd38] +- Updated dependencies [a7a48b7] +- Updated dependencies [b88c356] +- Updated dependencies [a3bcbcf] +- Updated dependencies [8a399b2] +- Updated dependencies [04e776b] +- Updated dependencies [1c563af] +- Updated dependencies [1c563af] +- Updated dependencies [a7df552] +- Updated dependencies [2f70c22] +- Updated dependencies [78f841b] +- Updated dependencies [f0022c4] +- Updated dependencies [1fb274e] +- Updated dependencies [1fb274e] +- Updated dependencies [c565813] +- Updated dependencies [d5a14dd] +- Updated dependencies [f85a83b] +- Updated dependencies [0db5ad5] +- Updated dependencies [93125ae] +- Updated dependencies [29678f2] +- Updated dependencies [56c8844] +- Updated dependencies [5cfd866] +- Updated dependencies [d4680d2] +- Updated dependencies [56bf27a] +- Updated dependencies [e67ba80] +- Updated dependencies [c8d06a9] +- Updated dependencies [cdeabec] +- Updated dependencies [4935c66] +- Updated dependencies [c6fe02d] +- Updated dependencies [c6fe02d] +- Updated dependencies [d7c5c33] +- Updated dependencies [a87d8be] +- Updated dependencies [15ec50e] +- Updated dependencies [db4c45b] +- Updated dependencies [a543e24] +- Updated dependencies [51290bc] +- Updated dependencies [ad381fd] +- Updated dependencies [4e4111c] +- Updated dependencies [6ff6ed6] +- Updated dependencies [2b61f2d] +- Updated dependencies [d0bb78e] +- Updated dependencies [8f2e808] +- Updated dependencies [3b49318] +- Updated dependencies [4e9fe9f] +- Updated dependencies [fe98cc6] +- Updated dependencies [98998ca] +- Updated dependencies [3aa0c7b] +- Updated dependencies [3aa0c7b] +- Updated dependencies [9ad3d24] +- Updated dependencies [036bf1d] +- Updated dependencies [326a90a] +- Updated dependencies [9fe7448] +- Updated dependencies [2e11d90] +- Updated dependencies [7518ee3] +- Updated dependencies [4049cac] +- Updated dependencies [7ebbc01] +- Updated dependencies [0e9371f] +- Updated dependencies [0b7ba4d] +- Updated dependencies [0b7ba4d] +- Updated dependencies [4578c56] +- Updated dependencies [fbcbcf1] +- Updated dependencies [fbcbcf1] +- Updated dependencies [c512c25] +- Updated dependencies [ffb31fc] +- Updated dependencies [0b997ea] +- Updated dependencies [3513ac7] +- Updated dependencies [81bd9fa] +- Updated dependencies [215e662] +- Updated dependencies [3d886ee] +- Updated dependencies [f4069c5] +- Updated dependencies [d8d0711] +- Updated dependencies [0ee2d15] +- Updated dependencies [a45d5d8] +- Updated dependencies [4f4c4ed] +- Updated dependencies [79c35d4] +- Updated dependencies [b15f8a1] +- Updated dependencies [f6e1d49] +- Updated dependencies [f2626c7] +- Updated dependencies [e175479] +- Updated dependencies [59d993c] +- Updated dependencies [e9a1f5c] +- Updated dependencies [40a6ee5] +- Updated dependencies [e87070e] +- Updated dependencies [5910b5e] +- Updated dependencies [7098aca] +- Updated dependencies [97610a5] +- Updated dependencies [081e6a0] +- Updated dependencies [c8c803c] +- Updated dependencies [f05649f] +- Updated dependencies [e030d43] +- Updated dependencies [ca135dc] +- Updated dependencies [e36ee53] +- Updated dependencies [1884948] +- Updated dependencies [3599fef] +- Updated dependencies [8a995b8] +- Updated dependencies [54c3ce1] +- Updated dependencies [3089848] +- Updated dependencies [e23eccb] +- Updated dependencies [ac8f2c5] +- Updated dependencies [43fc500] +- Updated dependencies [746637e] +- Updated dependencies [00bee27] +- Updated dependencies [fdfdd7e] +- Updated dependencies [db9cf80] +- Updated dependencies [e75dced] +- Updated dependencies [9af0005] +- Updated dependencies [3403be8] +- Updated dependencies [f66c440] +- Updated dependencies [440bed6] +- Updated dependencies [c572fe2] +- Updated dependencies [166a94f] +- Updated dependencies [dd986d8] +- Updated dependencies [b53b949] +- Updated dependencies [b2549a8] +- Updated dependencies [7806a14] +- Updated dependencies [ee8751d] +- Updated dependencies [96e4be4] +- Updated dependencies [194a42d] +- Updated dependencies [35ef501] +- Updated dependencies [faf6348] +- Updated dependencies [ce3d0ad] +- Updated dependencies [ce3d0ad] +- Updated dependencies [e148ca9] +- Updated dependencies [557ae7c] +- Updated dependencies [f782f17] +- Updated dependencies [6a3fe25] +- Updated dependencies [7731d70] +- Updated dependencies [5495331] +- Updated dependencies [da989bb] +- Updated dependencies [3073b72] +- Updated dependencies [c76edeb] +- Updated dependencies [5b12503] +- Updated dependencies [514bf3c] +- Updated dependencies [20e7d52] +- Updated dependencies [b3a3634] +- Updated dependencies [3e72f93] +- Updated dependencies [f89b5f5] +- Updated dependencies [d25b187] +- Updated dependencies [d25b187] +- Updated dependencies [0e00d36] +- Updated dependencies [d1d42ec] +- Updated dependencies [d303b3e] +- Updated dependencies [018b077] +- Updated dependencies [0fea05f] +- Updated dependencies [e194ab4] +- Updated dependencies [86bf9ed] +- Updated dependencies [d748ae8] +- Updated dependencies [dab6bf4] +- Updated dependencies [25be876] +- Updated dependencies [ed1de8c] +- Updated dependencies [b98e984] +- Updated dependencies [76bc1e0] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [a00cf99] +- Updated dependencies [5fb1746] +- Updated dependencies [29a163f] +- Updated dependencies [e223152] +- Updated dependencies [18d9990] +- Updated dependencies [9f5eca5] +- Updated dependencies [9963322] +- Updated dependencies [b27b894] +- Updated dependencies [26aa999] +- Updated dependencies [874a38d] +- Updated dependencies [3d0eeef] +- Updated dependencies [cb3bb93] +- Updated dependencies [83b8b80] +- Updated dependencies [1b99388] +- Updated dependencies [ad14957] +- Updated dependencies [02d9f69] +- Updated dependencies [f53f14b] +- Updated dependencies [e8c6666] +- Updated dependencies [a800912] +- Updated dependencies [4b7c630] +- Updated dependencies [87cd458] +- Updated dependencies [86da194] +- Updated dependencies [d973c34] +- Updated dependencies [156ddfa] +- Updated dependencies [156ddfa] +- Updated dependencies [1bb1da1] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [5081090] +- Updated dependencies [243dd3c] +- Updated dependencies [615cba8] +- Updated dependencies [b6eb25f] +- Updated dependencies [165587e] +- Updated dependencies [3b5475a] +- Updated dependencies [7399080] +- Updated dependencies [e04759d] +- Updated dependencies [ea9fd99] +- Updated dependencies [b32ee3e] +- Updated dependencies [b62260c] +- Updated dependencies [91f46d0] +- Updated dependencies [e5899a6] +- Updated dependencies [d7b26df] +- Updated dependencies [9859e4e] +- Updated dependencies [490cb6d] +- Updated dependencies [e865300] +- Updated dependencies [ec7c7e0] +- Updated dependencies [ec7c7e0] +- Updated dependencies [896a434] +- Updated dependencies [c74d843] +- Updated dependencies [6d5191e] +- Updated dependencies [85edd61] +- Updated dependencies [e84aeb3] +- Updated dependencies [8558668] +- Updated dependencies [0984817] +- Updated dependencies [322ff1e] +- Updated dependencies [680a86b] +- Updated dependencies [d952358] +- Updated dependencies [7b0a9c9] +- Updated dependencies [8bd0fcd] +- Updated dependencies [5fc57b3] +- Updated dependencies [fe58bbc] +- Updated dependencies [75b6d6a] +- Updated dependencies [c4e7fa5] +- Updated dependencies [c4e7fa5] +- Updated dependencies [581ca7d] +- Updated dependencies [3f0b6f2] +- Updated dependencies [3f0b6f2] +- Updated dependencies [b7cd1af] +- Updated dependencies [f313fbc] +- Updated dependencies [9814035] +- Updated dependencies [f9c7588] +- Updated dependencies [55382dc] +- Updated dependencies [b1d701b] +- Updated dependencies [c11b758] +- Updated dependencies [f9ebb7d] +- Updated dependencies [bb0db13] +- Updated dependencies [efcbac7] +- Updated dependencies [51f36e7] +- Updated dependencies [31b5a5f] +- Updated dependencies [20028df] +- Updated dependencies [20028df] +- Updated dependencies [69d4218] +- Updated dependencies [5ce6773] +- Updated dependencies [8532783] +- Updated dependencies [eb20c33] +- Updated dependencies [9432bac] +- Updated dependencies [6373952] +- Updated dependencies [3caae97] +- Updated dependencies [3f69471] +- Updated dependencies [87e902d] +- Updated dependencies [8bc7836] +- Updated dependencies [e5687cb] +- Updated dependencies [1e62f71] +- Updated dependencies [3d830b7] +- Updated dependencies [777eed2] +- Updated dependencies [8258181] +- Updated dependencies [ec7f7f8] +- Updated dependencies [038d4c9] +- Updated dependencies [3fdb92d] +- Updated dependencies [8d8f965] +- Updated dependencies [147a0d1] +- Updated dependencies [21914c3] +- Updated dependencies [6a40a09] +- Updated dependencies [1959aac] +- Updated dependencies [ed03834] +- Updated dependencies [e1deaef] +- Updated dependencies [117817e] +- Updated dependencies [4663801] +- Updated dependencies [8e4a230] +- Updated dependencies [3071d3e] +- Updated dependencies [d294951] +- Updated dependencies [6a6a4d3] +- Updated dependencies [0cbfb7c] +- Updated dependencies [7f2f081] +- Updated dependencies [4267484] +- Updated dependencies [1badd31] +- Updated dependencies [58f5c90] +- Updated dependencies [55828c5] +- Updated dependencies [7856110] +- Updated dependencies [9b37edb] +- Updated dependencies [4216796] +- Updated dependencies [a22e67b] +- Updated dependencies [5f37d36] +- Updated dependencies [62ce45d] +- Updated dependencies [84c4fff] +- Updated dependencies [c5b43b6] +- Updated dependencies [6662e3f] +- Updated dependencies [39e93ef] +- Updated dependencies [02bec6c] +- Updated dependencies [719183f] +- Updated dependencies [0aef788] +- Updated dependencies [c40a0dd] +- Updated dependencies [bb7ccde] +- Updated dependencies [bb7ccde] +- Updated dependencies [df000d4] +- Updated dependencies [df000d4] +- Updated dependencies [646303e] +- Updated dependencies [646303e] +- Updated dependencies [e9bc60b] +- Updated dependencies [52ff308] +- Updated dependencies [d1f082d] +- Updated dependencies [8464380] +- Updated dependencies [8c10776] +- Updated dependencies [8024e12] +- Updated dependencies [c4b84ad] +- Updated dependencies [252a582] +- Updated dependencies [abf8655] +- Updated dependencies [f931e76] +- Updated dependencies [1912af4] +- Updated dependencies [0511341] +- Updated dependencies [a3d47c2] +- Updated dependencies [21c8644] +- Updated dependencies [dfb3ba2] +- Updated dependencies [e5f9f01] +- Updated dependencies [76815b9] +- Updated dependencies [4d1e4a8] +- Updated dependencies [d4aaeaf] +- Updated dependencies [5d63c37] +- Updated dependencies [dc93781] +- Updated dependencies [4cc3ff9] +- Updated dependencies [20949e7] +- Updated dependencies [3d6392f] +- Updated dependencies [c056979] +- Updated dependencies [4f68ba1] +- Updated dependencies [3490bf5] +- Updated dependencies [920bc12] +- Updated dependencies [107853c] +- Updated dependencies [543b33d] +- Updated dependencies [b529a5c] +- Updated dependencies [1623faf] +- Updated dependencies [b4d573a] +- Updated dependencies [3c1a046] +- Updated dependencies [6f4c518] +- Updated dependencies [08b38cb] +- Updated dependencies [bae5841] +- Updated dependencies [87d0619] +- Updated dependencies [917b1a0] +- Updated dependencies [d9d7edd] +- Updated dependencies [4e0838d] +- Updated dependencies [d45d0e9] +- Updated dependencies [cb9841a] +- Updated dependencies [b60eb78] +- Updated dependencies [60f9baf] +- Updated dependencies [f5ef054] +- Updated dependencies [0d34066] +- Updated dependencies [0c3db0f] +- Updated dependencies [8d3dd6f] +- Updated dependencies [8ed3786] +- Updated dependencies [584e737] +- Updated dependencies [37510aa] +- Updated dependencies [c857a93] +- Updated dependencies [01d5034] +- Updated dependencies [f24574a] +- Updated dependencies [e790cfc] +- Updated dependencies [345d1be] +- Updated dependencies [430e5f5] +- Updated dependencies [09ef77c] +- Updated dependencies [5d3018d] +- Updated dependencies [404fc47] +- Updated dependencies [14a13bc] +- Updated dependencies [ca0bdbc] +- Updated dependencies [3d231ba] +- Updated dependencies [301eec6] +- Updated dependencies [572ecfb] +- Updated dependencies [5547ad4] +- Updated dependencies [94924e0] +- Updated dependencies [707b3cd] +- Updated dependencies [6673c80] +- Updated dependencies [24e064f] +- Updated dependencies [0f05907] +- Updated dependencies [5e725b8] +- Updated dependencies [05e3b8e] +- Updated dependencies [6d96721] +- Updated dependencies [086fa46] +- Updated dependencies [eeb95f7] +- Updated dependencies [d89f491] +- Updated dependencies [dc39326] +- Updated dependencies [c245986] +- Updated dependencies [28e294f] +- Updated dependencies [775ed65] +- Updated dependencies [0be162c] +- Updated dependencies [02a691a] +- Updated dependencies [e4ec6ec] +- 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 [b9dbc3d] +- Updated dependencies [07b27b3] +- Updated dependencies [d0c09e4] +- Updated dependencies [3fed1eb] +- Updated dependencies [1692cd1] +- Updated dependencies [0d784da] +- Updated dependencies [be50633] +- 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 [a35f7a6] +- 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 [1eac7ad] +- Updated dependencies [70d4c06] +- Updated dependencies [989aa48] +- Updated dependencies [b1cfa3d] +- Updated dependencies [23e689d] +- Updated dependencies [9fcea20] +- Updated dependencies [0fb55fc] +- Updated dependencies [19c554d] +- Updated dependencies [5bb9a83] +- Updated dependencies [bc0538a] +- Updated dependencies [f606ba2] +- Updated dependencies [d176b0a] +- Updated dependencies [105902f] +- Updated dependencies [f901746] +- Updated dependencies [c5dd7fc] +- Updated dependencies [c263588] +- Updated dependencies [ee0e115] +- Updated dependencies [a6d0715] +- Updated dependencies [7c6b7f4] +- Updated dependencies [4feb2d0] +- Updated dependencies [f7cd580] +- Updated dependencies [c9810dd] +- Updated dependencies [9f81260] +- Updated dependencies [e611e2b] +- Updated dependencies [8684079] +- Updated dependencies [a69a40a] +- Updated dependencies [b513343] +- Updated dependencies [1ac24f2] +- Updated dependencies [0a052e6] +- Updated dependencies [30c33e3] +- Updated dependencies [7e6e4e1] +- Updated dependencies [462dff3] +- Updated dependencies [8044f4d] +- Updated dependencies [cd86e41] +- Updated dependencies [ae0ca66] +- Updated dependencies [c03077e] +- Updated dependencies [3bc407d] +- Updated dependencies [69351e9] +- Updated dependencies [395ca21] +- Updated dependencies [76560a1] +- Updated dependencies [4c21e57] +- Updated dependencies [9e0945c] +- Updated dependencies [23d0bc7] +- Updated dependencies [7428da0] +- Updated dependencies [069fd40] +- Updated dependencies [6ee483c] +- Updated dependencies [b55be42] +- Updated dependencies [abb6df0] +- Updated dependencies [5b6c257] +- Updated dependencies [0b1179f] +- Updated dependencies [941c78a] +- Updated dependencies [8776119] +- Updated dependencies [74b4362] +- Updated dependencies [f7a5ebe] +- Updated dependencies [0a168a3] +- Updated dependencies [97b8e7a] +- Updated dependencies [af3873b] +- Updated dependencies [9d4fb3c] +- Updated dependencies [c5cf546] +- Updated dependencies [9cb9d95] +- Updated dependencies [cf07168] +- Updated dependencies [f846cc2] +- Updated dependencies [34b740f] +- Updated dependencies [30ce56e] +- Updated dependencies [2e0e451] +- Updated dependencies [94114c1] +- Updated dependencies [1ecb08a] +- Updated dependencies [adad73f] +- Updated dependencies [0cfd0c4] +- Updated dependencies [ae4dfaf] +- Updated dependencies [099cc9d] +- Updated dependencies [77efc66] +- Updated dependencies [747c8e9] +- Updated dependencies [6422da5] +- Updated dependencies [6422da5] +- Updated dependencies [8f4260f] +- Updated dependencies [d0fe050] +- Updated dependencies [dd11563] +- Updated dependencies [67e8792] +- Updated dependencies [2e15620] +- Updated dependencies [0cb45e9] +- Updated dependencies [2c702fd] +- Updated dependencies [92fd343] +- Updated dependencies [b74434c] +- Updated dependencies [f7af839] +- Updated dependencies [bbdfc05] +- Updated dependencies [605aa01] +- Updated dependencies [a33f3d5] +- Updated dependencies [d050a8f] +- Updated dependencies [9e797a8] +- Updated dependencies [b25d98a] +- Updated dependencies [75ced06] +- Updated dependencies [5fb8d15] +- Updated dependencies [2054a08] +- Updated dependencies [5c2ac83] +- Updated dependencies [a237d7f] +- Updated dependencies [4ffdebe] +- Updated dependencies [03ac4ec] +- Updated dependencies [33228c5] +- Updated dependencies [5e97907] +- Updated dependencies [d731b89] +- Updated dependencies [19955fb] +- Updated dependencies [905f57b] +- Updated dependencies [aefab7c] +- Updated dependencies [66d2757] +- Updated dependencies [c7168ac] +- Updated dependencies [44fe210] +- Updated dependencies [e246f06] +- Updated dependencies [a65a59b] +- Updated dependencies [b75129f] +- Updated dependencies [b0a1df0] +- Updated dependencies [6e43b7b] +- Updated dependencies [6dede7a] +- Updated dependencies [727091c] +- Updated dependencies [62f959d] +- Updated dependencies [c1c0a90] +- Updated dependencies [ab3ba2c] +- Updated dependencies [8a83ceb] +- Updated dependencies [462d02f] +- Updated dependencies [62c4441] +- Updated dependencies [f792290] +- Updated dependencies [eda6f14] +- Updated dependencies [2ee5861] +- Updated dependencies [544b8d7] +- Updated dependencies [45e6001] +- Updated dependencies [e46ce7b] +- Updated dependencies [d702b18] +- Updated dependencies [a06f0d4] +- Updated dependencies [a06f0d4] +- Updated dependencies [a19b2a7] +- Updated dependencies [9f024d8] +- Updated dependencies [9975c26] +- Updated dependencies [2ac0616] +- Updated dependencies [2ac0616] +- Updated dependencies [2ac0616] +- Updated dependencies [5a9a80b] +- Updated dependencies [1489952] +- Updated dependencies [04fd88a] +- Updated dependencies [aaaeeac] +- Updated dependencies [8eccf51] +- Updated dependencies [8a88901] +- Updated dependencies [030a7b8] +- Updated dependencies [b1845ed] +- Updated dependencies [99e9d29] +- Updated dependencies [70cfb12] +- Updated dependencies [aa31569] +- Updated dependencies [8259ea0] +- Updated dependencies [9040945] +- Updated dependencies [8bfb17d] +- Updated dependencies [8215ff7] +- Updated dependencies [4f11af5] +- Updated dependencies [b036ac5] +- Updated dependencies [d537a31] +- Updated dependencies [1bf5f6e] +- Updated dependencies [1bf5f6e] +- Updated dependencies [d537a31] +- Updated dependencies [6ec0e12] +- Updated dependencies [2bcd406] +- Updated dependencies [2bcd406] +- Updated dependencies [58a2faa] +- Updated dependencies [e74ec56] +- Updated dependencies [58a2faa] +- Updated dependencies [2bcd406] +- Updated dependencies [e74ec56] +- Updated dependencies [af284b0] +- Updated dependencies [cbfa5e4] +- Updated dependencies [14a8bd2] +- Updated dependencies [77e9f79] +- Updated dependencies [83dfbc0] +- Updated dependencies [9822b05] +- Updated dependencies [889bf12] +- Updated dependencies [7a8733f] +- Updated dependencies [1e001c7] +- Updated dependencies [1e001c7] +- Updated dependencies [0b44840] +- Updated dependencies [e15897c] +- Updated dependencies [ef65646] +- Updated dependencies [586705d] +- Updated dependencies [d79dc48] +- Updated dependencies [1173017] +- Updated dependencies [8baeda5] +- Updated dependencies [5da2e88] +- Updated dependencies [51ad8e4] +- Updated dependencies [ec749fb] +- Updated dependencies [c4526a7] +- Updated dependencies [fa26308] +- Updated dependencies [2ce2e36] +- Updated dependencies [584b422] +- Updated dependencies [62e2b6d] +- Updated dependencies [01fafde] +- Updated dependencies [370833a] +- Updated dependencies [2f569a9] +- Updated dependencies [69147ab] +- Updated dependencies [3a734e0] +- Updated dependencies [6868514] +- Updated dependencies [ba5bfe9] +- Updated dependencies [26501ae] +- Updated dependencies [9112bcd] +- Updated dependencies [31e7d0b] +- Updated dependencies [94e60c0] +- Updated dependencies [94e60c0] +- Updated dependencies [538f923] +- Updated dependencies [9524666] +- Updated dependencies [d65826f] +- Updated dependencies [741aa85] +- Updated dependencies [741aa85] +- Updated dependencies [db1f1fd] +- Updated dependencies [cf16a0d] +- Updated dependencies [cf540a7] +- Updated dependencies [cf540a7] +- Updated dependencies [d8e11b5] +- Updated dependencies [44a5011] +- Updated dependencies [adb7910] +- Updated dependencies [adb7910] +- Updated dependencies [f573976] +- Updated dependencies [f573976] +- Updated dependencies [2a1e48a] +- Updated dependencies [69f342b] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [2ec6827] +- Updated dependencies [fb0d85b] +- Updated dependencies [71fa166] +- Updated dependencies [4d12b23] +- Updated dependencies [4d12b23] +- Updated dependencies [ff48407] +- Updated dependencies [e22defb] +- Updated dependencies [45bec43] +- Updated dependencies [964a237] +- Updated dependencies [b7a688e] +- Updated dependencies [ef1fcb2] +- Updated dependencies [ace0a53] +- Updated dependencies [8311650] +- Updated dependencies [8311650] +- Updated dependencies [de70e97] +- Updated dependencies [74c4b34] +- Updated dependencies [e040b8c] +- Updated dependencies [f451a59] +- Updated dependencies [ba95607] +- Updated dependencies [b53b949] +- Updated dependencies [ea9fd99] +- Updated dependencies [1e44436] +- Updated dependencies [2acff87] +- Updated dependencies [299a2c6] +- Updated dependencies [325a92e] +- Updated dependencies [e23eccb] +- Updated dependencies [bf492c8] +- Updated dependencies [c7a3234] +- Updated dependencies [cdf3777] + - @objectstack/core@18.0.0 + - @objectstack/objectql@18.0.0 + - @objectstack/runtime@18.0.0 + - @objectstack/spec@18.0.0 + - @objectstack/platform-objects@18.0.0 + - @objectstack/service-automation@18.0.0 + - @objectstack/plugin-approvals@18.0.0 + - @objectstack/service-analytics@18.0.0 + - @objectstack/plugin-audit@18.0.0 + - @objectstack/plugin-sharing@18.0.0 + - @objectstack/plugin-webhooks@18.0.0 + - @objectstack/service-settings@18.0.0 + - @objectstack/plugin-email@18.0.0 + - @objectstack/plugin-auth@18.0.0 + - @objectstack/metadata@18.0.0 + - @objectstack/service-storage@18.0.0 + - @objectstack/plugin-security@18.0.0 + - @objectstack/rest@18.0.0 + - @objectstack/service-realtime@18.0.0 + - @objectstack/service-messaging@18.0.0 + - @objectstack/service-datasource@18.0.0 + - @objectstack/types@18.0.0 + - @objectstack/plugin-hono-server@18.0.0 + - @objectstack/mcp@18.0.0 + - @objectstack/service-sms@18.0.0 + - @objectstack/trigger-api@18.0.0 + - @objectstack/service-mailbox@18.0.0 + - @objectstack/service-package@18.0.0 + - @objectstack/service-job@18.0.0 + - @objectstack/plugin-pinyin-search@18.0.0 + - @objectstack/service-cache@18.0.0 + - @objectstack/service-queue@18.0.0 + - @objectstack/trigger-record-change@18.0.0 + - @objectstack/trigger-schedule@18.0.0 + ## 18.0.0-next.5 ### Patch Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index dbff6e99c91..91d9c8da1e6 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "18.0.0-next.5", + "version": "18.0.0", "license": "Apache-2.0", "description": "Boot any ObjectStack app in-process and verify it through the real HTTP stack — auto-derived CRUD round-trip fidelity plus the cross-owner RLS invariant. Catches runtime regressions that static checks miss.", "type": "module", diff --git a/skills/objectstack-ai/SKILL.md b/skills/objectstack-ai/SKILL.md index 609e884845e..1ce24ceee8e 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.5" + version: "18.0.0" 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 d8bf39622a5..ef4aaebab21 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.5" + version: "18.0.0" domain: api tags: rest, endpoint, auth, realtime, server --- diff --git a/skills/objectstack-automation/SKILL.md b/skills/objectstack-automation/SKILL.md index 40b61fd95ba..d1beab6fcae 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.5" + version: "18.0.0" 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 52fdce27c0e..bff697b6674 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.5" + version: "18.0.0" 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 809d0172a8a..a70bf200f00 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.5" + version: "18.0.0" 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 00659b935b5..6ee0ce4c140 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.5" + version: "18.0.0" domain: i18n tags: i18n, translation, locale, l10n, bundle, coverage --- diff --git a/skills/objectstack-platform/SKILL.md b/skills/objectstack-platform/SKILL.md index 42b76531958..e20868689be 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.5" + version: "18.0.0" 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 eba997686ea..52eb498efcf 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.5" + version: "18.0.0" 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 f4bebaa6fc3..234fd045d4a 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.5" + version: "18.0.0" 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 991771d4452..885583cfcd0 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.5" + version: "18.0.0" domain: process tags: upgrade, migration, protocol, major, retired-keys, tombstone, conversions, validate, report ---