From 42b65425286957c0b726c5155e9982520ba38fa1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:44:28 +0000 Subject: [PATCH] chore: version packages --- .changeset/account-oauth-tokens-internal.md | 72 -- .changeset/admin-export-wildcard-removed.md | 80 -- ...alytics-authorable-unknown-keys-refused.md | 67 -- ...anchor-missing-relation-quoted-template.md | 39 - .changeset/api-key-carries-organization.md | 74 -- .../approvals-record-reader-visibility.md | 56 -- .../audit-meta-item-organization-scope.md | 47 - .../audit-row-record-organization-stamp.md | 45 - ...capability-class-name-identity-enforced.md | 51 - ...cbp-master-editability-authored-widener.md | 60 -- .changeset/cbp-master-leg-ownership-floor.md | 83 -- ...ssing-master-insert-validation-envelope.md | 91 -- ...client-explain-recordids-batch-spelling.md | 13 - .../cloud-arm-host-marketplace-precedence.md | 9 - .changeset/dashboard-dataset-publish-gate.md | 10 - ...rce-config-url-query-credential-refused.md | 66 -- .changeset/datasource-credential-rehoming.md | 50 - .../deactivated-position-stops-sharing.md | 81 -- ...rived-capability-unseeded-bucket-warned.md | 46 - .changeset/diagnostics-store-outage-503.md | 70 -- .changeset/diff-dead-history-read.md | 27 - ...-item-canonical-type-and-history-outage.md | 59 -- .../dispatcher-meta-put-falsy-body-refused.md | 46 - .changeset/eighty-pandas-shake.md | 16 - .../enforce-active-on-grant-catalogues.md | 79 -- .changeset/es-es-position-rename-damage.md | 26 - .../export-filename-business-timezone.md | 39 - .../field-scale-precision-integer-refused.md | 39 - .../formula-filter-refusal-adr-0087-entry.md | 32 - .changeset/hungry-donkeys-shout.md | 9 - ...import-naive-datetime-business-timezone.md | 73 -- .../invalid-filter-target-field-provenance.md | 63 -- .changeset/ja-jp-position-rename-damage.md | 21 - .changeset/last-admin-standing-keys-gate.md | 59 -- .changeset/legacy-unique-guard-attribution.md | 51 - .changeset/list-diagnosed-consumer-sweep.md | 56 -- .changeset/lucky-pugs-repeat.md | 11 - .../mcp-http-bridge-merged-skill-read.md | 55 - .../mcp-prompt-bridge-merged-skill-read.md | 14 - .../memory-persistence-placeholder-refused.md | 55 - .../meta-read-path-credential-redaction.md | 50 - .changeset/meta-unrecognised-type-refused.md | 149 --- .../metadata-422-container-issue-descent.md | 60 -- ...etadata-plugin-additional-types-retired.md | 62 -- .changeset/mighty-rocks-jump.md | 11 - .changeset/mysql-dsn-bound-secret.md | 67 -- ...ysql-unbacked-conflict-target-preflight.md | 68 -- .../mysql-upsert-ambiguous-conflict-target.md | 29 - .../package-delete-driver-fault-status.md | 56 -- ...ganization-audience-binding-suggestions.md | 29 - .changeset/plump-crabs-sneeze.md | 17 - .changeset/preflight-refusal-audit-row.md | 29 - .changeset/publish-meta-canonical-fold.md | 79 -- ...reaper-verifies-repoint-before-deleting.md | 45 - ...record-change-reentrant-start-condition.md | 63 -- ...rd-chatter-position-renderer-vocabulary.md | 45 - .changeset/rest-meta-write-org-scope.md | 36 - .changeset/retire-remote-template-catalog.md | 23 - .changeset/rollback-canonical-type-fold.md | 62 -- ...ublish-drafts-flip-announce-driver-text.md | 45 - .../searchable-fields-anchor-provenance.md | 53 - .../searchall-title-canonical-namefield.md | 36 - .changeset/seed-tenancy-autonumber-split.md | 29 - .changeset/serve-multi-node-cap-advisory.md | 33 - .../sharing-read-merge-provenance-mark.md | 55 - .../sso-provider-map-id-param-retired.md | 58 -- .changeset/sys-email-headers-internal.md | 14 - .changeset/sys-job-global-unique-scope.md | 11 - ...osition-bundle-locales-per-organization.md | 14 - .../sys-setting-null-safe-row-identity.md | 71 -- .../system-overview-by-action-title-parity.md | 43 - ...overview-permission-change-tile-removed.md | 61 -- .changeset/system-write-organization-stamp.md | 79 -- .changeset/tall-jars-invent.md | 18 - ...filter-comparand-refused-at-engine-door.md | 47 - .../tenancy-organization-field-stamp-only.md | 42 - .changeset/tenant-index-follows-the-wall.md | 49 - .changeset/tough-jars-invite.md | 16 - .../translation-staleness-source-hash.md | 49 - .../ui-record-blocks-unknown-keys-refused.md | 90 -- .../ui-reference-rail-unknown-keys-refused.md | 74 -- .changeset/undeclared-field-preflight.md | 11 - .changeset/undeclared-update-field-door.md | 11 - ...nion-branch-policy-cross-package-parity.md | 41 - .../union-branch-policy-one-implementation.md | 47 - ...-violation-absence-sentence-superstring.md | 67 -- .changeset/upsert-id-insert-only.md | 52 - .../webhook-headers-secret-shape-gate.md | 75 -- examples/app-crm/CHANGELOG.md | 29 + examples/app-crm/package.json | 2 +- examples/app-showcase/CHANGELOG.md | 44 + examples/app-showcase/package.json | 2 +- examples/app-todo/CHANGELOG.md | 42 + examples/app-todo/package.json | 2 +- examples/embed-objectql/CHANGELOG.md | 26 + examples/embed-objectql/package.json | 2 +- packages/adapters/hono/CHANGELOG.md | 17 + packages/adapters/hono/package.json | 2 +- packages/apps/account/CHANGELOG.md | 29 + packages/apps/account/package.json | 2 +- packages/apps/setup/CHANGELOG.md | 29 + packages/apps/setup/package.json | 2 +- packages/apps/studio/CHANGELOG.md | 29 + packages/apps/studio/package.json | 2 +- packages/cli/CHANGELOG.md | 176 +++- packages/cli/package.json | 2 +- packages/client-react/CHANGELOG.md | 28 + packages/client-react/package.json | 2 +- packages/client/CHANGELOG.md | 40 +- packages/client/package.json | 2 +- packages/cloud-connection/CHANGELOG.md | 83 ++ packages/cloud-connection/package.json | 2 +- .../connectors/connector-mcp/CHANGELOG.md | 26 + .../connectors/connector-mcp/package.json | 2 +- .../connectors/connector-openapi/CHANGELOG.md | 26 + .../connectors/connector-openapi/package.json | 2 +- .../connectors/connector-rest/CHANGELOG.md | 26 + .../connectors/connector-rest/package.json | 2 +- .../connectors/connector-slack/CHANGELOG.md | 26 + .../connectors/connector-slack/package.json | 2 +- packages/console/CHANGELOG.md | 2 + packages/console/package.json | 2 +- packages/core/CHANGELOG.md | 330 ++++++ packages/core/package.json | 2 +- packages/create-objectstack/CHANGELOG.md | 24 + packages/create-objectstack/package.json | 2 +- packages/drivers/driver-memory/CHANGELOG.md | 32 +- packages/drivers/driver-memory/package.json | 2 +- packages/drivers/driver-mongodb/CHANGELOG.md | 32 +- packages/drivers/driver-mongodb/package.json | 2 +- packages/drivers/driver-sql/CHANGELOG.md | 282 +++++- packages/drivers/driver-sql/package.json | 2 +- .../drivers/driver-sqlite-wasm/CHANGELOG.md | 36 +- .../drivers/driver-sqlite-wasm/package.json | 2 +- packages/drivers/driver-turso/CHANGELOG.md | 95 +- packages/drivers/driver-turso/package.json | 2 +- packages/formula/CHANGELOG.md | 20 + packages/formula/package.json | 2 +- packages/lint/CHANGELOG.md | 84 +- packages/lint/package.json | 2 +- packages/mcp/CHANGELOG.md | 93 ++ packages/mcp/package.json | 2 +- packages/metadata-core/CHANGELOG.md | 76 ++ packages/metadata-core/package.json | 2 +- packages/metadata-fs/CHANGELOG.md | 8 + packages/metadata-fs/package.json | 2 +- packages/metadata-protocol/CHANGELOG.md | 844 +++++++++++++++- packages/metadata-protocol/package.json | 2 +- packages/metadata/CHANGELOG.md | 40 + packages/metadata/package.json | 2 +- packages/objectql/CHANGELOG.md | 241 ++++- packages/objectql/package.json | 2 +- packages/observability/CHANGELOG.md | 20 + packages/observability/package.json | 2 +- packages/platform-objects/CHANGELOG.md | 416 +++++++- packages/platform-objects/package.json | 2 +- packages/plugins/embedder-openai/CHANGELOG.md | 20 + packages/plugins/embedder-openai/package.json | 2 +- .../plugins/knowledge-memory/CHANGELOG.md | 27 + .../plugins/knowledge-memory/package.json | 2 +- .../plugins/knowledge-ragflow/CHANGELOG.md | 27 + .../plugins/knowledge-ragflow/package.json | 2 +- .../plugins/plugin-approvals/CHANGELOG.md | 95 ++ .../plugins/plugin-approvals/package.json | 2 +- packages/plugins/plugin-audit/CHANGELOG.md | 119 +++ packages/plugins/plugin-audit/package.json | 2 +- packages/plugins/plugin-auth/CHANGELOG.md | 363 +++++++ packages/plugins/plugin-auth/package.json | 2 +- packages/plugins/plugin-dev/CHANGELOG.md | 65 ++ packages/plugins/plugin-dev/package.json | 2 +- packages/plugins/plugin-email/CHANGELOG.md | 45 + packages/plugins/plugin-email/package.json | 2 +- .../plugins/plugin-hono-server/CHANGELOG.md | 29 + .../plugins/plugin-hono-server/package.json | 2 +- .../plugins/plugin-pinyin-search/CHANGELOG.md | 18 + .../plugins/plugin-pinyin-search/package.json | 2 +- packages/plugins/plugin-reports/CHANGELOG.md | 34 + packages/plugins/plugin-reports/package.json | 2 +- packages/plugins/plugin-security/CHANGELOG.md | 544 ++++++++++ packages/plugins/plugin-security/package.json | 2 +- packages/plugins/plugin-sharing/CHANGELOG.md | 220 +++- packages/plugins/plugin-sharing/package.json | 2 +- packages/plugins/plugin-webhooks/CHANGELOG.md | 99 ++ packages/plugins/plugin-webhooks/package.json | 2 +- packages/qa/dogfood/CHANGELOG.md | 75 ++ packages/qa/dogfood/package.json | 2 +- packages/qa/downstream-contract/CHANGELOG.md | 20 + packages/qa/downstream-contract/package.json | 2 +- packages/qa/http-conformance/CHANGELOG.md | 11 + packages/qa/http-conformance/package.json | 2 +- packages/rest/CHANGELOG.md | 318 +++++- packages/rest/package.json | 2 +- packages/runtime/CHANGELOG.md | 412 ++++++++ packages/runtime/package.json | 2 +- packages/sdui-parser/CHANGELOG.md | 2 + packages/sdui-parser/package.json | 2 +- .../services/service-analytics/CHANGELOG.md | 82 +- .../services/service-analytics/package.json | 2 +- .../services/service-automation/CHANGELOG.md | 134 +++ .../services/service-automation/package.json | 2 +- packages/services/service-cache/CHANGELOG.md | 27 + packages/services/service-cache/package.json | 2 +- .../service-cluster-redis/CHANGELOG.md | 21 + .../service-cluster-redis/package.json | 2 +- .../services/service-cluster/CHANGELOG.md | 26 + .../services/service-cluster/package.json | 2 +- .../services/service-datasource/CHANGELOG.md | 254 +++++ .../services/service-datasource/package.json | 2 +- packages/services/service-i18n/CHANGELOG.md | 28 + packages/services/service-i18n/package.json | 2 +- packages/services/service-job/CHANGELOG.md | 34 + packages/services/service-job/package.json | 2 +- .../services/service-knowledge/CHANGELOG.md | 26 + .../services/service-knowledge/package.json | 2 +- .../services/service-messaging/CHANGELOG.md | 36 + .../services/service-messaging/package.json | 2 +- .../services/service-package/CHANGELOG.md | 80 ++ .../services/service-package/package.json | 2 +- packages/services/service-queue/CHANGELOG.md | 34 + packages/services/service-queue/package.json | 2 +- .../services/service-realtime/CHANGELOG.md | 34 + .../services/service-realtime/package.json | 2 +- .../services/service-settings/CHANGELOG.md | 78 ++ .../services/service-settings/package.json | 2 +- packages/services/service-sms/CHANGELOG.md | 29 + packages/services/service-sms/package.json | 2 +- .../services/service-storage/CHANGELOG.md | 37 + .../services/service-storage/package.json | 2 +- packages/spec/CHANGELOG.md | 943 +++++++++++++++++- packages/spec/package.json | 2 +- packages/triggers/trigger-api/CHANGELOG.md | 26 + packages/triggers/trigger-api/package.json | 2 +- .../trigger-record-change/CHANGELOG.md | 26 + .../trigger-record-change/package.json | 2 +- .../triggers/trigger-schedule/CHANGELOG.md | 26 + .../triggers/trigger-schedule/package.json | 2 +- packages/types/CHANGELOG.md | 84 ++ packages/types/package.json | 2 +- packages/verify/CHANGELOG.md | 79 ++ packages/verify/package.json | 2 +- 240 files changed, 8071 insertions(+), 4330 deletions(-) delete mode 100644 .changeset/account-oauth-tokens-internal.md delete mode 100644 .changeset/admin-export-wildcard-removed.md delete mode 100644 .changeset/analytics-authorable-unknown-keys-refused.md delete mode 100644 .changeset/anchor-missing-relation-quoted-template.md delete mode 100644 .changeset/api-key-carries-organization.md delete mode 100644 .changeset/approvals-record-reader-visibility.md delete mode 100644 .changeset/audit-meta-item-organization-scope.md delete mode 100644 .changeset/audit-row-record-organization-stamp.md delete mode 100644 .changeset/capability-class-name-identity-enforced.md delete mode 100644 .changeset/cbp-master-editability-authored-widener.md delete mode 100644 .changeset/cbp-master-leg-ownership-floor.md delete mode 100644 .changeset/cbp-missing-master-insert-validation-envelope.md delete mode 100644 .changeset/client-explain-recordids-batch-spelling.md delete mode 100644 .changeset/cloud-arm-host-marketplace-precedence.md delete mode 100644 .changeset/dashboard-dataset-publish-gate.md delete mode 100644 .changeset/datasource-config-url-query-credential-refused.md delete mode 100644 .changeset/datasource-credential-rehoming.md delete mode 100644 .changeset/deactivated-position-stops-sharing.md delete mode 100644 .changeset/derived-capability-unseeded-bucket-warned.md delete mode 100644 .changeset/diagnostics-store-outage-503.md delete mode 100644 .changeset/diff-dead-history-read.md delete mode 100644 .changeset/diff-meta-item-canonical-type-and-history-outage.md delete mode 100644 .changeset/dispatcher-meta-put-falsy-body-refused.md delete mode 100644 .changeset/eighty-pandas-shake.md delete mode 100644 .changeset/enforce-active-on-grant-catalogues.md delete mode 100644 .changeset/es-es-position-rename-damage.md delete mode 100644 .changeset/export-filename-business-timezone.md delete mode 100644 .changeset/field-scale-precision-integer-refused.md delete mode 100644 .changeset/formula-filter-refusal-adr-0087-entry.md delete mode 100644 .changeset/hungry-donkeys-shout.md delete mode 100644 .changeset/import-naive-datetime-business-timezone.md delete mode 100644 .changeset/invalid-filter-target-field-provenance.md delete mode 100644 .changeset/ja-jp-position-rename-damage.md delete mode 100644 .changeset/last-admin-standing-keys-gate.md delete mode 100644 .changeset/legacy-unique-guard-attribution.md delete mode 100644 .changeset/list-diagnosed-consumer-sweep.md delete mode 100644 .changeset/lucky-pugs-repeat.md delete mode 100644 .changeset/mcp-http-bridge-merged-skill-read.md delete mode 100644 .changeset/mcp-prompt-bridge-merged-skill-read.md delete mode 100644 .changeset/memory-persistence-placeholder-refused.md delete mode 100644 .changeset/meta-read-path-credential-redaction.md delete mode 100644 .changeset/meta-unrecognised-type-refused.md delete mode 100644 .changeset/metadata-422-container-issue-descent.md delete mode 100644 .changeset/metadata-plugin-additional-types-retired.md delete mode 100644 .changeset/mighty-rocks-jump.md delete mode 100644 .changeset/mysql-dsn-bound-secret.md delete mode 100644 .changeset/mysql-unbacked-conflict-target-preflight.md delete mode 100644 .changeset/mysql-upsert-ambiguous-conflict-target.md delete mode 100644 .changeset/package-delete-driver-fault-status.md delete mode 100644 .changeset/per-organization-audience-binding-suggestions.md delete mode 100644 .changeset/plump-crabs-sneeze.md delete mode 100644 .changeset/preflight-refusal-audit-row.md delete mode 100644 .changeset/publish-meta-canonical-fold.md delete mode 100644 .changeset/reaper-verifies-repoint-before-deleting.md delete mode 100644 .changeset/record-change-reentrant-start-condition.md delete mode 100644 .changeset/record-chatter-position-renderer-vocabulary.md delete mode 100644 .changeset/rest-meta-write-org-scope.md delete mode 100644 .changeset/retire-remote-template-catalog.md delete mode 100644 .changeset/rollback-canonical-type-fold.md delete mode 100644 .changeset/runtime-publish-drafts-flip-announce-driver-text.md delete mode 100644 .changeset/searchable-fields-anchor-provenance.md delete mode 100644 .changeset/searchall-title-canonical-namefield.md delete mode 100644 .changeset/seed-tenancy-autonumber-split.md delete mode 100644 .changeset/serve-multi-node-cap-advisory.md delete mode 100644 .changeset/sharing-read-merge-provenance-mark.md delete mode 100644 .changeset/sso-provider-map-id-param-retired.md delete mode 100644 .changeset/sys-email-headers-internal.md delete mode 100644 .changeset/sys-job-global-unique-scope.md delete mode 100644 .changeset/sys-position-bundle-locales-per-organization.md delete mode 100644 .changeset/sys-setting-null-safe-row-identity.md delete mode 100644 .changeset/system-overview-by-action-title-parity.md delete mode 100644 .changeset/system-overview-permission-change-tile-removed.md delete mode 100644 .changeset/system-write-organization-stamp.md delete mode 100644 .changeset/tall-jars-invent.md delete mode 100644 .changeset/temporal-filter-comparand-refused-at-engine-door.md delete mode 100644 .changeset/tenancy-organization-field-stamp-only.md delete mode 100644 .changeset/tenant-index-follows-the-wall.md delete mode 100644 .changeset/tough-jars-invite.md delete mode 100644 .changeset/translation-staleness-source-hash.md delete mode 100644 .changeset/ui-record-blocks-unknown-keys-refused.md delete mode 100644 .changeset/ui-reference-rail-unknown-keys-refused.md delete mode 100644 .changeset/undeclared-field-preflight.md delete mode 100644 .changeset/undeclared-update-field-door.md delete mode 100644 .changeset/union-branch-policy-cross-package-parity.md delete mode 100644 .changeset/union-branch-policy-one-implementation.md delete mode 100644 .changeset/unique-violation-absence-sentence-superstring.md delete mode 100644 .changeset/upsert-id-insert-only.md delete mode 100644 .changeset/webhook-headers-secret-shape-gate.md diff --git a/.changeset/account-oauth-tokens-internal.md b/.changeset/account-oauth-tokens-internal.md deleted file mode 100644 index 0a0c76fe99..0000000000 --- a/.changeset/account-oauth-tokens-internal.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -"@objectstack/platform-objects": patch -"@objectstack/plugin-auth": patch ---- - -fix(security): `sys_account`'s OAuth access/refresh/id tokens stop serializing on the data API — `internal: true`, with better-auth's readback seam widened to cover them (#7987) - - - -`sys_account.access_token`, `.refresh_token` and `.id_token` hold each user's -**live third-party OAuth credentials** — the tokens ObjectStack received from -Google, GitHub or an OIDC IdP — in cleartext (better-auth's -`account.encryptOAuthTokens` is not set, so `setTokenUtil` stores them -verbatim). They were plain `Field.textarea` on an object declaring -`apiEnabled: true, apiMethods: ['get','list']`. - -**Both personas were measured leaking, on a real booted stack** (`bootStack(showcaseStack)`, -in-process HTTP + sqlite-wasm), with a planted token on a member's account row: - -- **admin**, `GET /data/sys_account/{another user's account id}` — 200, that - member's `refresh_token` verbatim, plus `access_token` and `id_token`; -- **member**, `GET /data/sys_account` (self-scoped by the `sys_account_self` RLS - policy) — 200, their **own** `refresh_token` verbatim. - -The member arm is the one this object does not share with its `sys_session` -sibling (#7823), and it is the sharper of the two: it converts a short-lived, -revocable ObjectStack session bearer into a **long-lived third-party refresh -token that this platform cannot revoke at all**. Neither collector reached these -columns — the engine's credential mask collects by field TYPE (`textarea` is -neither `secret` nor `password`) *and* exempts objects with -`managedBy: 'better-auth'`, which this object is. - -**The fix is three declarations plus one widening**, inheriting #7823's shape -rather than inventing a second mechanism: - -- the three columns are declared `internal: true` — the opt-in, type-independent - flag minted by #7728 meaning *the declared value is never returned on the - generic data path*. Storage, filtering and indexing are untouched: the strip - runs on rows the driver has already produced. -- better-auth **reads these back off adapter result rows** — measured, and the - risk this card was parked on: `internalAdapter.findAccounts(userId)` issues a - `findMany` with no projection, and `/get-access-token`, `/account-info` and - `/refresh-token` then read `account.refreshToken` / `.accessToken` / - `.idToken` off those rows. The read strip alone would answer - `REFRESH_TOKEN_NOT_FOUND` (400) and hand back an empty access token. So the - existing readback seam in `@objectstack/plugin-auth` — which already recovered - `sys_session.token` through `Engine.resolveInternalField` (#8118's privileged - batch accessor) — is widened to cover these three columns and renamed - accordingly. No engine carve-out, no second accessor. - -**Not retyped, deliberately.** `Field.secret()` would route better-auth's own -writes through the engine's encrypt-on-write path, placing the engine between -better-auth and its own adapter. `Field.password()` is inert here for the two -reasons above. - -**`password` / `previous_password_hashes` are deliberately out of scope** — -they are better-auth one-way hashes (ADR-0100's third channel), not reversible -outbound credentials, and the readback seam refuses to touch them. - -The regression proof drives both directions: the fixture PLANTS real token -values and re-reads them out of storage through the privileged accessor before -asserting anything (so "absent from the response" cannot pass vacuously), then -pins that the values are still on disk, still usable as a server-side predicate, -and that password sign-in — which reads a `sys_account` row back through the -same seam on every request — still works. diff --git a/.changeset/admin-export-wildcard-removed.md b/.changeset/admin-export-wildcard-removed.md deleted file mode 100644 index 261554017f..0000000000 --- a/.changeset/admin-export-wildcard-removed.md +++ /dev/null @@ -1,80 +0,0 @@ ---- -"@objectstack/plugin-security": minor -"@objectstack/spec": minor ---- - -fix(security): the shipped admin permission sets no longer grant export on the `*` wildcard (#8681) - - - -**BREAKING for any deployment whose administrators export today.** Landing after -the v17.0.0 cut, so it ships as `minor` under the lockstep launch-window -convention; the migration prescription is registered under protocol major 18, -where `objectstack migrate meta` users will look. - -`admin_full_access`, `organization_admin` and the derived -`organization_admin_no_bypass` shipped `objects['*'].allowExport = true`. That -single line made the 17.0 export axis **undeniable** for anyone holding an admin -set: an application could declare an object exportable by nobody, ship it, and -the platform would export it anyway. - -Measured on 17.0.0 GA — 40 export probes, 5 principals, 8 objects, real Bearer -tokens — an org owner exported `crm_quote` (9 rows), `crm_campaign` (13) and -`crm_task` (15) with 200 and full data. No app permission set granted export on -any of the three, and the app had no way to say no: - -1. the wildcard lives in code-package metadata, so editing it answers - `403 [not_overridable] Metadata item 'permission/admin_full_access' is - provided by a code package`; -2. the org admin holds no app-authored permission set, so there is nowhere to - author the per-object `allowExport: false` that would otherwise have won. - -**This was never a gate defect.** The same run proves the export gate exact for -every other principal: a token refused on one object exports another on the same -route, granting `allowExport` at runtime flips 403 to 200, and revoking it flips -it back. A plain member carrying `'*': { allowExport: true }` exported too — the -wildcard was simply doing what it said. What changes is that the platform stops -shipping that grant. - -This is #5491 applied to the export axis. That change removed `member_default`'s -CRUD wildcard because a wildcard in a set every principal resolves is not a -default but a floor no app can get under; the export wildcard survived by -omission rather than by decision, one tier up. - -**Migration — grant `allowExport` explicitly in an app permission set where -admin export is intended.** There is no automatic replacement, deliberately: -which principals may take a bulk machine-readable copy of a table is the -segregation-of-duties judgement the axis exists to make explicit. - -```ts -// In YOUR app's permission set — not a platform set (those are not overridable). -{ - name: 'system_admin', - objects: { - crm_account: { allowRead: true, allowExport: true }, // export intended - crm_quote: { allowRead: true }, // export withheld - }, -} -``` - -⚠️ **Nothing fails at parse time, and the shipped sets are re-seeded on -upgrade.** A deployment that upgrades without editing anything is valid metadata -whose administrators have quietly lost export on every object no app set names — -the first sign is a support report, not an error. Verify behaviourally: sign in -as an org owner and call `GET /api/v1/data//export`, expecting 200 where -export is intended and 403 `EXPORT_NOT_PERMITTED` where it is not. - -**What is deliberately unchanged.** READ is untouched — an admin still sees -every record they saw before; this narrows bulk egress only. `allowExport` on a -`'*'` entry remains a supported, honoured authoring shape in an app's own sets. -Specific-over-wildcard precedence is unchanged (an explicit per-object entry -still overrides the wildcard). The `viewAllRecords` / `modifyAllRecords` -super-user bits still do not imply export, exactly as before. And an app's own -admin set already gets precisely its declared posture — declared `false` answers -403, declared `true` answers 200 — which is what makes withdrawing the platform -grant safe rather than merely restrictive. - -Both admin sets are fixed together, and the org-admin pair from one declaration -(`organization_admin_no_bypass` is derived from `organization_admin`). Fixing -one and not the other was rejected outright: a half-closed export boundary reads -as closed and is not. diff --git a/.changeset/analytics-authorable-unknown-keys-refused.md b/.changeset/analytics-authorable-unknown-keys-refused.md deleted file mode 100644 index d3c06af811..0000000000 --- a/.changeset/analytics-authorable-unknown-keys-refused.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): refuse undeclared keys on the analytics authoring surface (#4001 data batch D) - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -All 8 `data/analytics.zod.ts` sites are strict: the cube family (`CubeSchema` + -its `refreshKey` block, `MetricSchema` + its `filters[]` items, -`DimensionSchema`, `CubeJoinSchema`) and the query family -(`AnalyticsQuerySchema` + its `timeDimensions[]` items). Before this change an -undeclared key on any of them was silently dropped: a join authored with a -typo'd `relationship` registered with the `many_to_one` default — a different -join shape than the author declared — and a cube's misspelled key vanished -under a successful parse. - -The subtle half is the query: `/analytics/query`'s TOP level has been strict -since #3878 (`AnalyticsQueryRequestSchema`), but top-level strictness does not -recurse — measured on `main`, `timeDimensions: [{ dimension, granuarity: -'day' }]` rode through the strict wrapper with the typo silently stripped, so -the query bucketed the whole range as one group under an ordinary 200. The -nested item is now strict, and the base schema's own strictness makes the -posture hold at every door instead of only at the wrapper that re-applied it. - -**What is refused:** any key the shape does not declare, with a prescriptive -message — the surface, the offending key, and a rename (`title` → `label` on a -metric/dimension, `label` → `title` on the cube, `table`/`sqlTable` → `sql`, -`granularity` → `granularities` on a dimension and the reverse on a query time -dimension, `orderBy` → `order`; `filters` on a query gets the `where` -prescription matching the dispatcher's #3878 hint). - -**What stays accepted:** every declared key byte-identically, including the -`#3878` tombstones on the request wrapper (`query`/`format` still answer their -migration text). - -## FROM → TO - -```ts -// before — parsed green; the join fell back to many_to_one silently -defineCube({ - name: 'orders', sql: 'orders', - measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, - dimensions: {}, - joins: { customers: { name: 'customers', sql: 'a.id = b.a_id', relationshipp: 'one_to_many' } }, -}) - -// after — rejected with `relationshipp` → `relationship`; write the declared key -defineCube({ - name: 'orders', sql: 'orders', - measures: { revenue: { name: 'revenue', label: 'Revenue', type: 'sum', sql: 'amount' } }, - dimensions: {}, - joins: { customers: { name: 'customers', sql: 'a.id = b.a_id', relationship: 'one_to_many' } }, -}) -``` - -There is deliberately no automatic rewrite: an undeclared key is either a -spelling of a declared one (the rejection names the rename) or names a -capability the analytics layer does not deliver, and blessing it would be -declared-but-unenforced surface (ADR-0078). `os migrate meta` surfaces the -change as a structured TODO (semantic entry -`analytics-authorable-unknown-keys-refused`, protocol major 18 — this refusal -is not part of the v17.0.0 cut). - - diff --git a/.changeset/anchor-missing-relation-quoted-template.md b/.changeset/anchor-missing-relation-quoted-template.md deleted file mode 100644 index d6136e73ac..0000000000 --- a/.changeset/anchor-missing-relation-quoted-template.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): anchor `looksLikeMissingRelation` on the driver's quoted template (#8264) - -`mapDataError`'s Postgres limb read `relation` and `does not exist` anywhere in -the message, not necessarily the same sentence — so ordinary business prose -using both words (`This relation does not exist in the diagram`) matched. -`does not exist` is ordinary business English; #8132 already anchored the -shared `@objectstack/types` leak predicate on the driver's own quoted -template for exactly this reason, and pinned the identical string as a -negative case. This file's copy of the same question was not covered by that -change (different package, different call site) and kept the loose reading. - -Anchored the same way here — a quoted identifier required between `relation` -and `does not exist` — as a locally-owned pattern rather than a call into the -shared leak predicate: that -predicate answers a different question ("may this be withheld from the -client"), and its other limbs (`sqlite_`, `unique constraint`, `foreign key`, -a bare SQL statement) have nothing to do with this file's question (is this -specifically an unknown-relation condition, for the 404-vs-500 split -`looksLikeMissingRelation` feeds). `relation-sub-object.ts` documents "two -widths, on purpose" for a neighbouring pair of consumers that ask genuinely -different questions; that does not extend to the two USES inside this file, -which both ask the same question and share one predicate correctly. - -**Both of the predicate's two call sites are covered, not just the reported -one:** the `DATA_STORE_FAULT` (500) gate the issue named, and the -`looksLikeUnknownObject` (404) limb the issue's own text did not measure. A -business message no longer gets mislabelled a `DATABASE_ERROR`, and a -crafted unquoted-but-attributable message no longer gets silently answered -`OBJECT_NOT_FOUND` — both now fall through to the generic, still-sanitised -terminal fault, which is the direction the branch's own #5462 comment already -argues for ("the safe way to be wrong is loud"). - -No reachable production path producing the unanchored shape was found at this -call site — this is consistency/invariant restoration between two spellings -of one question, not a fix for a demonstrated live misclassification. diff --git a/.changeset/api-key-carries-organization.md b/.changeset/api-key-carries-organization.md deleted file mode 100644 index 2f130459b5..0000000000 --- a/.changeset/api-key-carries-organization.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -"@objectstack/platform-objects": minor -"@objectstack/plugin-auth": minor -"@objectstack/core": minor -"@objectstack/runtime": minor ---- - -feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287) - - - -On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could -read **nothing at all**. `sys_api_key` carried no organization column, so key -authentication established a user but no active organization — and the -`isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with -no active organization matches no row. Every organization-scoped read answered -`200` with `total 0` while the console went on offering minting, so a tenant -admin could mint a valid-looking secret and discover only at call time that it -read nothing. (There was no cross-tenant leak — the failure was in the other -direction.) - -**The column was absent by an inherited rule, not by oversight.** -`resolveInjectedSystemColumns` injects `organization_id` into every registered -object *except* `managedBy: 'better-auth'` ones, and `sys_api_key` carries that -flag — even though better-auth's `apiKey` plugin is not loaded and the table is -hand-rolled ObjectStack. So the fix needs the declaration *and* the ADR-0105 D7 -extension-field registration to stay consistent. The read side, by contrast, -was **already wired**: `resolveApiKeyPrincipal` already read an organization -into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a -column no mint path ever wrote. - -**What changes** - -- `sys_api_key` declares `active_organization_id` (+ index, and the column is - shown in the "My Keys" and "All" list views, because the card's complaint was - a credential whose reach its owner could not see). -- `POST /api/v1/keys` **inherits** the caller's active organization — there is - deliberately no org parameter and no cross-org key — and **re-checks the - caller's `sys_member` membership at mint time**, honouring ADR-0091 validity - windows. Under a walled posture it refuses (400) rather than minting a key - with no organization, and refuses (403) for an organization the caller is not - a member of. The mint response echoes the organization the key is pinned to. -- The verifier reads **one spelling** (Prime Directive #12): the - `row.organization_id ?? row.organizationId` chain it used to carry was a - consumer-side tolerance for a producer that did not exist. -- An **ex-member's key fails closed at verify time** — no principal, not a - degrade to a user-only principal, which would resurrect the same - `200 + total 0` silent-empty. Checked at verify rather than by revoking on - membership loss, because membership ends through many paths (better-auth org - endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and - a hook must catch every one or it silently misses. It costs **zero extra - queries**: the resolver has already read `sys_member` for this user. -- **Pre-existing org-less keys are never backfilled** — that would silently - upgrade credentials minted under a different promise. They keep working under - `single` (no wall) and under `group` (whose wall derives from the owner's - memberships independently of the active organization, so they already work - there), and are **refused under `isolated`**, where they are provably dead - today. - -**The column is deliberately named `active_organization_id`, not -`organization_id`** — the `sys_session` spelling, for the same concept: the -organization a credential makes *active*. `objectHasOrgIdField` tests for the -literal `organization_id`, and Layer 0 exempts objects without it, so the other -name would have made `sys_api_key` itself org-walled. Both walled postures -exclude NULL, so every pre-existing org-less row would have vanished from its -**own owner's** "My Keys" list while, under `group`, continuing to -authenticate — a live credential nobody could see or revoke, which is a fresh -instance of the very class this change removes. diff --git a/.changeset/approvals-record-reader-visibility.md b/.changeset/approvals-record-reader-visibility.md deleted file mode 100644 index 7030ca42a7..0000000000 --- a/.changeset/approvals-record-reader-visibility.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@objectstack/plugin-approvals": minor ---- - -feat(approvals): read-only approval visibility for users who can read the target record, per object, default OFF (#8652) - -A new `ApprovalsPluginOptions.recordReaderVisibleObjects` names the objects on -which **a user who can READ a business record may also see that record's -approval requests and full action history** — read-only. Omitted or empty (the -default) leaves visibility exactly as it is today, so an existing deployment -sees no behaviour change on upgrade. **This is not a no-op change**: on an -object you list, a population that could previously see nothing gains a real -read. - -```ts -new ApprovalsServicePlugin({ recordReaderVisibleObjects: ['exam_sheet'] }) -``` - -**Who gains visibility.** Until now the visible set was submitter ∪ current -approver ∪ historical actor, with a platform/tenant admin override as the only -bypass — so a ledger or supervisor role that holds full read on the record but -never appears in the approval itself received `200` with an empty list, and the -Console's approval tab never rendered. On an enabled object, that role now sees -the record's approvals. - -**What becomes visible on an enabled object**, stated plainly because the switch -is an opt-in decision about confidentiality: - -- the approval request row, including its `payload` snapshot of the record as it - stood at submission time; -- the full action history — each actor, their decision, the timestamp, **and the - action's comment text** (意见正文); -- decision attachments on those actions, which are gated on the same rule. - -Enable it on objects whose approval commentary the record's readers are meant to -see; the comment text is often evaluative, and it is per object precisely so -that enabling it for a ledger object does not enable it for anything else. - -**What does NOT change.** - -- **Read-only.** No approval action is delivered through this tier. Approve, - reject, reassign, recall and comment keep authorizing exactly as before — on - the pending-approver slate, the submitter, or admin override — and a viewer - admitted by this tier gets `can_act: false`. Seeing a request confers nothing. -- **No new permission concept.** The tier is anchored on the existing - record-read permission: the service asks the engine to read the record **as - the caller**, so ordinary object CRUD and RLS decide. No new role, grant type - or policy, and no host-injected visibility hook — a security predicate the - platform can neither constrain nor audit was considered and rejected. -- **The inbox.** An untargeted list is unchanged. The rule is anchored on one - record, so it applies only where a record is named — a list filtered by - `object` + `recordId` (what a record page's approval tab sends), or a request - loaded by id. A work queue does not become a browse surface. -- **Tenant isolation, and everything else about the existing visible set.** The - tier only ever adds ids to the participant set; it can never return the "sees - everything" verdict and never relaxes an existing constraint. diff --git a/.changeset/audit-meta-item-organization-scope.md b/.changeset/audit-meta-item-organization-scope.md deleted file mode 100644 index 13466964b3..0000000000 --- a/.changeset/audit-meta-item-organization-scope.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch -"@objectstack/rest": patch ---- - -fix(metadata-protocol): scope the metadata audit read to the caller's organization (#8747) - -`ObjectStackProtocolImplementation.auditMetaItem` declared -`organizationId?: string | null` and never read it. The comment directly above -its query described the filter it would have built — "include rows for the -specific org AND env-wide (`organization_id IS NULL`) rows" — while the `where` -was exactly `{ type, name }`. The parameter was dead on the caller side too: -`GET /api/v1/meta/:type/:name/audit` never passed one. - -The consequence was a cross-tenant disclosure, measured rather than inferred: -three saves of one view name under two organizations and env-wide, then one -`auditMetaItem({ type, name })` read, returned all three organizations' rows — -and with each row its `actor`, `note`, `lock_state`, `code`, `operation`, -`source` and `request_id`. Nothing compensated lower down. The driver's tenant -wall never engaged, because it is armed only from an execution context this -read did not pass; the security plugin's Layer 0 never engaged, because the -middleware short-circuits on a principal-less call long before the field gate -that would have carried it; and no tenancy posture would have supplied the -scope either. The route carries no capability gate — unlike its `PUT` twin, -which gates on `manage_metadata` — so the reachable cohort was any -authenticated principal of any tenant, on the published `meta.getAudit` SDK -surface. - -The query now builds the described filter: rows for the caller's organization -plus env-wide (`organization_id IS NULL`) rows, and nothing else. The env-wide -limb is load-bearing rather than defensive — the REST `PUT /meta/:type/:name` -door passes no organization, so every row it writes is stamped -`organization_id: null`, and an equality-only filter would have blanked the -audit tab on those deployments instead of scoping it. A read that resolves no -organization is fail-closed onto the env-wide rows, symmetric with what an -org-less write produces, so omitting the parameter is no longer a skeleton key. - -The REST route supplies the organization from the execution context it already -resolves for 40-plus handlers, adding no new organization-resolution plumbing -to `packages/rest`. The same call also stopped passing `environmentId`, which -the request type never declared and the method body never read; environment -scoping is unaffected, since it comes from which protocol instance is resolved -rather than from the request payload. - -Behaviour change worth stating plainly: a caller that previously saw another -tenant's metadata audit rows for a same-named item no longer sees them. Own-org -and env-wide rows are unchanged. diff --git a/.changeset/audit-row-record-organization-stamp.md b/.changeset/audit-row-record-organization-stamp.md deleted file mode 100644 index 65fbda0f8a..0000000000 --- a/.changeset/audit-row-record-organization-stamp.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/plugin-audit": patch ---- - -fix(audit): audit rows are stamped from the record's own organization, not the actor's active one (#8707) - -`sys_audit_log` / `sys_activity` rows took their organization from -`sess.tenantId ?? recordOrgId` — the ACTING session's active organization in -preference to the organization of the record the row is about. A write -performed from a session whose active organization differs from the record's -therefore landed the audit row behind the wrong tenant's wall: unreadable to -the tenant admin it concerns, and readable by an organization with no claim to -the record. That is the invisible-audit-row defect the record-side fallback was -added to prevent, one layer down, and the maintainer's ruling on #8287 settles -it the other way — the stamp comes from the row's own organization. - -The precedence is now `recordOrgId ?? sess.tenantId`. The RLS fallback is -preserved unchanged: an audit row must never be written with a NULL -organization, so the acting session's tenant still answers whenever the record -has no organization of its own (single-tenant stacks, platform-global objects, -a NULL column), and the record's organization still answers on the two cases -the fallback was written for — background/sudo paths with no `tenantId`, and -better-auth's `activeOrganizationId` cache miss right after sign-in. - -Which column carries a record's organization is now resolved from the -REGISTERED SCHEMA rather than the hard-coded `organization_id` literal, with -the same precedence `SqlDriver.computeTenantField` already applies: an ADR-0066 -`tenancy.enabled: false` opt-out resolves to no organization at all (so a -platform-global object's audit trail is not scoped into one tenant and hidden -from the platform admin who acted), then a declared `tenancy.tenantField` when -the object really has that field, then the canonical injected -`organization_id`. - -Most deployments see no change: under the `isolated` posture the Layer 0 wall -makes a cross-organization write of a walled object impossible, so the two -sides agree by construction. The behaviour changes under the `group` and -`shared` postures, and on system paths that write another organization's row -while carrying a session. - -Not addressed here: `sys_api_key.active_organization_id` is still not -reachable by this resolver, so revocation rows on that object continue to fall -back to the actor's organization. Its column is deliberately not the object's -tenant-scope column and must not become one, so closing that half needs a -read-neutral, stamp-only organization declaration in `packages/spec`. #8707 -remains open for it. diff --git a/.changeset/capability-class-name-identity-enforced.md b/.changeset/capability-class-name-identity-enforced.md deleted file mode 100644 index 10fe7eaddc..0000000000 --- a/.changeset/capability-class-name-identity-enforced.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/cloud-connection": patch -"@objectstack/service-automation": patch ---- - -fix(cloud-connection,service-automation): stop two plugin classes renaming themselves in the shipped build, and enforce the class-name identity limb against `Ctor.name` (#8645) - -`Serve.providesCapability` (`packages/cli/src/commands/serve.ts`) decides whether a -host already supplied a capability's provider by comparing, by equality, both a -loaded plugin's `name` and its `constructor.name` against a declared identity -list. Every identity registry in that file therefore declares two spellings per -provider — the registered `plugin.name` id and the exported class name — and the -class-name spelling is a claim about the **built** artifact. - -**Measured against the built packages, two of the 27 declared class-name -identities matched nothing at all:** - -``` -MISMATCH CAPABILITY_PROVIDERS.automation declared=AutomationServicePlugin runtime=_AutomationServicePlugin -MISMATCH Serve.MARKETPLACE_PROXY_IDENTITIES declared=MarketplaceProxyPlugin runtime=_MarketplaceProxyPlugin -``` - -Both classes referenced themselves **by name inside their own body** — -`MarketplaceProxyPlugin.prototype.version` building the outbound proxy -User-Agent, and a `private static` backoff helper called from an instance method -in the automation plugin. esbuild rewrites such a class into -`var X = class _X { … _X … }` so the inner reference binds to the class binding -rather than the outer `var`, and the emitted class reports `_X` as its `.name`. - -There was no user-visible impact, because every guard naming these plugins also -declares the registered id, which the instance carries as a plain field no -bundler touches. What was dead is the **redundancy**: a guard running on one -limb it does not know it is running on is one rename away from failing open — -and failing open here means silently mounting a second instance over a host's -own. - -Both source idioms are replaced with module-scope declarations, so the shipped -classes keep their names. The marketplace proxy's self-reference was also -reading a field that was never there (`version` is an instance field, so -`prototype.version` was always `undefined`): its outbound `User-Agent` announced -the `?? '1.0.0'` fallback on every request and now announces the plugin's real -version, `1.1.0`. - -The enforcement half lives in `packages/cli/test/serve-capability-identity.test.ts`: -every declared class-name identity, across `CAPABILITY_PROVIDERS` and the four -marketplace identity lists, is now compared to the runtime `Ctor.name` of the -export it names, and must satisfy `providesCapability` through the class-name -limb alone. The `*_IDENTITIES` statics are re-derived from `Serve` itself, so a -fifth list cannot be added without being enumerated. #8357's local -"modulo one leading underscore" accommodation is retired rather than left as a -third spelling of the same rule. diff --git a/.changeset/cbp-master-editability-authored-widener.md b/.changeset/cbp-master-editability-authored-widener.md deleted file mode 100644 index bbd90a4579..0000000000 --- a/.changeset/cbp-master-editability-authored-widener.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(security): the `controlled_by_parent` master-editability check consults the same app-authored write widener the by-id path does (#8679) - - - -`crm_campaign_member`-shaped objects — ADR-0055 `controlled_by_parent` details — -route every insert/update/delete through `assertControlledByParentWrite`, which -asks whether the caller may EDIT the master. That gate's record-sharing leg -hard-refused on `canEdit === false` **without ever asking whether an app-authored -RLS update-widener admits the master row**. The by-id write path has asked -exactly that since #5493 (merged as PR #6909), where the deferral was installed -on the sharing middleware's refusal branch. - -So one principal, one master record and one operation got **two different -answers depending on who was asking** — measured on 17.0.0 GA with real Bearer -tokens, one variable (who created the master), everything else identical: - -| step | master created by ADMIN | master created by the caller | -|---|---|---| -| PATCH the master itself, by id | **200** | 200 | -| INSERT a child | **403** | 201 | -| UPDATE a child | **403** | 200 | -| `security/explain` update on the master, record-scoped | **`allowed=true`** | `allowed=true` | - -The master write and the platform's own `explain` verdict both said yes; only the -derived write disagreed, refusing with `master '...' not editable by this user -(record sharing)` — naming the very layer #6909 had already taught to defer. - -**The fix consults the same composition, and does not relax the check.** The -verdict comes from `checkAuthoredRowWrite` — the method -`SharingService.probeAuthoredRowWrite` passes straight through to — so the answer -at this call site is byte-for-byte the one a direct by-id write of that master -would get. There is no second copy to drift, which matters because a duplicated -permission composition is how the two paths diverged. The question is asked for -`update`, matching the two legs already above it: this gate's subject is edit -access to the master, never the detail's own verb. - -Nothing else widens. The object-level `update` grant and the master's own -write-RLS leg run first and still refuse on their own terms; `admit` retracts -only the record-sharing leg's refusal, exactly as an `admit` on the by-id path -hands the row to the pre-image gate rather than authorizing anything. Every other -outcome — `abstain`, no authored policy, a `check`-only policy, a principal-less -or delegated context, a throwing probe — leaves the refusal untouched, and the -method is fail-closed in the `abstain` direction, so no failure mode here can -open access. - -The regression proof drives both directions on one fixture and refuses to be -satisfiable by a relaxation: the RLS-widened master **permits** the derived write -**and** a principal with no widener and no share is still refused on the same -route with the same payload. A transferred master (write RLS admits via the -platform floor, record sharing refuses because the owner is someone else) keeps -the record-sharing leg itself pinned live — deleting that leg outright would -otherwise leave the suite green — with an `edit`-level share admitting the same -row and a `read`-level share still refusing it. diff --git a/.changeset/cbp-master-leg-ownership-floor.md b/.changeset/cbp-master-leg-ownership-floor.md deleted file mode 100644 index 121327dff1..0000000000 --- a/.changeset/cbp-master-leg-ownership-floor.md +++ /dev/null @@ -1,83 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(security): `controlled_by_parent` detail writes compose the master's ownership floor the same way a direct write does (#8865) - -**This change widens a permission boundary, deliberately and with maintainer -approval (ruling of 2026-08-15, direction 1): children of a master become -writable by every principal whose record-sharing verdict on that master is -`allow`.** That is the same set which already reaches the master itself — the -widening restores a symmetry the platform declares, it does not mint a new -capability — but it is a real widening and it is stated here rather than -softened. - -## What was measured - -`assertControlledByParentWrite` (ADR-0055, step 2.8) resolves master-edit access -in two legs. Leg 1 — the master's own write RLS — computed -`computeRlsFilter(master, 'update')` with **no** `dropPlatformOwnershipFloor`, -while the by-id write pre-image gate (step 2.7) computes the same filter for the -same object with that knob set whenever `ISharingService` answers `allow`. So the -platform's ownership floor (`created_by == current_user.id`, shipped by -`member_default`) was dropped on the direct path and left standing on the derived -one, and one principal, one master row and one `update` got two answers: - -| step | verdict before | -|---|---| -| PATCH the master `camp_mkt` directly, by id | allowed | -| UPDATE a child of `camp_mkt` | `403 … requires edit access to its master record (master 'crm_campaign' not editable by this user (row-level security))` | - -The principal in that measurement holds `modifyAllRecords` on the master, which -is exactly what makes the sharing verdict `allow` and drops the floor on the -direct path; it did not create the master, so the undropped floor refused it on -the derived path. Every widening mechanism the platform declares — ownership at -write DEPTH, an `edit`-level `sys_record_share`, `modifyAllRecords` — was -therefore inert **for children** while it worked **for the master itself**. An -app author saw a master they could edit and children they could not. - -This is the divergence #8679 closed in leg 2 (record sharing), surviving one leg -over, and it is closed the same way: one principal, one row, one operation must -not get two answers. - -## The change - -Leg 1 adopts step 2.7's composition, clause for clause: - -- ask `resolveSharingWriteVerdict('update', master, masterId, …)` — the tri-state - verdict, not `canEdit`'s boolean projection — and drop the platform ownership - floor **only** on `allow`; -- ask it only when a platform floor policy is actually applicable to this - (principal, master, `update`), so an object with no floor in play spends no - sharing probe; -- `abstain` and `deny` both leave the floor standing, and the verdict answers - `deny` when its own probe throws, so no failure mode of this composition can - widen; -- the on-behalf-of path (ADR-0090 D10) is excluded, mirroring step 2.7: a - delegated write keeps **both** principals' floors, exactly as before. - -Only the PLATFORM's floor is droppable (provenance, ADR-0105 D3). An app-authored -policy — including one spelling the identical predicate — reaches the compiler -untouched and still refuses (ADR-0049), and Layer 0 (the tenant wall) is not -affected at all. Step 2.7's composition and the insert leg's #8688 stand-down are -untouched. - -## Pinned - -The residual assertion the measuring run left in the tree -(`controlled-by-parent-detail-write-authority.test.ts`, labelled `RESIDUAL -(#8865)` with the comment "When #8865 lands the assertion above flips") now -asserts the permission, and keeps its witness — the same principal, the same -master row, the same operation, asked directly — so the two paths cannot drift -apart again without a red. - -A new section pins the flip to the composition rather than to a relaxation, each -case varying one input and asserting the direct write of the master agrees: - -- an `edit`-level `sys_record_share` on the master — and nothing else — is what - moves a child write from refused to permitted; -- an owner-less master, where `checkEdit` abstains for everyone (Modify All Data - included), keeps its floor and refuses on both paths — the case that separates - the ruled `=== 'allow'` composition from the boolean projection; -- an app-authored master policy still refuses a principal whose sharing verdict - is `allow`, while the same write without that policy is permitted. diff --git a/.changeset/cbp-missing-master-insert-validation-envelope.md b/.changeset/cbp-missing-master-insert-validation-envelope.md deleted file mode 100644 index 906b8bb635..0000000000 --- a/.changeset/cbp-missing-master-insert-validation-envelope.md +++ /dev/null @@ -1,91 +0,0 @@ ---- -"@objectstack/plugin-security": minor ---- - -fix(plugin-security)!: an insert that omits a required master-detail parent answers `400 VALIDATION_FAILED` with `fields[]`, not a `[Security]`-prefixed `422` (#8688) - - - -**BREAKING (error contract).** On an `insert` into a `controlled_by_parent` -detail whose master reference is absent, the platform used to answer: - -``` -HTTP 422 -code : MISSING_REQUIRED_FIELD -error : [Security] Missing master reference: insert on 'crm_contact' did not - supply 'crm_account'. … -fields: (absent) -``` - -It now answers the same envelope every other missing-required-field case -answers — `400 VALIDATION_FAILED`, carrying `fields[]` with -`{ field, code: 'required' }` — wherever required-field validation provably -refuses that omission. A client branching on `code === 'MISSING_REQUIRED_FIELD'` -for this condition must branch on `VALIDATION_FAILED` instead; a client already -handling the platform's ordinary missing-field envelope needs no change and -gains the field it could not previously highlight. - -**What was wrong.** `assertControlledByParentWrite` runs in the security -middleware chain, *outside* the executor that calls `validateRecord`, so on an -insert it short-circuited required-field validation on the one field they -share. One user-visible condition therefore had two answers on adjacent -branches of the same field: absent → `422` with no `fields[]`, present but -unresolvable → `400 VALIDATION_FAILED` with `fields[]`. A form could highlight -the offending input in the second case and not the first, and any surface -rendering the message string showed a missing required field as a security -refusal. Measured live on 17.0.0 GA over REST. - -The two harms could not be separated: both transport doors emit `fields[]` only -for the `VALIDATION_FAILED` duck-type and each overwrites `code` when it -matches, so "add `fields[]` while keeping `MISSING_REQUIRED_FIELD`" is not a -reachable throw shape. - -**The stand-down is CONDITIONAL, and the residue is deliberate.** It applies -only where `validateRecord` really does refuse the omission: a `master_detail` -declared `required: true` and not `readonly`/`system`. For three other -declarable shapes — a `master_detail` with no `required`; `required` + -`readonly`; `required` + `system` — the validator skips the field before its -required check ever runs (`if (def.system || def.readonly) continue;`), so the -master gate is the only thing refusing the insert. There it keeps answering -`422 MISSING_REQUIRED_FIELD` exactly as before. A flat hand-over was measured to -mint a detail row with a null master FK, which the `controlled_by_parent` read -filter (`fk IN (readable masters)`) can never match — readable by nobody, and -answering `422` on every later by-id write. - -**So the envelope asymmetry is not gone, it is confined**, to precisely the -shapes #8772's lint now refuses at publish time. An app that publishes cleanly -sees one envelope; an app still carrying one of those declarations sees the old -one until it is republished. One further residual, narrower still: a -`controlled_by_parent` object whose relation resolves through the required-*lookup* -fallback also keeps the `422` — validation would cover it, but the ruling covers -`master_detail`, and widening a ruling is not the implementer's call. - -**Unchanged, and pinned as unchanged:** a master that is *present but not -writable* by the caller still answers `403 PERMISSION_DENIED — requires edit -access to its master record`. The stand-down is keyed on the FK being absent; -every access leg still runs when one is supplied. The stored-row shape (a by-id -write whose persisted FK is null) also keeps its `422`: the caller sent no such -field, so a `fields[]` naming it would name a field that was never in the -request, and no payload the caller could send would fix it. - -**One pin was rewritten deliberately**, not adjusted to match new behaviour: the -`[#7474]` six-envelope truth table's **insert** leg in -`controlled-by-parent-sharing.test.ts`. Its successor asserts both sides of the -condition — the covered shape hands over (the executor is reached, and the real -`validateRecord` refuses with `VALIDATION_FAILED` + `fields[]`), and each -uncovered shape still gets the `422` (with the real validator raising nothing on -the same payload, which is why the gate must stay). The truth table's other -legs are update-path and are untouched. - -This supersedes the 2026-08-11 envelope choice on #7474, on that ruling's own -rationale: if a detail without its master is "precisely a required value that is -absent", the platform's contract for a required value that is absent is -`400 VALIDATION_FAILED` with `fields[]`. diff --git a/.changeset/client-explain-recordids-batch-spelling.md b/.changeset/client-explain-recordids-batch-spelling.md deleted file mode 100644 index 5cbed93bf1..0000000000 --- a/.changeset/client-explain-recordids-batch-spelling.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@objectstack/client": minor ---- - -feat(client): `security.explain()` accepts the `recordIds` batch spelling (#8480) - -The typed `security.explain()` request now declares the optional -`recordIds?: string[]` field alongside the existing `recordId?: string`, so a -typed-client consumer can reach the batch record-grained explain form added -server-side by #8326 without a cast. Type-level and TSDoc only — the method -still forwards the request body verbatim over POST; the 200-id cap and the -`recordId`/`recordIds` mutual exclusion are validated server-side by -`ExplainRequestSchema` (`@objectstack/spec`), unchanged. diff --git a/.changeset/cloud-arm-host-marketplace-precedence.md b/.changeset/cloud-arm-host-marketplace-precedence.md deleted file mode 100644 index 4279245eb5..0000000000 --- a/.changeset/cloud-arm-host-marketplace-precedence.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`serve`: the cloud-connected marketplace arm now leaves a host config's own marketplace and cloud plugins alone. - -`objectstack serve` auto-wires `MarketplaceProxyPlugin`, `MarketplaceInstallLocalPlugin`, the same-origin cloud-connection surface and `RuntimeConfigPlugin` whenever a cloud URL resolves. Each of those four mounts is now guarded on whether the loaded host config already wired that surface — the same presence check the offline arm has carried since the install-local fix — so CLI auto-wiring is a fallback for hosts that wire nothing rather than a second opinion about a surface the host already composed. - -No behaviour changes for any current deployment: `Kernel.use()` keys plugins by `plugin.name` and the host's registration runs after the CLI's, so the host's instance already won by ordering. What changes is that it now wins by rule instead of by the relative position of two blocks that never referenced each other, and the CLI stops constructing four plugins it was about to discard. It becomes visible the moment a host passes an argument the CLI cannot — a private control plane, a custom install `storageDir`, a credential path, white-label branding. diff --git a/.changeset/dashboard-dataset-publish-gate.md b/.changeset/dashboard-dataset-publish-gate.md deleted file mode 100644 index e680445cce..0000000000 --- a/.changeset/dashboard-dataset-publish-gate.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -'@objectstack/lint': minor -'@objectstack/metadata-protocol': minor ---- - -Dashboard writes are now judged by `validateWidgetBindings` at the runtime publish gate (#7529). A dashboard widget bound to a dataset that resolves to nothing — previously a `200` on both save and publish, failing only as a runtime error on the live board — is refused at **publish** with a located 422 (`INVALID_METADATA`, the offending key path named). Drafts are unaffected: a draft may still hold a forward reference to a dataset not yet authored, and only the draft→active promotion runs the gate. - -Because rule surfaces are registered per-rule, all six of the rule's error-tier findings now gate a dashboard publish as one reference-integrity class: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`, `chart-field-unknown`, `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`. Warning-tier findings (`table-count-only`, `chart-config-missing`, …) ride the non-blocking `advisories` channel on the save response. Config-authored stacks are unaffected — `os validate` / `os build` / `os lint` already ran this rule; the newly gated population is exactly the `sys_metadata` overlay writes (Studio / REST `/meta` / MCP) that previously bypassed it. - -The per-write snapshot (`RuntimeStackContext`) now carries the live `datasets` collection so bindings resolve against the real dataset universe — without it every legitimate board would read as dangling. Existing stored rows are untouched (the gate blocks new publishes only), and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` remains the migration-window escape hatch. diff --git a/.changeset/datasource-config-url-query-credential-refused.md b/.changeset/datasource-config-url-query-credential-refused.md deleted file mode 100644 index 902719ad5a..0000000000 --- a/.changeset/datasource-config-url-query-credential-refused.md +++ /dev/null @@ -1,66 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/service-datasource": patch ---- - -feat(spec): refuse credential-bearing URL query parameters (`?authToken=` / `?password=`) in authored driver config at publish (#8337) - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -The third spelling of the same secret: #7990 refused the inline credential -keys, #8082 refused the URL userinfo form (`user:password@host`), and the -query string stayed open — `libsql://x.turso.io?authToken=eyJ…` persisted the -JWT cleartext into `sys_metadata` (served back by the ordinary data API) and, -measured against the clients this tree pins, actually authenticates: -`@libsql/core@0.17.4` assigns the URL's `?authToken=` OVER the config-level -token — so the workaround also silently defeated the binder-injected secret — -and `pg-connection-string@2.14.0` copies every query parameter into the client -config, `?password=` winning over userinfo. - -**What is refused** (write door, shared value-level parse -`urlCredentialQueryParams` beside #8082's `urlUserinfoPassword`): turso -`config.url` / `config.syncUrl` carrying `?authToken=`, postgres `config.url` -carrying `?password=` — matched case-insensitively on the percent-decoded key, -non-empty values only, with the #8082-template prescription (datasource secret -binder / `external.credentialsRef`; runtime-environment DSNs are unaffected). -mysql and mongo URLs are deliberately NOT narrowed: both clients were measured -ignoring `?password=`, so refusing it would widen past the measured defect. - -**What stays accepted:** every credential-free URL byte-identically, benign -query parameters (`?tls=`, `?sslmode=`, …) included, and the parameter-absent -shape the read path serves — which keeps an untouched "Save" on a legacy row -working. - -**Read half** (the same PR, per the card): `redactDatasourceConfig` / -`getDatasource()` now strip credential query parameters from served URLs for -every driver (new `redactUrlCredentials` / `redactUrlCredentialQueryParams` -exports), `restoreRedactedConfig` mirrors the composite so an untouched -round-trip keeps the stored token, and the credential-migration planner -refuses a query-token row with the per-row remedy instead of planning -`nothing-to-migrate` over cleartext. - -## FROM → TO - -```yaml -# before — parsed green; JWT stored cleartext in sys_metadata, and at connect -# it silently overrode the binder-injected secret -driver: turso -config: - url: libsql://app-org.turso.io?authToken=eyJhbGciOiJFZERTQSJ9.x.y - -# after — rejected with the binder prescription; bind the secret instead -driver: turso -config: - url: libsql://app-org.turso.io -external: - credentialsRef: sys_secret:01J9ZK4T2N # or the connection form's secret field -``` - -There is deliberately no automatic rewrite: moving the value requires -encrypting it into `sys_secret` through a running secret binder, which a -source-file transform cannot do — stripping the parameter alone would silently -drop a live credential. - - diff --git a/.changeset/datasource-credential-rehoming.md b/.changeset/datasource-credential-rehoming.md deleted file mode 100644 index d9d511adf3..0000000000 --- a/.changeset/datasource-credential-rehoming.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/service-datasource": minor ---- - -feat(service-datasource): operator-initiated re-homing of stored cleartext datasource credentials into `sys_secret` (#8155) - -A datasource row created before #8078 closed the write door can still hold its -credential in cleartext inside `config`. #8081 and #8154 closed the read paths so -none of it is SERVED; neither removes what is already at rest. This adds the -migration that does — `IDatasourceAdminService.migrateCredential(name)`, reached -from the Setup action **"Move credential to the secret store"** on a datasource -record, backed by `POST /api/v1/datasources/:name/migrate-credential`. - -**Per datasource, initiated by an operator, never a sweep.** There is no batch -spelling of the route, deliberately: deciding a stored secret's identity with no -operator present and rewriting rows at boot is the destructive shape the standing -ruling escalates rather than permits. The inventory is free and already exists — -`/meta` badges every affected row `_diagnostics: { valid: false }`, so the -operator works from a list the platform already computes, and it shrinks visibly -as each row is done. - -**Durability ordering.** The secret is written to the store, **read back and -compared**, and only then does a single record write add -`external.credentialsRef` and drop the inline key together. A crash before that -write leaves the row untouched and working on its inline credential; a crash -after it leaves a row referencing a secret this run already proved readable. A -failed read-back or a failed record write unbinds the secret it just minted -rather than orphaning it. It deliberately does NOT write the ref in one step and -delete the key in a second: the connect path is fail-closed on a `credentialsRef` -it cannot resolve (ADR-0062 D3) and never falls back to `config`, so a row -carrying an unverified ref beside its cleartext is not a safe intermediate state. - -**Idempotent.** A row that already references a secret is never bound again — a -re-run answers `already-bound`, writes nothing, and mints no second `sys_secret` -row. A row holding both a ref and an inline copy (an interrupted run, or a wizard -re-entry, whose redacted round-trip carries the stored credential forward by -design) has the copy dropped against the ref it already has. - -**What it refuses, and what it tells the operator instead.** Only the key a -driver's own contract declares as its inline credential slot is re-homed — -`password` for postgres/mysql/mongodb, `authToken` for turso — because that is -exactly the key the injected secret substitutes at connect time. Everything else -is refused with a reason and a remedy rather than guessed at: a credential -embedded in a connection URL (the mysql and mongodb DSN branches hand the URL to -the client verbatim and drop the injected secret, so re-homing it could leave the -datasource connecting unauthenticated), a pre-#8078 alias spelling that no -connection builder reads, turso's still-writable `encryptionKey`, a code-defined -datasource, and a host whose secret binder cannot read a secret back. Nothing is -deleted that was not re-homed, and credential-shaped keys left behind are named -in the result so "migrated" never reads as "this row is now clean". diff --git a/.changeset/deactivated-position-stops-sharing.md b/.changeset/deactivated-position-stops-sharing.md deleted file mode 100644 index 54873250b8..0000000000 --- a/.changeset/deactivated-position-stops-sharing.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -"@objectstack/plugin-sharing": minor ---- - -fix(security): a deactivated `sys_position` stops conferring sharing-rule record shares (#8710) - - - -**BREAKING for deployments that already deactivated a position named as a -sharing-rule recipient.** Their `sys_record_share` rows are revoked on the next -evaluation of that rule. - -#8613 made `sys_position.active` real at the authorization DERIVATION seam: a -deactivated position stops carrying its permission sets and its name leaves -`context.positions`. A sharing rule reaches users by a **second road that never -passes that seam** — `SharingRuleService.expandRecipient` → `PositionGraphService` -— so a rule sharing records with `cfo` kept sharing them after `cfo` was -deactivated, while the `deactivate_position` dialog promises, unqualified: - -> Deactivate this position? Users keep their assignment but the position stops -> granting permissions until re-activated. - -A record share is access, so the promise covers it. Maintainer ruling, -2026-08-15, verbatim: **"Access-conferring paths filter deactivated positions; -addressing paths do not."** - -**What changes at runtime.** When a sharing rule's recipient is a position, the -evaluator reads the `sys_position` catalogue row and, if it is explicitly -deactivated, the rule expands to **nobody**: - -- no new shares are materialised for that position's holders; -- the shares it had already materialised are **revoked** on the next - reconcile — by `evaluateRule`, by the per-record hook pass, and by the - synchronous recipient-axis revoke (#7729) — because a rule that confers - nothing has an empty desired set and every existing grant is stale; -- the verdict is read with `isRowActive` (`@objectstack/core`), the same - predicate #8613 established, so the 1/0 and `'false'` storage shapes every - driver produces are judged identically. - -This is **not** a refactor and **not** a no-op: it changes who receives record -shares. - -**What deliberately does NOT change**, per the same ruling: - -- **approval ROUTING** keeps reading the raw directory — filtering there is - fail-OPEN (an approval step routing to nobody), #8613's carve-out, reaffirmed; -- **write gates and blast-radius reads** (`assertAudienceAnchorBindingGate`, - `setsBoundToPosition`, the delegated-admin surfaces) stay unfiltered, because - dropping a deactivated row there would make a refused binding permitted and - narrow a delegate's boundary — access *widening*; -- `PositionGraphService.expandPositionUsers`, the ADDRESSING primitive, is - untouched: the filter is at the sharing call site, so moving it down into the - helper would take the paths above with it. A pin fails if it ever does. - -**Rows that keep granting exactly as before:** a position whose `active` column -is absent or NULL (the predicate is "explicitly deactivated", never "explicitly -active"), a recipient name with no `sys_position` row at all (the -`sys_member.role` transition source of ADR-0057 D4), and a position whose -same-name row was deactivated in *another* organization — `sys_position.name` is -unique per organization (#8468), so the flag is read off this rule's own -tenant's row. - -**Cost.** One `sys_position` read per distinct position per evaluator pass, -memoised for that pass only (a memo outliving the pass would make a deactivation -take effect late). The ruling accepted the extra read explicitly; the sibling -seam in #8613 needed none because both tables were already at hand there. - -**Before upgrading**, list the deactivated positions and check whether any is a -sharing-rule recipient whose shares are still meant to flow — re-activate those, -or move the grant onto the rule: - -``` -GET /api/v1/data/sys_position?filters=[["active","=",false]] -GET /api/v1/data/sys_sharing_rule?filters=[["recipient_type","=","position"]] -``` diff --git a/.changeset/derived-capability-unseeded-bucket-warned.md b/.changeset/derived-capability-unseeded-bucket-warned.md deleted file mode 100644 index d319dfa8af..0000000000 --- a/.changeset/derived-capability-unseeded-bucket-warned.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -fix(plugin-security): the derived capability seeder's skip is counted and warned instead of leaving the platform bucket silently unseeded (#8536) - -**This does not change what the seeder does. It changes whether an operator can -tell what it did.** No adoption, no backfill, no new writes — the #5876 guard -keeps declining an authored row, which is the ruled behaviour (#8552 settled the -posture on an occupied platform bucket: keep declining, loudly). - -`bootstrapSystemCapabilities` derives a placeholder `sys_capability` row for any -capability a bootstrap permission set grants by name. Its lookup runs under the -system context, which carries no `tenantId`, so it reads **across -organizations** — and when the row it finds is one it does not own, the #5876 -guard `continue`s before any insert is attempted. - -Before #8461 that was harmless, because `name` was unique installation-wide: "a -row resolves this name" and "the platform holds a row for this name" were one -statement, which is exactly what #5876's reasoning rests on ("the capability -resolves and the authored copy is the better one"). Per-organization uniqueness -(ADR-0120 D1) separated them. An organization's row now satisfies the lookup -while the platform's NULL-organization bucket is **never written at all**, and -nothing said so: `skippedAuthored` moved, and that counter cannot distinguish -"an authored copy was left alone" from "the platform's definition exists -nowhere". - -The skip now reads the platform bucket once — on that branch only, the same cost -the curated half already accepted — and warns with the curated half's -provenance-naming shape: it names the `managed_by` and organization it **read** -off the blocking row rather than asserting an ownership verdict, states which of -the three bucket observations it saw (free / held by an unstamped row / held by -a row with a named provenance), and carries the #8552 hand-resolution line only -where a row an operator may legitimately rename is what blocks the bucket. Where -an organization's row is what stands in the way, the message says there is -nothing to remove — that row is a supported ADR-0066 D1 extension. - -The warning fires only where the platform's own placeholder is genuinely -**absent**, so it means one thing. A skip that declines a mere refresh — the -placeholder is present and simply was not the row the cross-organization lookup -selected — stays summary-only, as #4632 decided. - -`CapabilitySeedResult` gains `unseededDerived`, a documented **subset** of -`skippedAuthored` rather than a split of it: the existing counter keeps its -meaning and its value, because the two facts are separable only since #8461 and -neither should be inferred from the other. diff --git a/.changeset/diagnostics-store-outage-503.md b/.changeset/diagnostics-store-outage-503.md deleted file mode 100644 index fe818418db..0000000000 --- a/.changeset/diagnostics-store-outage-503.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): `getMetaDiagnostics` stops publishing an unreadable metadata store as "0 problems" (#8855) - - - -`GET /api/v1/meta/diagnostics` sweeps every metadata type and publishes four -numeric facts about the corpus. Its per-type read was wrapped in an **untyped** -`catch` that `continue`d, and the comment above it named a benign reason ("type -not listable in this kernel scope") that is genuinely real. The catch took -everything else with it — including the one error the callee exists to raise. - -`getMetaItems` classifies a failed `sys_metadata` read by error **type** and -throws a 503 (`SERVICE_UNAVAILABLE`) for every read failure that is not "the -table has not been provisioned yet" — the discrimination #5532 introduced so an -outage would stop looking like emptiness. `getMetaDiagnostics` caught that 503 -back into emptiness one layer up, then published the emptiness as a **number**. - -**Measured on `origin/main` @ `8664a2c99` before the fix**, prediction written -down first and matched exactly. With an engine whose every read rejects: - -``` -[outage: connect ECONNREFUSED 10.0.0.5:5432] RESOLVED - total=0 scannedTypes=26 scannedItems=0 Object.keys(stats).length=0 -[benign: SQLITE_ERROR: no such table: sys_metadata] RESOLVED - total=0 scannedTypes=26 scannedItems=0 Object.keys(stats).length=26 -``` - -Two user-visible harms from one `catch`, and the benign run is what makes them -legible — it is the same payload minus the `stats`: - -- `stats[t]` is never written, so an unreadable type is **absent** from the - response rather than zero. The Studio directory tile the field's own doc names - loses the type, byte-shaped like an environment that declares none of it. -- `total` counts entries that **failed validation**, and a store nobody can read - contributes none — so the endpoint whose whole job is reporting problems - answered `total: 0` at the exact moment it could read nothing. Green was the - failure mode. - -`scannedTypes` reported the full 26 in both runs: it is computed from the intent -(`targetTypes.length`, fixed before the loop) and never decremented on -`continue`. - -**The fix narrows the catch; it does not delete it.** A 503 arriving from the -read is rethrown **unchanged** and the sweep fails loudly (ADR-0110 D3: a miss -and an outage are different facts with opposite dispositions). Every other -failure still skips that one type, so a kernel scope that cannot enumerate one -type does not fail the whole governance sweep. - -**No response field was added.** A per-type degradation marker would be a -public-surface addition, and the payload type is unchanged. - -The envelope is **propagated, not rebuilt**: re-running the driver-error -classification here would re-wrap an already-shaped 503 in a second one and -displace the driver error riding as `cause` — the object `logWithheldServerFault` -prints for the operator. The REST boundary needs no change: the handler already -routes thrown errors through `handleRouteError`, which preserves the 503. - -The pin carries the discriminating control in the same file: an unprovisioned -`sys_metadata` still answers benignly with every type present at `count: 0`, a -type that is genuinely not listable is still skipped at the cost of one type, -and a healthy store still counts its rows — while the outage cases throw. "0 -problems" is the right answer in the benign cell, and it is exactly the answer a -blanket change would have kept producing in the wrong one. diff --git a/.changeset/diff-dead-history-read.md b/.changeset/diff-dead-history-read.md deleted file mode 100644 index bfd2b9ce9d..0000000000 --- a/.changeset/diff-dead-history-read.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -perf(metadata-protocol): `diffMetaItem` stops awaiting a `historyMetaItem` read it discarded, halving the history round trips on the live diff endpoint (#8798) - -`diffMetaItem` opened by awaiting a full `historyMetaItem` read, mapped it into a -`versions` array, and threw it away (`const _used = versions; void _used;`) while -the read it actually uses ran a few lines below through the engine. Every request -to the routed `GET /api/v1/meta/:type/:name/diff` paid for two reads of -`sys_metadata_history` where one is used. - -Diff bodies are unchanged. The authorization gate the discarded call passed -through never reached this function's output: `historyMetaItem`'s early return -answers `{ events: [] }` for a type that is neither `isOverlayAllowed` nor -`isRuntimeCreateAllowed`, without throwing and without touching the engine, and -`diffMetaItem` reads the history rows directly — so the five gated-shut types -(`field`, `job`, `api`, `capability`, `agent`) were already served a full diff -regardless. - -One behaviour change, on the outage path only. The discarded call was unguarded, -so an unavailable `sys_metadata_history` was fatal for gated-open types while -gated-shut types fell into the `try`/`catch` below it and answered an empty diff -— one outage, two answers, decided by an authorization gate unrelated to reading -history. Every type now takes the `catch`, which is the function's only stated -intent for that failure. Whether swallowing that outage is the right answer at -all is tracked in #8833. diff --git a/.changeset/diff-meta-item-canonical-type-and-history-outage.md b/.changeset/diff-meta-item-canonical-type-and-history-outage.md deleted file mode 100644 index 57e11432b2..0000000000 --- a/.changeset/diff-meta-item-canonical-type-and-history-outage.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): `diffMetaItem` folds its type at the request boundary and stops serving a history outage as an empty diff (#8868, #8833) - -`GET /api/v1/meta/:type/:name/diff` is a routed live endpoint with a -caller-supplied `:type`. Two independent defects in that one method, fixed -together because they land in the same function. - -**#8868 — the canonical fold.** `diffMetaItem` was the NINTH `/meta` entry point -on this URL family and the last one still deriving its type key from -`PLURAL_TO_SINGULAR`, the manifest-COLLECTION map that #7894 moved this boundary -off (#8769 routed `publishMetaItem`, #8819 routed `rollbackMetaItem`). It now -routes through `canonicalizeMetaRequestType`, which changes three things: - -- **the answer.** For the four MANIFEST-ABSENT types — `field`, `seed`, - `external_catalog`, `translation`, legitimately absent from that map because - they are not stack collections — a plural spelling stayed plural all the way - into the `sys_metadata_history` query, matched no row, and the endpoint - answered a well-formed **empty diff** (`added: []`, `removed: []`, - `changed: []`) for an item that does have history. Not a refusal and not an - error: a silent "nothing changed". Manifest-present types (`views` → `view`) - folded already and were never affected. -- **unrecognised spellings.** The #7894 boundary refusal never ran on this verb, - so a spelling like `viewes` was forwarded to the plugin path instead of - refused. It is now `400 INVALID_REQUEST`, naming both accepted spellings. The - refusal stays narrow by construction: a name that reaches for no declared type - (a possible plugin kind) is still served. -- **the echoed `type`.** The response echoed the caller's spelling back while the - read had used a different key. It now reports the canonical spelling — the - precedent `saveMetaItem` and `deleteMetaItem` already set, both of which - `return { type: request.type }` after their own fold. - -**#8833 — the swallowed outage.** The history read sat in a `try` whose `catch` -was empty apart from a comment. `histRows` stayed `[]` and the code below read -that never-filled accumulator as a real answer, so a `sys_metadata_history` -outage was served as a successful 200 with an empty diff — byte-identical to -"these two versions are the same", with no log line either. An operator -comparing versions before a rollback, and any SDK or agent reading this -endpoint, acted on "unchanged" with full confidence. - -Per the maintainer ruling on #8833, the `catch` now routes through the -platform's existing discrimination, `rethrowUnlessMetadataStoreUnprovisioned`: - -- a **genuinely absent table** — a minimal deployment that never provisioned - history — keeps its benign empty answer, so first boot does not explode; -- **every other read failure** (connection drop, timeout, permission denial, - query error) propagates `503 SERVICE_UNAVAILABLE`, carrying the driver error - as `cause`. ADR-0110 D3: a miss and an outage are different facts. This is the - same guard #5532 restored for `getMetaItems`. - -⚠️ **Behaviour change worth reading before upgrading.** This ADDS loudness where -there was none. PR #8841 had removed the last path that threw here, so as of -that change the outage was silent for *every* type; a diff whose history store -is unreachable now returns 503 where it previously returned 200 with an empty -diff. A diff against a deployment that never provisioned `sys_metadata_history` -is unaffected. No response field was added — a `historyUnavailable` key was -considered and declined. diff --git a/.changeset/dispatcher-meta-put-falsy-body-refused.md b/.changeset/dispatcher-meta-put-falsy-body-refused.md deleted file mode 100644 index 70923bc548..0000000000 --- a/.changeset/dispatcher-meta-put-falsy-body-refused.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(runtime): a `PUT /meta/:type/:name` with a falsy body is refused instead of being answered as a READ (#8842) - -The http-dispatcher's metadata save branch opened `if (method === 'PUT' && body)`. -The `&& body` conjunct was not a guard — it was a hole. Every path inside that -block returns (including the terminal `501`), so a falsy body did not merely skip -the write: execution continued past the whole save block into the read `try` -below, which resolved the type and answered the ordinary metadata **read**. - -A caller who asked to write received what looks like a successful read. No -status, header or field distinguished it from a real write acknowledgement — -the shape "Absence must be loud" exists to prevent. The `manage_metadata` -capability gate, which is the first thing the save branch does, was skipped -entirely for such a request as well. (Not an escalation: the request was answered -by the read path, which runs the same ADR-0106 mask a plain `GET` runs, and -nothing was written. Skipping a write gate on a request that performs no write -grants nothing — the defect is the lie, not a privilege.) - -**Reachable from an ordinary client, measured rather than read.** The host that -mounts this dispatcher path is the Hono adapter's catch-all, which builds the -body as `await c.req.json().catch(() => ({}))`. That `.catch` covers a parse -*failure* — an empty body or garbage lands on `{}` — but not a *successful* -parse of a falsy JSON value. Driven against a real Hono app, a `PUT` with -`content-type: application/json` and a payload of `null`, `false`, `0` or `""` -each arrive at the dispatcher falsy. - -**The fix matches the sibling transport rather than inventing a second answer.** -`packages/rest`'s `PUT /meta/:type/:name` already folds `req.body ?? {}` and -proceeds into the save unconditionally, so its bodyless writes are refused -downstream by the per-type schema with `422 INVALID_METADATA`. The dispatcher now -does the same: the branch keys off the method alone, and a nullish body folds to -`{}`. Two doors onto one `saveMetaItem` disagreeing about what a bodyless -metadata write means was the actual defect. - -What callers see instead of a spurious read: - -- holding `manage_metadata` → `422 INVALID_METADATA` from the per-type schema, - with the structured `issues` the Studio form reads; -- not holding it → `403 PERMISSION_DENIED` from the capability gate, which now - runs on this request at all. - -A `PUT` carrying a real body is untouched — it saves exactly as before, and the -body still reaches the writer verbatim. diff --git a/.changeset/eighty-pandas-shake.md b/.changeset/eighty-pandas-shake.md deleted file mode 100644 index 3ee9466145..0000000000 --- a/.changeset/eighty-pandas-shake.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/plugin-security': minor ---- - -**Security boundary change — this WIDENS who may write rows that are refused today.** On an ADR-0055 `controlled_by_parent` detail, the ADR-0055 master gate is now the sole row-level write authority: the platform's wildcard ownership floor (`owner_only_writes` / `owner_only_deletes`, `created_by == current_user.id`) is no longer applied to such a detail at the by-id write pre-image gate. A by-id UPDATE or DELETE of a child row **created by another user** now succeeds whenever the caller may edit that child's master — where it previously answered `403` `record_access_denied`. Maintainer ruling 2026-08-15 on #8757 (delegated adjudication). - -What the widening rests on: `assertControlledByParentWrite` — the object's declared write gate — already runs on the same operation, immediately after the pre-image gate, under a superset of its guard, and it refuses whenever the master is not editable. The floor is handed to that gate, not removed. Callers who could not edit the master are refused exactly as before, with the master gate's own sentence instead of the record-access one. - -Why it was wrong before: `controlled_by_parent` means "access derives from the master", and the detail declares nothing about who may write it. Two gates were answering one write, and the stricter — a creator-only rule no author wrote — always won: `SharingService.checkEdit` abstains on the `public`-mapped model before reaching its `modifyAllRecords` branch, so ownership depth, an `edit`-level `sys_record_share` and Modify All Data were all inert on a detail. - -Deliberately unchanged, each measured: - -- **BULK (AST) writes keep the floor.** `assertControlledByParentWrite` returns early with no single id, so nothing would replace it there. The floor is dropped from the by-id call site, never from the object's posture alone. -- **Delegated (on-behalf-of) by-id writes keep both principals' floors**, matching ADR-0090 D10's existing exclusion at this gate. -- **INSERT and the read path are untouched** — an insert has no pre-image and so never carried the floor; the floor is `update`/`delete`-only. -- **App-authored policies are untouched** (provenance, ADR-0105 D3), Layer 0's tenant wall is untouched, and a detail that authors its own `select` policies still derives its write scope from them (#7665). diff --git a/.changeset/enforce-active-on-grant-catalogues.md b/.changeset/enforce-active-on-grant-catalogues.md deleted file mode 100644 index 6c05ba5fb1..0000000000 --- a/.changeset/enforce-active-on-grant-catalogues.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/plugin-security": minor -"@objectstack/plugin-auth": minor ---- - -fix(security): `sys_permission_set.active` and `sys_position.active` now actually stop granting access (#8613) - - - -**BREAKING for deployments that already switched a permission set or position -off.** Both objects ship a Deactivate action whose confirmation dialog promises, -in all four locales, that access stops: - -> Deactivate this permission set? Existing assignments stay in place but stop -> granting access until re-activated. -> Deactivate this position? Users keep their assignment but the position stops -> granting permissions until re-activated. - -Nothing read the column. Measured on the real resolver: a position seeded -`active: false` still granted its permission sets, and a permission set seeded -`active: false` still returned `posture: PLATFORM_ADMIN` with its system -permissions. Deactivation moved a badge in Setup and nothing else — while the -admin who had just revoked a compromised or over-broad grant was told the -opposite, and whose likely next step was therefore *not* the action that would -have worked (delete the set, or remove the assignments). - -**What changes at runtime.** `resolveAuthzContext` / `resolveUserAuthzGrants` -(`@objectstack/core`) — the single seam every transport resolves authorization -through — now drop a deactivated row **before** any derivation: - -- a deactivated `sys_position` no longer contributes its - `sys_position_permission_set` grants, and its name leaves `positions` (so the - name-reuse path cannot resolve the same grant one layer down); -- a deactivated `sys_permission_set` contributes no name, no - `system_permissions`, no `tab_permissions`, **and no `PLATFORM_ADMIN` - posture** — the flag is applied before the posture is derived, not after; -- the `plugin-security` DB loader applies the same predicate, which is what - judges a set reached by NAME through an active position of the same name. - -Both tables were already read at that seam, so this costs **zero new hot-path -queries**. - -**⚠️ Read this before upgrading.** Any `sys_permission_set` or `sys_position` -row currently carrying `active: false` **stops granting the moment this -lands** — on live data, with no migration step to notice. That is the correct -direction (it is what the dialog said when someone clicked Deactivate), but on -an installation that used the switch believing it was inert it is a real -revocation. Before upgrading, list the deactivated rows and re-activate any that -are still meant to grant: - -``` -GET /api/v1/data/sys_permission_set?filters=[["active","=",false]] -GET /api/v1/data/sys_position?filters=[["active","=",false]] -``` - -A row whose `active` column is **absent or NULL** is unaffected: the predicate -is "explicitly deactivated", never "explicitly active", so rows that predate the -column keep granting exactly as before. - -**Break-glass, closed in the same change** (`@objectstack/plugin-auth`). -Enforcing the flag opened a one-click, installation-wide lockout: deactivating -`admin_full_access` un-makes every platform admin at once, through a payload -that touches neither `name` nor any identity table, and re-activating requires -the permission the click just took away (the seeders deliberately never -reconcile `active`, so no restart restores it). The last-administrator guard now -judges that write like the delete and rename spellings it already refused, and -an environment whose break-glass set is *already* off is read as emptied rather -than as a bootstrap window — so it does not silently disarm the guard for every -other identity write. Re-activation itself stays permitted, or the refusal would -have no way out from inside the product. diff --git a/.changeset/es-es-position-rename-damage.md b/.changeset/es-es-position-rename-damage.md deleted file mode 100644 index cbcb0a23a7..0000000000 --- a/.changeset/es-es-position-rename-damage.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -"@objectstack/plugin-security": patch -"@objectstack/plugin-sharing": patch ---- - -Repair the ADR-0090 `sys_role` → `sys_position` rename in the es-ES object -translation bundles, and guard it mechanically. - -The rename half-landed in Spanish: an unreviewed substring find-replace produced -two non-words (`Puestoes` as the plural of `Puesto`, and `contpuesto` where the -replace ate the unrelated word `control`), while nine further leaves in -`plugin-security` and three in `plugin-sharing` were missed entirely and still -named the pre-rename concept. In `plugin-sharing` the same picklist key rendered -two different ways in one file — `position` was `Puesto` on the sharing rule and -`posición` on the record share, and `unit_and_subordinates` read `Rol y -subordinados` (naming the removed role concept) against `Unidad de negocio y -subordinados` on its sibling. - -Spanish-facing admins saw `Puestoes` as the object's plural label in navigation -and list views, and two different words for one recipient kind across two Setup -screens. - -Two regression guards now cover the classes involved: a malformed-compound and -stale-term check on the renamed security objects, and a self-consistency check -asserting that a picklist option key shared by several sharing objects renders -identically within a locale. Neither needs a reader of the locale to review it. diff --git a/.changeset/export-filename-business-timezone.md b/.changeset/export-filename-business-timezone.md deleted file mode 100644 index 8e11a00ee3..0000000000 --- a/.changeset/export-filename-business-timezone.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/rest": patch ---- - -fix(rest): stamp the export download's filename in the business timezone (#8484) - -`exportContentDisposition` built the `-YYYYMMDD-HHMMSS` half of the suggested -filename from process-local getters (`now.getFullYear()` / `getHours()` / …), -which read the deployment host's `TZ` — a hosting fact, not the caller's -business timezone. The route had already resolved that timezone one frame up -(`ExecutionContext.timezone`, the platform-default → global → tenant cascade) -and simply never passed it here. - -After #8373 moved the export's **contents** onto the business timezone, the -filename was the last export surface still on the host clock, so the two -disagreed exactly when `TZ` was not the business zone: a container at `TZ=UTC` -serving an Asia/Shanghai tenant downloaded `orders-20260731-220000.csv` whose -first row read `2026-08-01 06:00:00` — off by a day, and at a month boundary by -a month. The name and the rows inside it now read one clock. - -**The no-timezone fallback stays PROCESS-LOCAL, deliberately not UTC.** This is -the opposite of the cell path's UTC fallback, and the asymmetry is the point: -each fallback preserves the historical output of the surface it serves. The -cells were hardcoded to UTC before #8373; this filename has always used the -process clock. Defaulting it to UTC would look safer while silently re-timing -the filename of every deployment that sets a host `TZ` but resolves no business -timezone — a user-visible rename for zero correctness gain. An explicitly -resolved `'UTC'` is a *resolved* zone, not a missing one, and does produce a UTC -stamp regardless of the host. - -The shared clock helper is split rather than parameterised with a default: -`zonedWallClock` now returns `null` when no usable zone resolves, and each of -the two callers supplies its own fallback at the call site where it can be read -and pinned. Baking either fallback into the shared helper would silently -re-time the other surface. - -Filename **naming** is untouched — label selection, sanitization and the RFC -5987/6266 `filename*` encoding all behave exactly as before, and the export's -contents are not touched at all. diff --git a/.changeset/field-scale-precision-integer-refused.md b/.changeset/field-scale-precision-integer-refused.md deleted file mode 100644 index 6f6e46321d..0000000000 --- a/.changeset/field-scale-precision-integer-refused.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): refuse malformed field `scale`/`precision` declarations at authoring time (#8321) - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -`Field.scale` ("Decimal places") and `Field.precision` ("Total digits") are -digit counts, but both parsed as bare `z.number()` — admitting `scale: 2.5` -and `scale: -1`, neither of which has a defined meaning as a count. That -looseness became load-bearing when #7501 made `scale` enforced at write time: -the runtime branch deliberately guards on `Number.isInteger(def.scale) && -def.scale >= 0` (inventing floor/round semantics in a consumer would be PD #12 -guessing), so a typo'd declaration silently got **no enforcement at all** — -the declared-but-inert shape that hides AI-authored metadata errors. - -**What is refused:** a non-integer or negative `scale` or `precision`, at -parse time with the issue path and substance (`invalid_type` "expected int" / -`too_small` ">=0") — the house `z.number().int().min(0)` shape (ADR-0078 -declared=enforced). - -**What stays accepted:** every well-formed declaration byte-identically -(`0`, `2`, any non-negative integer, or no declaration). -`CurrencyConfigSchema.precision` (under `currencyConfig`) is a **different -surface** with its own bounds and `scale → precision` alias table — unchanged. - -**Stored metadata is not hard-broken:** a `sys_metadata` row already at rest -with a malformed value keeps loading — the ADR-0087 D2 conversion -`field-malformed-scale-precision-removed` (retired from the load path, -replayed by the stored-row rehydration seam and `os migrate meta`) drops the -meaningless key, which is behaviour-preserving because a malformed declaration -enforced nothing. The semantic entry -`field-scale-precision-integer-refused` (protocol major 18) tells authors to -re-declare the digit count they meant. - - diff --git a/.changeset/formula-filter-refusal-adr-0087-entry.md b/.changeset/formula-filter-refusal-adr-0087-entry.md deleted file mode 100644 index 8d3d34f9a5..0000000000 --- a/.changeset/formula-filter-refusal-adr-0087-entry.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -"@objectstack/spec": patch ---- - - - -docs(spec): register the FILTER-axis formula refusal in the ADR-0087 ledger (#8370) - -The refusal itself shipped in 17.0.0 (#8296 / PR #8369): a `where` naming a -`formula` field is `400 INVALID_FIELD` at both doors — the REST ingress -(`assertFilterFieldsExist`) and the engine's own filter seam -(`assertFilterIsMaterializable`), which saved reports, flows and dashboard -widgets reach directly. It shipped with **no** ADR-0087 semantic entry, so -`objectstack migrate meta`, `spec-changes.json` and the generated upgrade guide -said nothing about it. - -Its SORT-axis twin (#7095, `engine-find-formula-order-by-refused`) carries one, -for the identical shape. This adds the FILTER-axis sibling — -`engine-find-formula-filter-refused` under protocol 17 — and regenerates the two -projections of the registry. - -For a code-path API there is no `sys_metadata` row for the D2 chain to rewrite -and no mechanical rewrite in either direction (the platform cannot invent the -stored column, and it must not filter post-hoc — `driver.find` has already -applied `limit` / `offset`, so a post-hoc predicate would filter an arbitrary -PAGE), which makes the ledger entry the only notification channel this class -has. The remedy it prescribes is the one the sort and search axes already -prescribe, in the same words: denormalise the value onto a stored field written -when the source changes, and filter that. `summary` and `autonumber` fields need -no action — both get real maintained columns and filter correctly. - -No behaviour changes: registration and regenerated artifacts only. diff --git a/.changeset/hungry-donkeys-shout.md b/.changeset/hungry-donkeys-shout.md deleted file mode 100644 index a8445b7e18..0000000000 --- a/.changeset/hungry-donkeys-shout.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -Declare `saveMetaItem`'s missing-item refusal as a real ADR-0112 envelope: `400` / `INVALID_REQUEST`, was an undeclared throw served as `500 INTERNAL_ERROR`. - -`PUT /api/v1/meta/:type/:name` unwraps the `{ item }` / `{ metadata }` envelope shapes before calling the protocol, so a caller sending `{"item": null}` or `{"metadata": null}` reached a guard that declared neither `code` nor `status` — the only refusal in the method that did not. With no status to read, the REST boundary defaulted to a server fault, so an authoring mistake was reported as `500 INTERNAL_ERROR` and the guard's own sentence was withheld by the ADR-0112 disclosure rule and replaced with a generic fallback. Callers now receive `400` with the refusal quoted and the remedy named. - -Unchanged: a missing, empty or literal-`null` request body never reached this guard and still answers `422 INVALID_METADATA` from the per-type schema parse. No new error code is introduced — `INVALID_REQUEST` is already registered to this package in the ADR-0112 ledger, and is what the structurally identical opening guard in `rollbackMetaItem` already uses. diff --git a/.changeset/import-naive-datetime-business-timezone.md b/.changeset/import-naive-datetime-business-timezone.md deleted file mode 100644 index cd35c345f7..0000000000 --- a/.changeset/import-naive-datetime-business-timezone.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -"@objectstack/core": patch -"@objectstack/rest": patch ---- - -fix(rest): read an offset-free import cell in the business timezone, not the host `TZ` (#8485) - -`parseDateCell` ended in `new Date(s)`. A spreadsheet cell like -`2026-08-01 06:00:00` carries no offset, so ECMAScript resolves it against the -**process** timezone, and the instant bulk import stored became a property of -the deployment host: - -``` -TZ=Asia/Shanghai → 2026-07-31T22:00:00.000Z -TZ=UTC → 2026-08-01T06:00:00.000Z -``` - -Same file, same tenant, same cell — eight hours apart, decided by a setting -nobody authoring the spreadsheet can see, and never consulting the business -timezone the route had already resolved one frame up -(`ExecutionContext.timezone`, the platform-default → global → tenant cascade). - -Since the export renders `datetime` cells in that business timezone (#8373), the -advertised export → edit in a spreadsheet → re-import round trip was lossless -only where the host `TZ` happened to equal the business zone. `import-coerce.ts` -opens by calling itself "the inverse of `export-format.ts`"; it now is one, and -the regression proof asserts inverse-ness on the **pair** — every fixture under -a host `TZ` deliberately different from the business timezone, because a test -that runs only under a matching `TZ` cannot fail. - -**An offset-free datetime cell is now read in the caller's business timezone**, -through `@objectstack/core`'s new `zonedWallClockToUtcMs` — the DST-safe wall -clock → instant primitive that `zonedDateStartToUtcMs` (the date-bucket drill -path) is now the midnight special case of. One implementation of zone -arithmetic, `Intl` offsets from the platform tz database, never hand-rolled; -generalising the existing one rather than hand-rolling a second in `rest` is -what keeps the export and import halves of this seam from drifting apart again. -Two wall clocks are not a bijection with instants, and both degenerate DST -readings resolve to the earlier candidate instant — a gap reading lands just -before the gap, an ambiguous reading on its first occurrence (pinned, measured). - -Three things deliberately do **not** move: - -- **A cell that carries an explicit offset** (`…Z`, `…+08:00`) already names one - instant and is honoured exactly as written. This change affects naive cells - only. -- **The date-only fast path stays UTC.** `YYYY-MM-DD` is UTC per ECMAScript and - a `date` is a timezone-naive calendar day (ADR-0053); sweeping it into the - zoned handling to make the code look uniform would silently re-time every - date-only import to fix nothing. -- **No timezone resolved ⇒ UTC**, never the process clock. That is the fallback - the export's cell path takes in the same case, so the round trip stays exact - for deployments that configure no zone — and a process-`TZ` fallback would - preserve the defect for exactly the deployments that cannot see it. This is - the one **behaviour change for existing deployments**: a host with a non-UTC - `TZ` and no resolved business timezone previously read naive cells in the host - clock and now reads them as UTC. An explicitly resolved `'UTC'` is a resolved - zone, not a missing one. - -Two adjacent legs of the same defect, both on the naive-cell path: - -- **A naive cell landing in a `date` or `time` field** now takes the typed - components verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`). - Those branches also read the process clock, so a host east of the cell stored - the *previous calendar day* for a `date` column. -- **An xlsx date cell.** An Excel serial date carries no timezone; ExcelJS - materialises it as a `Date` whose UTC components are the sheet's wall clock, - and `import-prepare.ts` rendered it with `toISOString()` — stamping a `Z` the - file never had. That fabricated offset then outranked the business timezone by - the very carve-out above, so every real date cell in a user-authored workbook - imported as UTC whatever the tenant's zone. It now flattens to the same - offset-free `YYYY-MM-DD HH:mm:ss` a CSV export writes, which is what that - function's contract already claimed to produce. diff --git a/.changeset/invalid-filter-target-field-provenance.md b/.changeset/invalid-filter-target-field-provenance.md deleted file mode 100644 index c199c1d014..0000000000 --- a/.changeset/invalid-filter-target-field-provenance.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/driver-sql": patch -"@objectstack/driver-turso": patch ---- - -fix(drivers): withhold the target field from a policy-authored `INVALID_FILTER` refusal (#8197) - -`#7929`/B stopped `driver-sql` echoing the operands of a cross-field -`{ $field }` refusal, and `#8220` gave that withhold a spec-declared provenance -mark so an author-written predicate gets its diagnostic back. Neither reached -the rest of the `INVALID_FILTER` family: five other refusals still named the -refused constraint's own **target column** to every caller. - -That column is not always the caller's. The security middleware ANDs an -administrator's compiled CEL rule into `opCtx.ast.where`, and on such a -predicate the target is as administrator-authored as the referent `#7929` -already withholds — the argument that ruling accepted, one step out. The most -reachable case is a permission rule over a `multiple: true` field, which lowers -to a membership test on a JSON-stored column and is refused by `#7398`'s gate -while naming the column the administrator wrote. - -Measured on a real `SqlDriver` (better-sqlite3, `:memory:`) through -`driver.find`, all five answered `INVALID_FILTER` / 400 naming the target, and -the author-marked spelling was byte-identical to the unmarked one — the mark -reached these sites but was never consulted, because none of these builders -passed through the withheld-refusal carrier. - -They now do. The five join the seam `#8220` already owns, with its fail -direction unchanged: - -- the JSON-column operator gate (`#7398`), -- the zero-operator field constraint (`#5240`), -- the unbindable comparand (`#5041`) — which also answers a **malformed** - `{ $field }`, one whose referent is not a string and so never reaches the - cross-field arm, -- the `$between` arity refusal, - -plus `driver-turso`'s copied `RemoteTransport.uncompilableComparand`, so one -deployment does not disclose differently depending on its connection mode. -`driver-sqlite-wasm` inherits `SqlDriver`'s compiler and needed no source -change. - -**Who sees what.** A subtree positively marked `'author'` by a read-scope merge -boundary keeps the whole diagnostic, target column included. Everything else — -`'policy'`, unmarked, and ambiguous — receives the refusal's identity -(`INVALID_FILTER` / 400), which class fired, and the capability statement and -repair prescription with placeholder names; the naming half goes to the server -log. Unmarked withholds by design: the mark is permission to reveal, never a -requirement to prove secrecy, and any design where a missing mark lands on the -disclosing branch re-opens `#7929`. - -**The accepted cost, stated rather than hidden.** The author-vouch surface is -two call sites, and `plugin-security`'s is conditional on `ast.where` still -being the caller's verbatim object — which fails once `plugin-sharing` has -composed (`#8430`). Until that lands, an author on an object with active -sharing rules loses the target-field name from these messages. That is -fail-closed, and it is the price of the ruling rather than a defect. - -Redaction takes everything derived from the predicate — the target field, the -operator, the comparand preview, the filter path — for the reason `#7929` gave -when it withheld both operands rather than one: a comparand preview is the -administrator's literal just as surely as a column name is, and half a -redaction is none. diff --git a/.changeset/ja-jp-position-rename-damage.md b/.changeset/ja-jp-position-rename-damage.md deleted file mode 100644 index 20bd8e93cb..0000000000 --- a/.changeset/ja-jp-position-rename-damage.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@objectstack/plugin-sharing": patch ---- - -Repair the ADR-0090 `sys_role` → `sys_position` rename in the ja-JP object -translation bundle, and extend the mechanical guard to cover it. - -`sys_record_share.fields.recipient_id.help` still read "...ユーザー/グループ/ロールの -ID" — naming the pre-rename `role` concept — while the same bundle already -rendered the renamed concept correctly, twice, as `ポジション` -(`recipient_type.options.position` on both sharing objects), and the English -source for this exact leaf says `position`. Japanese-facing admins saw the -stale word in the Setup field-help tooltip for Record Share's `Recipient` field. - -`recipient-vocabulary-consistency.test.ts` (added when the es-ES half of this -same rename damage was repaired) now asserts a ja-JP stale-term rule alongside -the existing es-ES one, generalised into one per-locale table so a future -locale's rule is one entry, not a parallel `describe` block. The ja-JP pattern -excludes `ロールアップ` (rollup) and `ロールバック` (rollback) by lookahead rather -than `\b`, which does not bound katakana in JS regex (`\w` is ASCII-only) and -would otherwise match nothing at all. diff --git a/.changeset/last-admin-standing-keys-gate.md b/.changeset/last-admin-standing-keys-gate.md deleted file mode 100644 index 06108bb469..0000000000 --- a/.changeset/last-admin-standing-keys-gate.md +++ /dev/null @@ -1,59 +0,0 @@ ---- -"@objectstack/core": minor -"@objectstack/plugin-auth": minor ---- - -feat(security): bind the break-glass standing-key lists to what the authz resolver actually reads — the correspondence stops being prose (#8734) - -`plugin-auth`'s last-administrator guard (ADR-0024 D5.2) decides whether a -pending write can empty the administrator population by testing the payload -against three standing-key lists (`MEMBER_STANDING_KEYS`, -`GRANT_STANDING_KEYS`, `PERMISSION_SET_STANDING_KEYS`). A payload touching none -of them is skipped without any reads — so a column `resolveAuthzContext` starts -reading that a list omits is a write class the guard **silently stops judging**, -on the one path whose failure mode is an installation-wide administrator lockout -with no in-product recovery. - -Nothing bound the two together. The correspondence lived in a comment, and it -had already gone false once: #6084 wrote — naming `active` explicitly — that -everything a permission-set write touches other than `name` is invisible to "who -is an administrator". That was true when written; #8613 made `active` a -resolution-time predicate and the sentence became false. Nothing mechanical -would have caught it, because the guard's own tests stay green precisely when -the guard is never consulted. - -**The mechanism is two links, and the first one is a measurement.** - -- `@objectstack/core` now exports `ADMIN_STANDING_SURFACE` — declared beside the - resolver, listing every table the administrator-derivation path reads, each - classified `derives` or `reads-only` with its reason, and for the deriving - tables every column read. It is asserted **equal** to what the real - `resolveAuthzContext` reads, observed at runtime through a recording engine - that records every property access and every `where` key per table. Observation - rather than source extraction because the reads that matter have moved into - helpers: `active` is read by `isRowActive(row)` and the ADR-0091 window bounds - by `isGrantActive(row, now)`, neither named at the resolver's own call site — - the exact shape #8613 had. - -- `@objectstack/plugin-auth` now exports its standing-key lists plus - `STANDING_KEYS_BY_TABLE` and `STANDING_KEY_EXCLUSIONS`, and a gate requires - every column of that measured surface to have an answer: it is standing-bearing - (in a list) or it is excluded with the reason it cannot empty the administrator - population. There is no third state — the third state is what `active` was - between #6084 and #8613. - -So a resolver change that starts reading a new column fails at the first link -until the declaration is updated, and at the second until the guard has an -explicit answer for it. Landing #8613 green would have required writing down that -deactivating `admin_full_access` cannot empty the administrator population — -which is false, and which is what the old comment asserted by accident. - -**No guard behaviour changes.** Every list keeps exactly the values it had; the -gate is one-directional by construction (it can only ever demand that the guard -judges *more*), because the other direction would put pressure on a break-glass -guard to fire less often. - -The table-level half is covered too: a resolver that started deriving -administrator standing from a **new** table is invisible to any column-set -comparison, since the table is absent from both sides — so the surface enumerates -every table the path reads, and an unclassified one fails. diff --git a/.changeset/legacy-unique-guard-attribution.md b/.changeset/legacy-unique-guard-attribution.md deleted file mode 100644 index e6c1a3e80d..0000000000 --- a/.changeset/legacy-unique-guard-attribution.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -test(driver-sql): attribute each `legacyUniqueReplacements` guard to exactly one case (#8557) - -**`patch`, and deliberately not `none`.** This adds no runtime code and changes -no behaviour — every assertion is green on `main` before the change. The bump is -the floor rather than a skipped changeset because the file it protects is -release-relevant: what lands is the pin that makes a future single-guard -deletion visible, and the release notes for the version that first carries it -are the place a maintainer looks to learn the pin exists. A `minor` would claim -a capability; `none` would leave the protection undocumented at the only moment -anyone reads for it. - -The declared-index replacement arm's guards were **individually unpinned**: -measured on #8468, deleting the ADR-0120 S6 name-identity guard, or admitting a -declared bare `unique: true` through the scope filter, left the entire suite -green — including the two tests whose names say they cover exactly those cases. -The protection was real but collective, so no test attributed it to a line, and -a refactor could remove any single guard and be told nothing. - -`schema-drift.legacy-unique-guard-attribution.test.ts` adds that attribution. -The existing object-level suites are untouched — they are broader than any one -guard, which is why they could not do this job. - -- **Nine guards are individually attributable.** One input per guard, - constructed so only that guard can reject it, each paired with a **twin** — - the same input with the single property that guard reads changed, which must - produce exactly one replacement. The twin is the reachability witness: without - it a case would still pass while some earlier guard swallowed the input, which - is the failure mode being fixed, one level up. Measured: deleting any one of - the nine turns **exactly one** test red, and its name says which line went. -- **Five guards cannot be attributed at all**, because another guard rejects a - superset of their inputs — deleting one is behaviour-preserving for every - possible argument, so a test claiming to pin it would be lying. For those, - what is pinned is the **fact the domination rests on**, so the day it breaks - and the guard becomes load-bearing alone, something goes red. - -Behind the dominated S6 guard are the hand-written organization composites on -`sys_team`, `sys_business_unit` and `sys_member` — three shipped platform -objects on a spelling valid indefinitely. Those composites are now pinned -directly, in both the shipped bare-`true` spelling and the respelled -`'organization'` form. - -The bare-spelling case is the test-side half of a pair whose first half already -shipped: #8463 (PR #8512) put the same divergence into prose on -`isOrganizationScopedUnique`'s JSDoc, in this same file, with no test attributing -it. Routing the declared branch through the field predicate remains the rejected -option 1 of #8323 (maintainer ruling 2026-08-13), and is now refused by a test -rather than only by a comment. diff --git a/.changeset/list-diagnosed-consumer-sweep.md b/.changeset/list-diagnosed-consumer-sweep.md deleted file mode 100644 index 5dbd30920d..0000000000 --- a/.changeset/list-diagnosed-consumer-sweep.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@objectstack/service-datasource": minor -"@objectstack/runtime": minor -"@objectstack/mcp": minor ---- - -fix(runtime,mcp,service-datasource): the #6504 consumer sweep — three list consumers stop making claims a known-partial read cannot support (#6504) - - - -`IMetadataService.listDiagnosed?(type)` (PR #7721) lets a plural read say whether -its answer can be trusted as complete. This is the consumer half: the callers -that were restating a possibly-short listing as a fact about the environment. - -Each consumer was qualified individually, per PR #6051's discipline, and most -were left alone — a caller publishing a snapshot with no count has nothing to -mis-state. Three make a claim, and each now withholds exactly that claim while -still serving everything it could read: - -- **`removeDatasource` no longer deletes on a bound-object count it could not - take completely.** The guard `if (bound > 0) throw` is the only thing standing - in front of an irreversible delete that also unbinds the datasource's secret, - and its input is derived from the metadata service's object listing. During a - loader outage that listing goes silently short, and the worst value is the - benign one: `0` reads exactly like "nothing is bound", so the guard OPENED. - It now refuses with `SERVICE_UNAVAILABLE` / 503 — a dependency outage the - operator can retry, not a client error — and the record, its credential and - its pool all survive. -- **The MCP `list_objects` tool stops publishing `totalCount` on a known-partial - listing.** This is the same claim PR #7721 removed from the - `objectstack://objects` resource, on the other MCP primitive: same payload - shape, different door, never covered. A degraded read now serves the same - objects with `totalCount` **absent** and `partial` / `returnedCount` / - `warning` plus the 503 envelope in its place, so a client reading the total - gets `undefined` rather than a believable wrong integer. Both bridges - implement it — stdio (`@objectstack/mcp`) and HTTP (`@objectstack/runtime`) — - because a completeness claim must not depend on which transport a client - connected over. -- **The ADR-0015 §5.2 boot gate stops announcing an all-clear over a sweep it - could not complete.** It validated whatever `listObjects()` returned and then - logged *all federated objects match their remote schema*, with a count. - Federated objects behind an unreadable loader were never validated, so - `onMismatch: 'fail'` could not have fired for them. The gate now warns that - the swept set was incomplete and names what it did validate. ⛔ It does **not** - abort boot on a degraded metadata read: turning a transient outage into a - refusal to start would be a new failure mode bought with a diagnosis fix. - -Every new member is optional in the same way `listDiagnosed` itself is: a host -whose metadata service predates the verdict behaves exactly as it did before, -and a service without it reports nothing degraded — precisely what it could -express. diff --git a/.changeset/lucky-pugs-repeat.md b/.changeset/lucky-pugs-repeat.md deleted file mode 100644 index 3217487edf..0000000000 --- a/.changeset/lucky-pugs-repeat.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/service-analytics': patch ---- - -Source the comparand-type allow-list and the accepted-set refusal sentence from the shared `@objectstack/spec/data` door instead of re-spelling them locally. - -`comparand-shape.ts`'s `isBindableComparand` / `isRenderableTextComparand` spelled the same six accepted comparand types (`string | number | bigint | boolean | null | Date`) that `isAcceptedFilterComparand` single-sources for the SQL driver family, and two refusal messages hand-copied the accepted-set sentence. Both predicates now delegate the type membership to the door and quote `ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE`, matching how `driver-sql` and `driver-turso` consume it. - -No comparand is accepted or refused differently: the local copies already agreed with the door, and the full accept/refuse matrix is pinned end to end at both analytics filter doors, in three comparand positions each, measured before the change and re-run unchanged after it. - -One user-visible wording correction falls out of removing the copy: the hand-copied sentence omitted `bigint`, a type both predicates have always accepted and both doors have always compiled, so a refusal message under-described the values it accepts. The message now names the full set. The package-local extras — a binary bindable, and the `undefined` arm both doors already refuse upstream — are unchanged and recorded at their use sites. diff --git a/.changeset/mcp-http-bridge-merged-skill-read.md b/.changeset/mcp-http-bridge-merged-skill-read.md deleted file mode 100644 index 06e69e1d30..0000000000 --- a/.changeset/mcp-http-bridge-merged-skill-read.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(runtime): the HTTP MCP prompt bridge reads the merged skill listing, so a runtime meta PUT finally reaches `/api/v1/mcp` (#8726) - - - -`PUT /api/v1/meta/skill/{name}` with `{active:true}` returned 200 and the flip -was **not** reflected over MCP prompts. This is the second of the two skill -reads behind that symptom, and the one #8328's own three-step reproduction -actually runs through. - -The two surfaces read different layers: - -- **stdio** (long-lived server, `packages/mcp` → `bridgePrompts`) — fixed by - PR #8724. -- **HTTP** `/api/v1/mcp`, built **per request** by `packages/runtime` - (`domains/mcp.ts` → `buildMcpBridge.listSkills`) — this change. It read - `metadataService.list('skill')`, the registry/loader listing, one layer - **below** where any `sys_metadata` overlay merging happens. So the overlay row - the PUT wrote was never seen, while `GET /api/v1/meta/skill` served it - correctly from the merged read: two surfaces, one skill name, two answers. - -The read now goes through the protocol layer's `getMetaItems`, per the -maintainer's ruling on #8328 (2026-08-13, option 3) — and ⛔ **not** by pushing -the overlay merge down into `MetadataService.list()` for every consumer, which -is a wider contract change archived unscheduled as #8722. - -**Resolved per request, on the same per-environment seam `getMeta()` already -uses** — never captured once at boot, which on a multi-tenant host would serve -one environment's overlay rows to every other one. Pinned by two -multi-environment tests. - -**⛔ No fallback to the un-merged listing when the merged read throws.** That -would answer registry rows in the shape of merged ones — this exact defect, -restored silently at the moment the overlay store is unreadable, which is -precisely when an overlay is most likely to be the thing being missed. The -throw travels to the MCP client instead. Structural absence is treated as the -different thing it is: a host assembled without the metadata protocol has no -merged read to offer, so it keeps the registry listing unchanged, including the -load-bearing `?? []` for a host with no metadata service at all. - -**#6504's completeness verdict is added here rather than preserved** — unlike -the stdio bridge, this read never had a diagnosed wrapper, so a known-partial -skill surface presented as a complete one. The verdict is asked of -`IMetadataService.listDiagnosed` directly rather than taken from the merged -read, because `getMetaItems` swallows a MetadataService read failure into its -own `catch` and reports a merged list either way. It is reported at `warn` -(functional degradation: the prompt surface is visibly smaller than the -environment declares), and a verdict probe that itself fails is reported as -"could not be determined" rather than failing a read whose items succeeded. diff --git a/.changeset/mcp-prompt-bridge-merged-skill-read.md b/.changeset/mcp-prompt-bridge-merged-skill-read.md deleted file mode 100644 index 11d587d1cc..0000000000 --- a/.changeset/mcp-prompt-bridge-merged-skill-read.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/mcp": patch ---- - -fix(mcp): the skill prompt bridge reads the protocol's merged metadata listing, so a runtime `PUT /api/v1/meta/skill/` reaches MCP prompts (#8328) - -The bridge read `IMetadataService.list('skill')` — one layer below where the -`sys_metadata` overlay merge happens — so an override returned 200 and never -reached the prompt surface while `GET /api/v1/meta/skill` served it. The -long-lived (stdio) server's bridge now takes its items from the protocol's -`getMetaItems` when the host can supply it, and keeps the #6504 completeness -verdict by asking `listDiagnosed` for it alongside. A host assembled without the -metadata protocol reads exactly as before, and a merged read that throws does not -fall back to the un-merged listing. diff --git a/.changeset/memory-persistence-placeholder-refused.md b/.changeset/memory-persistence-placeholder-refused.md deleted file mode 100644 index 855814b500..0000000000 --- a/.changeset/memory-persistence-placeholder-refused.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): refuse `${…}` placeholder syntax in memory `persistence.path` / `persistence.key` at publish (#8495) - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -The #8336 defect one surface over: a `${…}` placeholder written in the memory -driver's persistence config (e.g. `persistence: { type: 'file', path: -'${DATA_DIR}/mem.json' }`) is resolved by **nothing** — the driver would create -and write a literal `./${DATA_DIR}/…` path, or write under the literal -placeholder-bearing localStorage key, with no error naming the unresolved -placeholder. #8336's ruling (refuse loudly at authoring time — the value was -authored under a false belief) applies to these two keys with its reason -intact: they are config-material like the connection keys, not record data. - -**What is refused:** a complete `${…}` span in memory `persistence.path` (file -persistence and the `auto` override) or `persistence.key` (localStorage and the -`auto` override) — the same shared judgment (`placeholderFree`) the -connection-material keys use, so the policy cannot drift per key. - -**What stays accepted:** every literal path/key byte-identically, including -placeholder-looking near-misses (`$VAR`, `{name}`, an unclosed `${`) — and the -memory driver's `initialData` stays deliberately **unjudged**: it carries -arbitrary record values, where a literal `${…}` may be legitimate data (the -mother ruling's deliberate memory-driver exclusion, which reached exactly as -far as its reason did). - -## FROM → TO - -```ts -// before — parsed green; the driver created a literal `./${DATA_DIR}/…` path -defineDatasource({ - name: 'scratch', driver: 'memory', - config: { persistence: { type: 'file', path: '${DATA_DIR}/scratch.json' } }, -}) - -// after — write the literal path (or leave it unset: the shared datasource -// factory scopes the default destination per datasource) -defineDatasource({ - name: 'scratch', driver: 'memory', - config: { persistence: { type: 'file', path: './data/scratch.json' } }, -}) -``` - -There is deliberately **no automatic rewrite**: the placeholder names a value -that exists only in the author's intended deployment environment, which a -source-file transform cannot know. `os migrate meta` surfaces the change as a -structured TODO (semantic entry `memory-persistence-placeholder-refused`, -protocol major 18 — this refusal is not part of the v17.0.0 cut). - - diff --git a/.changeset/meta-read-path-credential-redaction.md b/.changeset/meta-read-path-credential-redaction.md deleted file mode 100644 index 7e667ff6ed..0000000000 --- a/.changeset/meta-read-path-credential-redaction.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): the metadata read path no longer serves stored cleartext credentials (#8154) - -`decorateMetadataItem` returned the whole stored body, so a `datasource` row -written before #8078 closed the write door came back with `config.password` in -cleartext — and the password embedded in `config.url` alongside it — from -`GET /api/v1/meta/datasources`, from the single-item read, and from the layered -read in **both** its `overlay` and `effective` layers. PR #8126 closed the -datasource-admin door (`GET /api/v1/datasources/:name`); this closes the -platform door one over. Meta read permission is granted at a far lower bar than -"may see the production database password", which is what made this reachable. - -The fix consumes the per-type redactor registry #8300 landed in -`@objectstack/spec/kernel` (`getMetadataTypeRedactor`) rather than redacting -`datasource` specifically: `datasource` is that registry's first consumer, and a -type-shaped patch here would be the narrow fix that leaves the next -secret-bearing type exposed. A plugin whose metadata type stores secrets gets -the same protection by calling `registerMetadataTypeRedactor` — no change here. - -Three properties worth knowing, each measured rather than assumed: - -- **`_diagnostics` are still computed on the RAW stored body, before - redaction.** The redacted body is exactly the shape the post-#8078 schema - accepts, so computing them afterwards flips `valid:false` to `valid:true` on - precisely the rows that hold a stored credential — which would delete the - operator's only inventory of what still needs migrating (#8081 item 3). The - two steps are composed inside one function so no call site can invert an - ordering it cannot see. -- **The stored record is never mutated, and the connect path is untouched.** - Redaction is a serving act; datasource connection and boot-time restore read - `sys_metadata` directly through the data engine, not through these exits. -- **The write path carries the credential forward**, and this half is not - optional: `saveMetaItem` accepts a redacted body and persists the credential - away, so a read scrub shipped alone would convert today's loud `422` into - **silent credential deletion** on an ordinary GET → edit → PUT round trip. - `config.url` makes it unavoidable rather than a masking choice — a - URL-embedded password is schema-accepted, so dropping it round-trips to - deletion and masking it round-trips to storing the mask as the literal - password. Stored material is re-applied only where the incoming body is - indistinguishable from what the read served; anything the author actually - wrote wins and is still judged by #8078's write gate on its own merits. This - also restores the #4326 byte-identical round-trip invariant, which - read-redaction alone would have broken. - -It preserves cleartext already at rest and creates none; moving stored -credentials into `sys_secret` is #8081 item 3's migration and is deliberately -not attempted on a write door an author drove. diff --git a/.changeset/meta-unrecognised-type-refused.md b/.changeset/meta-unrecognised-type-refused.md deleted file mode 100644 index b2af06345b..0000000000 --- a/.changeset/meta-unrecognised-type-refused.md +++ /dev/null @@ -1,149 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/metadata-protocol": minor ---- - -fix(metadata): `PUT /meta/:type` refuses a type name the platform does not have, instead of minting a namespace for it (#8421) - - - - -**BREAKING** accept-set narrowing on a published HTTP surface, landing after the -v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). A write -that answered `200 {"success":true}` now answers `400 INVALID_REQUEST`: - -``` -PUT /api/v1/meta/fieldz/showcase_task.title - before → 200, sys_metadata row persisted with type='fieldz' - after → 400 INVALID_REQUEST, nothing persisted -``` - -`fieldz` — or any typo — was neither a declared metadata type nor a known plural -spelling of one, so the boundary classified it as PLUGIN-registered, which every -authorization gate is permissive toward by construction. The row was persisted -under a type nothing reads and nothing serves, and the caller was told it had -succeeded. That silence is the real cost: a metadata-type typo, from a human or -from generated code, produced `success: true` and no indication the type is not -real. - -**Why this is only now safe to refuse.** #7894 closed the sibling case (a plural -spelling of a type the platform DECLARES) and left this one open on purpose: a -static predicate cannot tell `fieldz` from a plugin kind, and the live-registry -alternative was measured to be worse than the defect — the live type set is -ITEM-POPULATED, so it omits every legitimate kind that has no items yet, which -is the state each kind is in immediately before its first create. What changed -is the platform, not the boundary's information: #8586 retired -`MetadataPluginConfig.additionalTypes` and with it the last channel by which a -plugin could DECLARE a metadata kind, so an unrecognised name can no longer be a -declaration this refusal has not heard about (maintainer ruling 2026-08-14). - -**What still passes, pinned in both directions.** Every declared type in -`DEFAULT_METADATA_TYPE_REGISTRY`, in canonical and REST-plural spelling; every -manifest spelling and the singular each folds to; and the six plugin kinds that -have no static registry entry at all — `theme`, `webhook`, `connector`, -`sharing_rule`, `analytics_cube`, `rag_pipeline`. `PUT /meta/theme/dark` on a -deployment with zero themes is explicitly covered, because that first create is -exactly what a live-registry check would have broken. - -**The refusal is scoped to the door that mints.** Reads still ANSWER: a running -kernel legitimately holds live type keys the static contract does not — `data`, -`kind` and `package` all enter the registry during an ordinary `registerApp`, -and `GET /api/v1/meta/types` lists that live set — so refusing unrecognised -names on the read path would answer 400 for types the same service advertises. -`DELETE` is untouched for the mirror-image reason: rows minted under an -unrecognised type before this change are real, nothing rewrites them on upgrade, -and refusing their deletion would turn the accumulation this fixes into an -accumulation nobody can clear. - -**…but one published ADVERTISEMENT narrows with it, and that is a second -behaviour change worth reading on its own.** `GET /api/v1/meta/types` keeps -listing every live type, and every entry keeps every field — what changes is the -VALUE of one boolean: - -``` -GET /api/v1/meta/types → entries[] where type ∈ {policy, data, package, kind} - before → allowRuntimeCreate: true - after → allowRuntimeCreate: false -``` - -The listing synthesised `allowRuntimeCreate: true` for every live type with no -static registry entry, on the same expired premise as the write door: a name the -registry does not carry might be a kind some plugin declared. It now derives that -flag from the SAME predicate the mint door enforces, so the two endpoints agree -by construction instead of via two rules maintained apart. Nothing ever honoured -a runtime create on those four — they are internal bookkeeping (seed datasets, -package rows, kind descriptors) — so the advertisement was a promise the platform -did not keep, which is the same defect this card is about, relocated to the read -door. Direct precedent: `api` declared `allowRuntimeCreate: true`, the runtime -never honoured it, and the 2026-08-07 ruling removed the declaration rather than -converging the read path onto it. - -⛔ The six plugin kinds with no registry entry — `theme`, `webhook`, `connector`, -`sharing_rule`, `analytics_cube`, `rag_pipeline` — are **not** affected: they are -in the static spelling contract, stay advertised `allowRuntimeCreate: true`, and -stay mintable. A UI reading this field (Setup → Metadata, the Studio designers) -therefore loses create affordances on exactly the four types whose creates were -already refused, and keeps them everywhere else. - -**The premise behind both halves is a CURRENT posture, not a closed door.** -Maintainer ruling, 2026-08-15, verbatim and untranslated: -暂时不考虑让插件申明新的元数据类型 — plugins do not declare new metadata types -*for now*. That word is recorded deliberately: plugin-declared kinds were -considered and deferred, not ruled out. If they are ever wanted, the two sites -that encode the deferral name it and its date in place — -`getMetaTypes()`'s synthesis and `isRuntimeCreateAllowed` in -`@objectstack/metadata-protocol` — so the decision is findable rather than -re-derived from the code's silence. - -**Two shapes reaching the mint door are exempt, and each is a fact about the -request rather than a claim the caller makes.** - -1. *The COMPOUND arity carries an OBJECT name in the `:type` segment.* - `PUT /api/v1/meta/lead/views/all_leads` is `type='lead'`, - `name='views/all_leads'` — one operation reaching one save, the shape both - the runtime dispatcher and the REST route document verbatim. `lead` is an - object, i.e. runtime data no static contract can enumerate, so a type verdict - applied there would refuse every object name that is not coincidentally a - metadata type. The ruling is about metadata TYPE names like `fieldz`. - ⚠️ Residue, stated rather than hidden: `PUT /meta/fieldz/a/b` is therefore - still accepted, because at that arity `fieldz` is a claim about an object and - the only way to check it is the live-registry lookup this card ruled out. -2. *A namespace that already exists is not being minted.* `duplicatePackage` - re-saves every row of a package under a new name, taking each type from the - stored row — measured: a package holding one pre-existing residue row - answered `{success: false, copiedCount: 0, failedCount: 1}`, i.e. could not - be duplicated at all. That contradicts the `DELETE` reasoning above, so the - store (never the request) exempts a type that already has rows. The probe - runs only once the refusal has already fired, and a store that cannot answer - refuses — a fresh deployment has no residue to protect. - `migrate meta --stored` was read as a third victim and measured NOT to be - one: an unrecognised type has no manifest collection, hence no ADR-0087 - chain, hence no notice, so such a row is reported `canonical` and the mint - door is never reached. - -**What breaks.** A caller creating metadata at runtime, at the simple arity, -under a type name that is in neither half of the static spelling contract and -has no rows already. That set is **not** empty in this repo — measured on -`objectql`, `runtime` and `rest`, three in-tree fixtures minted `trigger` (a kind -ADR-0088 retired outright), `policy`, and a synthetic `my_plugin_kind`. All three -are corrected here rather than exempted, and each for its own reason: the -`trigger` specimens were debt independent of any ruling (a retired kind cannot -demonstrate a live tier, and they were green only through the hole this card -closes), `policy` becomes a refusal case of its own, and #7894's control keeps -its `metaUrlSpellingRefusal` claim while its boundary expectation follows the -narrowing. An out-of-tree plugin that made its kind live by registering an item -of it, and then accepted runtime writes to that kind through `/meta`, needs its -spelling in the contract; there is no declared-kind channel to register one -through today — that is the trade #8586's retirement made, and the `暂时` above -is what makes it revisitable. - -`@objectstack/spec` gains one export, `unrecognisedMetaTypeRefusal`, alongside -the #7894 verdict it deliberately does not merge with: one says *you spelled a -declared type wrongly* and can name the replacement, the other says *there is no -such type* and never guesses. The residue pin #7894 left behind -(`metadata-url-spelling.test.ts`, the case that asserted `fieldz` was refused by -nobody) is **flipped, not deleted**. ⚠️ #7894's positive control keeps its own -claim intact — `metaUrlSpellingRefusal` still cannot refuse a kind that is a -misspelling of nothing, which is what makes that control true by construction — -but the BOUNDARY it drives now refuses six of the twelve names it exercises, -and that case says so in place rather than leaving it to inference. diff --git a/.changeset/metadata-422-container-issue-descent.md b/.changeset/metadata-422-container-issue-descent.md deleted file mode 100644 index 1bb76d300c..0000000000 --- a/.changeset/metadata-422-container-issue-descent.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata): the `422 INVALID_METADATA` envelope descends `invalid_key` / `invalid_element`, so a rejected record key arrives with the rule it broke (#8783) - -Zod raises a `z.record` / `z.map` **key** rejection as `invalid_key` and a -`z.map` **element** rejection as `invalid_element`, and in both cases the -issue's own `message` is a bare wrapper — `"Invalid key in record"` — with the -real diagnosis one level down in `issue.issues`. That is structurally the -`invalid_union` shape #4971 named: the prescription is produced and then -dropped by a walk that reads only the top level. - -Both `packages/spec` walks learned to descend those codes in #5389. -`zodIssuesToMetadataIssues` — the walk behind `saveMetaItem`'s 422 (#5364) and -the read path's diagnostics (#5598) — expanded `invalid_union` only, so it -stopped at the wrapper. Three walks over one `safeParse`, two of them reaching -the prescription and the Studio-facing one not. - -**It was reachable from ordinary authored metadata, not synthetic.** -`ObjectSchema.fields` is a record whose KEY schema carries the snake_case rule -(`spec/src/data/object.zod.ts`), and `object` is in the builtin -`getMetadataTypeSchema` registry. So the commonest authoring mistake on the -most-authored metadata type — writing `firstName` for a field key, which is -exactly what an agent coming from JS naming writes — produced: - -``` -{ path: 'fields.firstName', code: 'invalid_key', message: 'Invalid key in record' } -``` - -The author was told a key was invalid and never told what a valid one looks -like, so the next move was to guess. The declared message existed and was -correct; it just did not reach anyone. Now the same save answers: - -``` -{ path: 'fields.firstName', code: 'invalid_key', message: 'Invalid key in record' } -{ path: 'fields.firstName', code: 'invalid_format', message: 'Field names must be lowercase snake_case (e.g., "first_name", …)' } -``` - -**Additive, and matched to the walks that already worked** rather than chosen. -The other two were measured over the card's own repro first: `formatZodIssue` -prints the wrapper line then the indented detail, and `zodIssuesToFields` emits -the `invalid_shape` wrapper entry then the detail entry. So the wrapper stays at -index 0 — it is the only entry naming the slot the client sent, and Studio's -designer keys on it — and the detail joins it on the same path. No entry that -shipped before is removed or renumbered. - -**Targeted, not a widened walk.** Only the two container codes open the descent; -an `issues` array hanging off any other code is still ignored, `invalid_union` -still expands through the unchanged ranking, and the nesting bound now covers -both descents at the same depth of 3. Container issues are deliberately *not* -ranked the way union branches are: a union's branches are competing candidates, -while a container has one inner schema, so every issue it raised is a true -statement about the value. - -The verdict is unchanged in every case — this moves what a refusal *says*, never -whether it is one. `union-branch-policy.cross-package-parity.test.ts` gains a §5 -comparing the container descent across all three walks; its §1 (the policy is -not publicly exported from `@objectstack/spec`, so this package must run its own -copy) is untouched, and no export was added. diff --git a/.changeset/metadata-plugin-additional-types-retired.md b/.changeset/metadata-plugin-additional-types-retired.md deleted file mode 100644 index 7324bccf10..0000000000 --- a/.changeset/metadata-plugin-additional-types-retired.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): retire the inert `additionalTypes` key from `MetadataPluginConfig` (#8586, ADR-0049) - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -`MetadataPluginConfig.additionalTypes` was declared, authorable, and documented -on four docs pages as THE way a plugin registers a custom metadata type — and -read by **nothing**. The only production writer of the manager's type registry -is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)`, called exactly once, and -it replaces the array outright: measured on the real `MetadataManager`, -declared count == live count (27 == 27). An author who followed the published -instructions wrote the key, got no error, and nothing happened — the #4212 -`onInstall` silence trap one level down (maintainer-ruled REMOVE, 2026-08-14). - -**What is refused:** an authored `additionalTypes` on `MetadataPluginConfig` -(inline or via the manifest's `config` embed). The key is a `retiredKey()` -tombstone — the schema is not `.strict()`, so a plain deletion would have -silently stripped it — refused at `tsc` (typed `never`) and at the parse -(`invalid_type` at path `additionalTypes`, message carrying the prescription). - -**What stays accepted:** every `MetadataPluginConfig` without the key, -byte-identically. Runtime behaviour is unchanged: nothing ever read the key, -so removing it removes no behaviour. - -The retirement kit: - -- tombstone at the schema (`packages/spec/src/kernel/metadata-plugin.zod.ts`) -- ADR-0087 registration: retired-key entry - `kernel/MetadataPluginConfig:additionalTypes` + D3 semantic entry - `metadata-plugin-additional-types-retired`, both under protocol 18 (no D2 - conversion — a plugin config is not a stack collection member, the - `kernel/Manifest:loading` precedent) -- pin tests (`additional-types-retirement.test.ts`) -- docs corrected: `content/docs/plugins/adding-a-metadata-type.mdx` (four - sites) now describes how a kind actually enters the live set — as a side - effect of registering an item of that kind; the generated reference page - follows the schema -- the two source comments that asserted the phantom growth path - (`metadata-manager.ts`, `metadata-protocol/src/protocol.ts`) and the - `registerMetadataTypeSchema` doc note corrected - -## FROM → TO - -```ts -// before — parsed green; the entries were merged into nothing -const config: MetadataPluginConfig = { - storage: {}, - additionalTypes: [{ type: 'chart', label: 'Chart', filePatterns: ['**/*.chart.ts'], domain: 'ui' }], -}; - -// after — delete the key; register items of the kind instead, and bind its schema -const config: MetadataPluginConfig = { storage: {} }; -// in the plugin: registerMetadataTypeSchema('chart', ChartSchema) from init(ctx); -// the kind enters the live set when an item of it is registered. -``` - - diff --git a/.changeset/mighty-rocks-jump.md b/.changeset/mighty-rocks-jump.md deleted file mode 100644 index e0874eeeb0..0000000000 --- a/.changeset/mighty-rocks-jump.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -**`DELETE` / `PATCH` / `POST` on the dispatcher's `/metadata/:type/:name` are refused with `405` instead of being answered as reads.** - -The `parts.length >= 2` block carried exactly one method-sensitive branch — the `PUT` save — and the read that followed it had no method guard, so every other verb fell into it and was served the ordinary metadata read. `DELETE` was the sharpest case: a caller asking to delete a metadata item received `200` plus the item document, which is indistinguishable from a successful destructive call, while nothing was deleted and `protocol.deleteMetaItem` was never invoked. No status, header or field separated any of those answers from a real `GET`. - -The block now answers `405 METHOD_NOT_ALLOWED` with an `Allow: GET, HEAD, PUT` header naming what it serves, aligning it with every other route in the same file (which already guard their verb). `GET`, `HEAD` and `PUT` are unchanged, and a request that passes no method still defaults to the read. - -Note this narrows an accepted surface: a client that was relying on `DELETE`/`PATCH`/`POST` returning the document now gets a `405`. It never performed the operation the verb named — use `GET` to read, or `packages/rest`'s `DELETE /api/v1/meta/:type/:name` for a real metadata delete. diff --git a/.changeset/mysql-dsn-bound-secret.md b/.changeset/mysql-dsn-bound-secret.md deleted file mode 100644 index 380c76d31f..0000000000 --- a/.changeset/mysql-dsn-bound-secret.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -"@objectstack/service-datasource": patch ---- - -fix(service-datasource): a bound `external.credentialsRef` reaches the mysql client on the DSN branch instead of being dropped (#8696) - - - -`DatasourceConnectionService` resolves a datasource's `external.credentialsRef` -to a cleartext secret and hands it to the driver factory as `spec.secret`. The -mysql arm then **threw it away** whenever `config.url` was present: the DSN -string became the whole knex `connection`, and the resolved credential reached -nothing. Measured on `origin/main`, driver `mysql`, `config.url` -`mysql://app@db.internal:3306/app`, secret bound: - -```text -knex connection: typeof=string value="mysql://app@db.internal:3306/app" -``` - -**This is a broken binding, not a disclosure.** Since #8082 refuses a -`user:password@` userinfo at the publish door, a bare-username DSN plus a bound -secret is the *only* authorable URL shape for this driver — the exact shape the -connection form produces and the exact shape #8155's re-homing remedy tells -operators to write. Such a datasource therefore connected **unauthenticated**, -or failed with a driver-level auth error naming nothing about the binding, while -its Setup page showed a credential bound and the connect path reported success. -It is the declared-≠-enforced shape one layer below Prime Directive #10: -`MysqlConfigSchema.url` already states the contract this code failed to keep — -*"bind the secret … and it is injected at connect time. A bare username -(`user@host`) stays writable."* - -**The fix hands mysql2 the DSN and the secret together** — `{ uri, password }` -rather than a hand-parsed URL. mysql2 keeps owning its own DSN grammar (no URL -parsing, no re-encoding, no second dialect of `mysql://…` in this repo), and its -merge gives the **explicit** key precedence, so the bound credential also wins -over a legacy password embedded in a stored pre-#8082 row — the precedence the -postgres arm's DSN branch already declares. Measured on mysql2 3.23.1, knex -3.3.0 and pg 8.22.0. - -A DSN with **nothing bound passes through unchanged**, as the bare string it has -always been, so the entire blast radius is datasources that bind a secret — the -ones that are broken today. - -Two measured findings this change deliberately does **not** act on, each filed -on its own: - -- **The mongodb arm is still open.** `buildMongoUrl`'s `if (explicit) return - explicit;` drops the bound secret the same way, so a mongo DSN datasource - still reaches `MongoClient` with an **empty** password. The remedy is not a URL - rewrite — `MongoClient`'s `auth` option injects beside an unmodified url, and - it wins over an embedded userinfo password (measured on mongodb 7.5.0) — but it - requires a username as well, and reading the url's userinfo username needs the - platform's own DSN grammar (`new URL()` rejects the multi-host form - `MongoConfigSchema` documents). `@objectstack/spec/data` exports the password - half of that grammar and no username half; adding one belongs beside it rather - than as a second copy of the userinfo boundaries here. -- **The postgres arm passes this assertion at the config layer and is broken one - layer below it.** `pg` merges `parse(connectionString)` **over** the explicit - `password`, so `{connectionString, password}` resolves to the DSN's own - (absent) password — effective `password: null`, measured on pg 8.22.0. Its - `if (url)` branch is not fixed by symmetry with this one; the two clients merge - in opposite directions, which is why each arm's precedence is measured rather - than assumed. diff --git a/.changeset/mysql-unbacked-conflict-target-preflight.md b/.changeset/mysql-unbacked-conflict-target-preflight.md deleted file mode 100644 index f05981d3fa..0000000000 --- a/.changeset/mysql-unbacked-conflict-target-preflight.md +++ /dev/null @@ -1,68 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -fix(driver-sql): MySQL refuses an upsert whose `conflictKeys` no PRIMARY KEY or UNIQUE index backs — calls that previously "resolved" now throw (#8621) - -**This narrows MySQL's accept set.** A `SqlDriver.upsert(object, data, conflictKeys)` -call on MySQL whose conflict target is backed by no PRIMARY KEY and no UNIQUE -index used to resolve; it now throws `VALIDATION_ERROR` / 400. That is why this -is a `minor` and not a patch: code that ran without error against MySQL will -start failing, deliberately, and the rows it was writing were not the rows the -caller asked for. - -SQLite and Postgres have refused this exact call since #8445 / #8567, with this -exact sentence. MySQL did not, and could not: knex compiles -`onConflict([...]).merge(...)` on `mysql2` to `ON DUPLICATE KEY UPDATE`, which -takes **no conflict target at all**, so the named keys are dropped before the -statement leaves the process and the server is never asked to find an index for -them. The existing refusal classifies an error the server raised, so on MySQL it -had nothing to classify. - -Measured on live MySQL 8.0.46 — `email` is the column the caller names, `tax_id` -carries the only unique index: - -``` -seed upsert({email:'a@b.com', tax_id:'T-1', title:'first'}, ['email']) -> resolved -B upsert({email:'other@b.com', tax_id:'T-1', title:'second'}, ['email']) -> resolved - ONE row: merged on `tax_id`, which the caller never named, across two - different `email` values. -D seed, then upsert({email:'a@b.com', tax_id:'T-2'}, ['email']) -> resolved - TWO rows, both `email='a@b.com'`: the merge that WAS asked for did not - happen either. -``` - -So the failure being replaced is not an illegible error — it is a silent wrong -write. `upsert` now consults the table's physical keys before compiling on MySQL -and answers the wording, `code` and `status` the other two dialects already -answer (#5240 — one condition, one wording). - -**What this means for an existing MySQL deployment.** The calls that change are -exactly those naming a conflict target no key covers — the same calls that have -always been errors on SQLite and Postgres. The most likely one to surface is a -tenant-scoped `unique: true` field: its index materializes as the composite -`(COALESCE(organization_id, '__global__'), field)` (ADR-0120 D3), so -`conflictKeys: ['field']` alone is not backed by it. The remedy is the one the -refusal already prints: declare the column(s) `unique: true` and re-run schema -sync, name the full composite, or upsert on the primary key. - -Deliberately unchanged: - -- **SQLite and Postgres.** They already refuse this from the server, and they - attach the server's own sentence as `cause` — ground truth a pre-flight cannot - reconstruct. Running the pre-flight there would replace a planner verdict with - an introspection verdict for no gain. -- **The default `['id']` path.** The pre-flight runs only when the caller names - a target; the default is this driver's own primary key on every table it - creates, so probing it would add a round trip to every ordinary upsert to - answer a question with only one possible answer. -- **Anything the pre-flight cannot prove.** A failed introspection, a table - reporting no keys at all (indistinguishable from a table that does not exist), - and a possibly stale cache all proceed rather than refuse — the cache is - re-read from the database before any refusal is thrown. - -**Not fixed here, and filed as #8755:** `ON DUPLICATE KEY UPDATE` carries no -conflict target even when the named one IS backed, so on MySQL a second unique -index can still absorb the conflict and merge on a key the caller never named. -This change closes the unbacked-target hole; it does not make MySQL honour -`conflictKeys` as a target. diff --git a/.changeset/mysql-upsert-ambiguous-conflict-target.md b/.changeset/mysql-upsert-ambiguous-conflict-target.md deleted file mode 100644 index 16149d5518..0000000000 --- a/.changeset/mysql-upsert-ambiguous-conflict-target.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/driver-sql": minor ---- - -fix(driver-sql): refuse a MySQL upsert whose named conflict target another UNIQUE key can absorb (#8755) - -`ON DUPLICATE KEY UPDATE` — the only merge statement MySQL compiles — carries no -conflict target, so the merge lands on whichever UNIQUE key the row collides with -first. `#8621` closed the half where nothing backed the named target; this closes -the half where the target IS backed and a *second* UNIQUE key absorbs the -conflict instead. - -Measured on live MySQL 8.0.46, `email` and `tax_id` both `unique: true`, the -caller naming `email`: the second upsert merged on `tax_id`, across two different -values of the named key, leaving one row and no error. The identical call on -SQLite and PostgreSQL raises `UNIQUE constraint failed: …tax_id` and leaves the -seeded row untouched. - -**Accept-set change, MySQL only.** An `upsert(object, data, conflictKeys)` naming -a non-primary target on a table that carries any other UNIQUE key is now refused -before the statement is compiled — `code: 'VALIDATION_ERROR'`, `status: 400`, -nothing written and no auto-number reserved. The message names the colliding -index and both workarounds: drop or rename the extra UNIQUE key, or run the -object on a dialect that honours the target. - -Deliberately unchanged: a table whose only UNIQUE key IS the conflict target (the -common shape) merges exactly as before, as do the `conflictKeys`-less default and -an explicitly named primary key. The MySQL dialect limit and that residue are -documented under *Database Drivers → MySQL*. diff --git a/.changeset/package-delete-driver-fault-status.md b/.changeset/package-delete-driver-fault-status.md deleted file mode 100644 index f0a1927d25..0000000000 --- a/.changeset/package-delete-driver-fault-status.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@objectstack/service-package": patch -"@objectstack/rest": patch ---- - -fix(rest): `DELETE /api/v1/packages/:id` answers a driver fault as a 5xx, and stops swallowing coded refusals (#8275) - -`packageService.delete` swallowed every throw and reported failure by returning -a bare `{ success: false }`, so the door answered -`400 PACKAGE_DELETE_FAILED`. The statement behind it is -`DELETE FROM sys_packages WHERE id = ? [AND version = ?]`, so a missing table, a -lock timeout or a foreign-key restriction — a **server** fault — was answered as -a client error: it invited the caller to fix a request that was never the -problem, and it hid a real fault from every dashboard that buckets by status. - -This is the sibling of what #8016 fixed on the throw path and #8131 fixed for -`publish`. `service-package` had been left **partially converted** by #8131 — -the same service answering two different classifications for the same kind of -fault — and this closes that. - -**Two changes, both small:** - -- `delete`'s catch re-throws a throw that **declares its own status**, so a - coded refusal reachable from this call path keeps the producer's status and - code through the door's #8016 mapping (a `409 DESTRUCTIVE_CHANGE` stays a - 409) instead of being flattened into one 400. It reuses the existing - `declaresHttpAnswer` predicate rather than declaring a second one. -- an undeclared throw stays a returned failure, and the door answers it **500**. - -⛔ The discriminant is the **status** channel, never `.code`. Every SQL driver -populates a string `code` on its errors (`ERR_SQLITE_ERROR`, `SQLITE_ERROR`, the -SQLSTATE `42P01`, `ER_NO_SUCH_TABLE`), so a `.code`-reading predicate re-throws -genuine driver faults as if they were refusals — resolving them to a `500 -INTERNAL_ERROR` that carries the driver's own message. Pinned per dialect in -`delete-driver-fault.test.ts`, on this seam rather than inherited from -`publish`'s suite by analogy. - -**4xx is not swept**, which is the other half of the fix: the -repeated-`?version=` refusal is checked before `delete` is called at all, -`PACKAGE_DELETE_PARTIAL` keeps its 400 (per-item uninstall failures are a -different outcome), a declared 4xx thrown from below keeps its own status and -code, and a declared 5xx keeps its own too. - -**No message changed, and that is deliberate.** Unlike `publish`, this path -never disclosed anything: the door builds its sentence from the request's own -`:id` and `?version=`, and the producer returns a bare flag with **no message -channel at all**. Mirroring `publish`'s `driverFault` message here for symmetry -would have *created* a channel to the wire that nothing filters — the 5xx -withhold (#8086) lives in `sendThrownError`, which a returned failure never -reaches at any status. The new suites pin that absence from both sides: the -producer's returned shape has exactly one key, and the door answers its own -sentence even when handed a producer that grows a message. - -Verified against a real `node:sqlite` database running the real statements from -`index.ts` — including a genuine foreign-key restriction, the fault family only -`DELETE` can have. diff --git a/.changeset/per-organization-audience-binding-suggestions.md b/.changeset/per-organization-audience-binding-suggestions.md deleted file mode 100644 index 2175c57951..0000000000 --- a/.changeset/per-organization-audience-binding-suggestions.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -Reconcile audience-binding suggestions per organization (ADR-0090 D5/D9) - -`sys_audience_binding_suggestion` rows are per-tenant by construction — a -package suggests, and a TENANT admin confirms — but the reconciler read and -wrote through a module-level `{ isSystem: true }` context carrying no tenant. -On a shared-runtime multi-organization installation that produced ONE -organization-less row that every tenant read: the first admin to confirm or -dismiss answered for all of them, while the binding their confirm created -existed only in their own organization, so every other tenant's users never -received the package's default permission set and the surface reported the -suggestion resolved. - -- every read and write in the module now carries `{ isSystem: true, tenantId }` - — the anchor lookup, the "is it already bound?" lookup, and the - list/confirm/dismiss paths, not just the writes; -- `reconcileAudienceBindingSuggestions` is the new entry point the runtime - calls: one pass per organization under a `group`/`isolated` posture, and the - publishing organization alone on the package-door publish path; -- pre-existing organization-less rows are reaped before the passes and - regenerated per organization. Without that, ADR-0120 D3's platform bucket - keeps showing the old row to every tenant and the per-organization passes - create nothing at all. No permission binding is touched by the reap. - -A `single`-posture deployment is unchanged: exactly one organization-less pass, -and no reap. diff --git a/.changeset/plump-crabs-sneeze.md b/.changeset/plump-crabs-sneeze.md deleted file mode 100644 index 7adb0b1016..0000000000 --- a/.changeset/plump-crabs-sneeze.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(security): the derived capability seeder owns its row by the same conjunction as the curated half - -`bootstrapSystemCapabilities`' DERIVED half tested ownership with `managed_by === 'platform'` alone. That was sufficient while `sys_capability.name` was unique installation-wide; since #8461 made it unique per ORGANIZATION (ADR-0120 D1) it also admits a platform-STAMPED row sitting inside an organization — the shape the file header names ("from seed data or a legacy import") and the shape #8470 refused to let `managed_by` alone stand for on the curated half, because it "would not carry that guarantee". The guard admitted such a row and rewrote its `label`/`description` with `humanize(name)`, which is the precise harm #5876 exists to prevent, while the platform (NULL-organization) bucket was never written. Every counter read zero and nothing was logged, because both #5876's counter and #8536's live on the branch where the guard DECLINES. - -The ownership test is now the same conjunction the curated half uses — `managed_by: 'platform'` AND `organization_id: null`. The lookup is unchanged (still cross-organization, by design). This restores a declared invariant rather than widening an accept set: what the derived half may refresh narrows to the rows it provably owns. - -**Reachability: a DORMANT asymmetry with a LIVE route — not a live defect.** No shipped artifact in this repository produces such a row: both capability seeders run under a system context with no tenant and never write `organization_id`, `normalizeManagedByVocab` does not touch this object, the admin door refuses the stamp outright (`assertSystemRowWriteGate`), and no `sys_capability` seed dataset exists anywhere in the repo. The ROUTE is nevertheless live and needs no unsupported step, and its load-bearing link is measured rather than argued: the seed loader writes as `isSystem` specifically so seeds can target `sys_*` tables, `defineSeed` type-checks `managed_by: 'platform'`, and on a per-organization replay the loader's tenant stamp short-circuits its own `sys_` exemption when an organization is pinned. Measured against the real seed loader, a `sys_capability` seed carrying `managed_by: 'platform'` was inserted with `organization_id` set when an organization was pinned, and inserted unstamped when none was — so the stamp is the pinning's doing, not a fixture artifact. Not claimed: how many organizations a given deployment replays seeds into is a provisioning question this repo cannot answer. So the fix lands as trap-removal and invariant-restoration, at exactly that severity — worth landing because the mistake would be invisible, ADR-0066 asset ownership forbidding the organization's own admin from editing or deleting the row through Setup. - -**Observability.** The newly-declined row flows through #8536's skip branch unchanged, so `skippedAuthored` and `unseededDerived` keep their exact documented meanings and their subset relationship; they simply become reachable on a state the broken guard used to swallow. The misplaced stamp gets its OWN signal, a new `platformStampedInOrg` counter on `CapabilitySeedResult`, rather than being folded into `unseededDerived` — "the platform's definition is missing" and "a row wears the platform's stamp where the platform never writes" are different facts, and the second is worth counting even when the first is false. The warning gains a matching remediation arm; the admin-authored row's "supported extension" sentence would be false here, and its "nothing for an operator to remove" advice would be wrong about the one row Setup cannot touch at all. - -**Not changed:** the platform bucket is still not backfilled when another row satisfies the lookup. That is #8552's ruled posture (no adoption, no backfill), shipped for the admin-authored case in #8536; the fix makes the state observable, not repaired, and the suite pins the bucket ABSENT so a future backfill has to fail rather than pass. - -`patch`, not `minor`: the behaviour change is a guard declining a row it should never have rewritten, plus diagnostics. `platformStampedInOrg` is a new field on a returned result object, but `bootstrapSystemCapabilities` is a boot-time internal whose only caller ignores the result shape — no consumer reads the type, so nothing gains a capability it can build on. diff --git a/.changeset/preflight-refusal-audit-row.md b/.changeset/preflight-refusal-audit-row.md deleted file mode 100644 index b9aa1afee3..0000000000 --- a/.changeset/preflight-refusal-audit-row.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch -"@objectstack/metadata-core": patch ---- - -fix(metadata-protocol): a package publish refused by the namespace-prefix rule now leaves an audit row per violation (#8595) - -`publishPackageDrafts` refuses a whole batch pre-flight when an object draft's -name is missing its package namespace prefix (ADR-0028). That refusal returns -ABOVE the batch's `engine.transaction()`, so it reached neither the post-commit -`allowed` rows nor the rollback handler's `batch_aborted` row: it wrote nothing -to `sys_metadata_audit` at all. The compliance consequence is the defect — a -package rejected for a bad object name was **indistinguishable in the trail from -a package nobody ever pressed Publish on**, so a compliance query could not tell -a refused publish from one that never happened. - -Each violation now leaves its own `publish` / `denied` row keyed on the -offending draft's `(type, name)` — the tuple `auditMetaItem` reads, so the -refusal is visible on that item's own audit-log tab via -`GET /api/v1/meta/:type/:name/audit`. The row carries the violated rule -(`namespace_prefix`) as its `code`, and the rule's actionable message as `note`. -Rows are keyed on the draft's own organization scope, matching the promoted -rows: an env-wide draft audits env-wide even when the publishing session carries -an active org. - -One row per violation rather than one per batch: a pre-flight refusal names N -violating items and no single causal one, so a batch-level row would have had to -mint a synthetic identity — exactly what the `batch_aborted` row declines to do -for its own unattributable case. diff --git a/.changeset/publish-meta-canonical-fold.md b/.changeset/publish-meta-canonical-fold.md deleted file mode 100644 index ee89aec3f7..0000000000 --- a/.changeset/publish-meta-canonical-fold.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): route `publishMetaItem` through the `/meta` canonical-type fold (#8769) - -`canonicalizeMetaRequestType` is the `/meta` request boundary, and its own -header describes it as the fold "all six entry points funnel through". -`publishMetaItem` is a **seventh** entry point on the same URL family -(`/api/v1/meta/:type/:name/publish` and the `…/published` overlay) and did not -funnel through it: it reached the draftability check through -`PLURAL_TO_SINGULAR`, the MANIFEST-COLLECTION map, which is the exact lookup -#7894 replaced at the other six. One contract, two dialects, decided by which -verb you used (Prime Directive #12). - -The fix is the same one line the other six carry, at the top of the method. What -that line reaches — measured on `origin/main`, not inferred — differs by whether -the type is in the manifest map, and the two halves are not the same severity: - -**The four manifest-absent types — fail-closed, but closed for the wrong reason -and with the wrong verdict.** `field`, `seed`, `external_catalog` and -`translation` are legitimately absent from `PLURAL_TO_SINGULAR` (they are not -stack collections; that absence is precisely why #7894 moved the boundary onto -the URL map). Unfolded, they arrived at the draftability check as unrecognised, -where `isRuntimeCreateAllowed`'s "no static registry entry ⇒ this is a -plugin-registered kind" arm answers **true** — the permissive plugin branch, -taken for a type the platform itself declares. So a publish addressed -`/meta/fields/showcase_task.title` PASSED a gate that `/meta/field/...` answers -`403 NOT_OVERRIDABLE`, and only failed further down, on `404 no_draft`, having -already forgotten which type it was judging. A publish addressed -`/meta/translations/zh_cn` likewise never resolved the draft that -`PUT /meta/translations/zh_cn` had folded and written under `translation`. After -the fold: the first is refused `403 NOT_OVERRIDABLE` by its real registry entry, -the second promotes the row it names. - -**Manifest-present types — one lookup that did NOT fail closed.** -`promoteDraftForPublish` folds through the manifest map before the row lookup, -so a publish addressed `/meta/views/case_grid` always resolved the canonical -row. `getEffectiveLock` does not agree with it: its artifact limb folds, its -**overlay limb queries `sys_metadata` with the raw `type`**. Addressed with the -plural, the ADR-0010 `_lock` carried by the stored active row was looked up -under a `type` no row has and came back `'none'` — which is not a neutral value, -it is the verdict "the author declared no protection" (#5706) — while the -promote one line later read the folded key and overwrote the row the lock -protected. Measured on `origin/main`: `_lock: 'no-overlay'` plus a pending -draft, canonical spelling `403 ITEM_LOCKED`, plural spelling **200 and the -active body replaced**. - -That window is narrow and is stated at its real width rather than rounded up: it -needs an environment kernel (the gate is skipped wholesale when `environmentId` -is `undefined`), a lock carried by a *stored overlay* row rather than a packaged -artifact, and a draft that predates the lock — because the save door refuses to -mint one once the lock is live. It is nevertheless a lock gate that could be -addressed around from the wire, and "a lock gate must not fail open" is the rule -this file already carries. - -`promoteDraftForPublish`'s own `PLURAL_TO_SINGULAR` fold is **kept**, and the -measurement is the reason: that helper's other caller is `publishPackageDrafts`, -which feeds it stored row types. That is data at rest, where a legacy row -written under a plural `type` is real and nothing rewrites it on upgrade — a -different input class needing a different map, exactly as `canonicalMetaType`'s -header describes. Deleting it as "now redundant" would have changed the batch -path. - -`publishPackageDrafts` and `deletePackage` need no fold of their own: neither -takes a caller-supplied `type` at all (both are addressed by `packageId`), and -the per-row work they delegate is already covered — `deletePackage` routes every -row through `deleteMetaItem`, which folds, and `publishPackageDrafts` reaches -the manifest-map fold described above. - -The audit row and the publish receipt now record the canonical type too; both -read `request.type`, so a publish addressed `/meta/views/case_grid` previously -wrote `type='views'` into `sys_metadata_audit` for a row stored under `view`, -and a compliance query on the canonical spelling did not find it. - -Pinned in `packages/objectql/src/protocol-publish-canonical-fold.test.ts` -against a real engine and repository, with the reverse verification's direction -predicted before it was run: predicted 3 red / 4 green, measured 3 red / 4 -green, each red for its predicted reason. diff --git a/.changeset/reaper-verifies-repoint-before-deleting.md b/.changeset/reaper-verifies-repoint-before-deleting.md deleted file mode 100644 index 5efbd0869a..0000000000 --- a/.changeset/reaper-verifies-repoint-before-deleting.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/service-settings": patch ---- - -fix(settings): the rotated-secret reaper verifies the repoint instead of inferring it (#8262) - -`SettingsService.reapRotatedSecret` deleted the `sys_secret` row that -`upsertRow` reported as `previousEnc`, and inferred that the repoint it was -cleaning up after had taken effect from `previousEnc !== nextEnc`. That -inference holds for the shipped adapter, which forwards -`context: { isSystem: true }`. It does not hold for an adapter that drops -`context` — the reader `SettingsEngine`'s own doc comment contemplates, and a -documented extension point rather than a mistake nobody makes. - -With `context` dropped, `sys_setting.value_enc` is `readonly: true` so the -UPDATE has it stripped, the row keeps naming the OLD handle, and the reaper -then deleted **the ciphertext still in force**: `materialiseRow` dereferenced a -dangling handle, got nothing, and the setting silently read as empty. That is -unrecoverable — the audit trail records digests, never handles or ciphertext, -so nothing can even name what was destroyed. Measured on the real engine over -the real `SysSetting` / `SysSecret` schemas, three writes gave `sys_secret` -`1 → 1 → 2` with `value_enc` pinned to a row that no longer existed. - -The reaper now re-reads the row after the write and deletes `previousEnc` only -once storage confirms the row no longer names it. The criterion is -`current !== previousEnc` rather than the narrower `current === nextEnc`: -under a concurrent rotation the row may already have moved on to a third -handle, where `previousEnc` is genuinely unreferenced and the narrower test -would leak the orphan the reaping exists to prevent. Both refuse the case that -matters. - -Every refusal branch (unreadable row, failed read, row still naming the -handle) leaves an orphan and logs — the recoverable direction, and the one an -orphan sweep can clean up; there is no recoverable direction on the other -side. The added read sits behind every cheap guard, so it is paid only where a -destructive delete would otherwise follow, and it is inside the same -best-effort guarantee as the delete: a rotation is never failed by it. - -Latent rather than live: no shipped path reaches this, because the shipped -adapter forwards `context`. The population at risk is third-party and custom -`SettingsEngine` adapter authors — who also had no discovery path, since the -warning on `SettingsEngine.update` still described only the pre-#8063 -consequence ("the rotated-away credential stays in force"). That warning now -states the real consequence, and a non-forwarding adapter announces itself in -the log instead of failing silently. diff --git a/.changeset/record-change-reentrant-start-condition.md b/.changeset/record-change-reentrant-start-condition.md deleted file mode 100644 index 563e7880fa..0000000000 --- a/.changeset/record-change-reentrant-start-condition.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -"@objectstack/service-automation": patch ---- - -fix(automation): evaluate a record-change flow's start condition on the re-entrant dispatch its own write causes — the loop-breaker goes back to being a backstop (#8689) - -A `record-after-update` flow whose start condition, **as authored, is false on the -flow's own write-back**, was still re-dispatched for the same record. Nothing ran -away — the engine's last-resort re-entrancy breaker caught it every time — but the -breaker was the *only* thing working, and its own WARN said so: *"Its start -condition did not suppress the re-fire."* - -**Which of the two candidate mechanisms — measured, not assumed.** The report named -two readings that need different repairs: the re-entrant dispatch *skips* condition -evaluation, or evaluation *runs but aborts* and the abort is counted as a fire. -Measured on a real booted kernel (ObjectQL + automation + record-change trigger on -better-sqlite3), a flow guarded on `record.status != "escalated"` whose data node -writes `status = "escalated"`: - -``` -dispatches for the record ........ 2 (the re-fire really happened) -start-condition evaluations ...... 1 (the FIRST dispatch only) -evaluations that threw ........... 0 -loop-breaker WARNs for that id ... 1 -``` - -Two dispatches, one evaluation, zero throws: the first reading is the true one, and -the second is falsified for this path. `AutomationEngine.execute()` checked the -re-entrancy breaker **before** the start-condition gate and returned there, so on the -one dispatch where an author's re-fire guard is load-bearing, the guard was never -consulted at all. - -**The fix is the ordering, not a stronger breaker.** The gate now runs first; the -breaker check moved below it. The re-entrant dispatch already carries the post-write -row, so the condition evaluates `false` and the flow is suppressed with -`condition_not_met` — by the guard its author wrote. Measured after the change on the -same harness: 2 dispatches, **2** evaluations (the second returning `false` against -`status = "escalated"`), **0** breaker WARNs, and the flow still fires and applies its -write exactly as before. - -The breaker is **unchanged in strength**, deliberately — making it catch more while -leaving evaluation broken would have been the wrong direction. A condition that is -genuinely true on re-entry (the 2026-07-06 shape: a `boolean` persists as integer `1` -on SQLite/libsql, and CEL `1 != true` is true, so `is_escalated != true` never trips) -still lands on the breaker, at the same depth, with the same WARN and the same skip -envelope. What changed is that reaching it now *means* something — the condition was -evaluated and returned true — so the WARN states that as fact instead of inferring it. - -Two consequences worth naming for anyone reading logs or run history: - -- flows whose re-fire guard was already correct stop producing the breaker WARN - entirely, and their re-entrant dispatch is now recorded as `condition_not_met` - rather than `reentrancy_loop_guard`; -- a run skipped by its condition, and a re-entrant dispatch refused by the breaker, - no longer release the re-entrancy key — only the run that took it does. Releasing a - key it never owned would have disarmed the breaker for the run still on the stack, - which is exactly the runaway the breaker exists to stop. - -The regression pins assert the reporter's own three-legged probe design together — -the flow actually fired, no breaker WARN carries that record's id, and the start -condition was **evaluated** at the re-fire against the post-write row and returned a -verdict rather than throwing. Asserting only "the flow terminated" would be vacuous -here: the breaker already made that true. diff --git a/.changeset/record-chatter-position-renderer-vocabulary.md b/.changeset/record-chatter-position-renderer-vocabulary.md deleted file mode 100644 index 1eb00e2043..0000000000 --- a/.changeset/record-chatter-position-renderer-vocabulary.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -fix(spec): `record:chatter` / `record:discussion` `position` speaks the renderer's vocabulary, and the row's schema defaults are dropped (#8762) - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -`RecordChatterProps.position` declared `sidebar | inline | drawer` — a -vocabulary NO renderer read point ever compared. Measured at objectui pin -`665661ab0932`, the renderer chain is self-consistent in three places and -speaks `bottom | right | left`: `RecordChatterPanel` docks on `right`/`left` -and renders in flow on `bottom`, the designer registration publishes -`enum: ['bottom', 'right', 'left']`, and the renderer merge falls back to -`bottom`. So the schema's own default (`sidebar`, materialized onto every -parsed node that said nothing) was a silent no-op falling through to the -in-flow render, while the value that actually docks the panel (`right`) was -refused at publish. The maintainer ruling (2026-08-15) converged the row on -the renderer's vocabulary — one vocabulary, no mapping layer. - -**FROM → TO:** `position: 'sidebar'` → `'right'` (the docked side panel the -spelling meant); `'inline'` → `'bottom'` (the in-flow branch it already -landed in); `'drawer'` → `'right'` (no overlay drawer was ever implemented — -the docked panel is the nearest surviving intent). One-line fix: re-spell -`position` to `bottom`/`right`/`left`; `os migrate meta` rewrites sources -mechanically via the ADR-0087 conversion -`record-chatter-position-vocabulary`, and stored `sys_metadata` rows replay -clean through the rehydration seam. A live author gets a per-value "was -removed" prescription from the enum's own error map. - -**All three schema defaults are dropped** (`position: 'sidebar'`, -`collapsible: true`, `defaultCollapsed: false`) per the `maxVisible` -principle — renderer fallbacks stay the renderer's facts. The old -`collapsible` default *inverted* the renderer merge's own `false` fallback, -turning "the author said nothing" into "the author asked for collapsible". A -page that wants the collapse affordance authors `collapsible: true` -explicitly; unset keys now parse to nothing and the renderer decides. - -The row stays ONE shared schema object for `record:chatter` AND -`record:discussion` (the #8744 pairing) — both names accept and refuse -identically. The objectui renderer is unchanged. - - diff --git a/.changeset/rest-meta-write-org-scope.md b/.changeset/rest-meta-write-org-scope.md deleted file mode 100644 index d529322ca9..0000000000 --- a/.changeset/rest-meta-write-org-scope.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -'@objectstack/metadata-core': patch -'@objectstack/runtime': patch -'@objectstack/rest': patch ---- - -REST `/meta` write doors now carry the caller's organization, so audit rows are no longer stamped environment-wide - -`PUT /meta/:type/:name` (both arities), `DELETE /meta/:type/:name`, -`POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` passed no -organization, so every `sys_metadata_audit` row a REST-authored metadata write produced was -stamped `organization_id: null`. Composed with the scoped audit read shipped alongside it — -which returns own-org rows **plus** environment-wide ones, a limb that is required rather -than optional — that left every REST-authored audit row readable by every tenant, carrying -its `actor`, `note`, `lock_state` and `request_id`. The read side could not close this: the -rows were genuinely unscoped, so no filter could separate them. - -The organization is taken from the execution context these doors already resolve, and is -threaded through `organizationIdForMetaWrite` — the same registry-derived predicate the -runtime `/metadata` dispatcher uses. Types the registry declares `allowOrgOverride: true` -(`view`, `dashboard`, `report`, `translation`, `email_template`) now scope both the overlay -row and its audit row to the caller's organization; every other type continues to write -environment-wide, because its write genuinely is environment-wide and the protocol refuses -an org-scoped write for it. `null` is now reserved for writes that really are -environment-wide. - -Two behaviour changes ride along, both required for the fix to be usable rather than -separate improvements: `publish` and `rollback` resolve their row through the organization, -so scoping the save without scoping them would have broken the draft → publish loop; and -`GET /meta/:type/:name/published` is now organization-scoped (organization-first, then -environment-wide), without which it would answer 404 for an item the same caller had just -published through the same transport. - -`organizationIdForMetaWrite` / `declaresOrgOverride` moved from `@objectstack/runtime` into -`@objectstack/metadata-core` so both doors share one implementation — `@objectstack/rest` -cannot import from `runtime`, which depends on it. Runtime behaviour is unchanged. diff --git a/.changeset/retire-remote-template-catalog.md b/.changeset/retire-remote-template-catalog.md deleted file mode 100644 index 3037c88739..0000000000 --- a/.changeset/retire-remote-template-catalog.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -'create-objectstack': minor ---- - -Retire the five remote content templates from the scaffolder's catalog. - -`todo`, `compliance`, `content`, `contracts` and `procurement` were delisted -from the official ObjectStack template marketplace and are no longer -maintained, but the CLI carried its own hardcoded catalog and never learned -that: `--help` recommended all five by name with marketing descriptions, and -the `Available:` line on a bad `-t` offered them too. - -- `blank` (bundled, offline) is now the whole catalog, so the help text - advertises only what is actually supported. -- Asking for one of the five by name — `-t todo` in an old script or tutorial — - is refused with a message that says the template was retired, instead of the - generic "Unknown template" error that reads as a typo. -- The GitHub tarball-fetch path that served the remote templates is removed - along with its `tar` dependency; nothing else reached it. - -Note this corrects the catalog at HEAD only. Already-published versions keep -advertising the retired templates until a new version of `create-objectstack` -is released. diff --git a/.changeset/rollback-canonical-type-fold.md b/.changeset/rollback-canonical-type-fold.md deleted file mode 100644 index 4627ddda9c..0000000000 --- a/.changeset/rollback-canonical-type-fold.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): `rollbackMetaItem` routes through the canonical type fold, closing an ADR-0010 `_lock` a plural URL spelling could address around (#8819) - -`rollbackMetaItem` is the **eighth** `/meta` entry point on the -`POST /api/v1/meta/:type/:name/rollback` URL family, and it was the last one -still deriving its type key from `PLURAL_TO_SINGULAR` — the -MANIFEST-COLLECTION map #7894 moved this boundary off — instead of -`canonicalizeMetaRequestType`. The other seven fold; this one did not. - -**The half of that asymmetry that was not fail-closed is the lock.** -`assertLockAllowsWrite` delegates to `getEffectiveLock`, whose artifact limb -folds and whose **overlay limb queries `sys_metadata` with the raw `type`**. The -rollback passed the caller's spelling to the gate while every row operation -below it used the folded key. So for a manifest-present type, a rollback -addressed `/meta/views/case_grid/rollback` looked the `_lock` up under a `type` -no row carries, got `'none'` back — which is not a neutral value but the verdict -"the author declared no protection" (#5706) — and then restored the history body -against the folded key, which resolves the protected row perfectly. A lock gate -addressable around from the wire, on the verb that overwrites the active body. - -**The severity window is narrow and is not rounded up here.** It needs an -environment kernel (`assertLockAllowsWrite` opens with -`if (this.environmentId === undefined) return null`, skipping the gate wholesale -otherwise) **and** a lock carried by a **stored overlay row** rather than a -packaged artifact — the artifact limb folds, so an artifact `_lock` was already -found under either spelling. Inside that window the write landed. - -The fold also reaches three things that were merely incoherent rather than -unsafe: the revertability tier (`isOverlayAllowed` / `isRuntimeCreateAllowed`) -took the permissive **plugin** branch for the four manifest-absent types -(`field`, `seed`, `external_catalog`, `translation`); and the -`[not_overridable]` refusal, both ADR-0010 audit rows and both receipt sentences -reported the **caller's** spelling for a row written under the canonical one. -`recordMetadataAudit` re-folds internally through `PLURAL_TO_SINGULAR`, which -covers a manifest-present plural and misses the four manifest-absent ones — so -folding at the boundary is what makes the audit trail agree with the write for -both classes. - -Placed after the existing `toVersion` envelope guard rather than at the very top -of the method: that is the position `saveMetaItem` documents for this exact pair, -naming this method's opening guard its structural twin — a malformed request -envelope is refused before its type key is canonicalised, and both refusals are -`[invalid_request]`/400 either way. - -**What this does not do.** `getEffectiveLock`'s overlay limb still queries the -raw `type`. Folding it there would close the class at the producer for every -present and future caller, which is the contract-first shape — but it is a -shared gate whose blast radius wants its own measurement, so it is deliberately -left open as its own card rather than ridden in here. - -Pinned in `packages/objectql/src/protocol-publish-canonical-fold.test.ts` as -group D, driving the real `ObjectQL` / protocol / `SysMetadataRepository` over an -in-memory driver on an environment kernel: the canonical spelling is refused by -the lock, the plural spelling is refused by the **same** lock, and — the clause -that matters, since the first two can both pass while the write still lands — -the protected active body is **unchanged** afterwards. A positive control runs -the identical plural call with the lock removed and asserts it really does -restore the earlier body, so the group cannot pass by being unable to roll back -at all. diff --git a/.changeset/runtime-publish-drafts-flip-announce-driver-text.md b/.changeset/runtime-publish-drafts-flip-announce-driver-text.md deleted file mode 100644 index 13f5ca1f4a..0000000000 --- a/.changeset/runtime-publish-drafts-flip-announce-driver-text.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -"@objectstack/runtime": patch ---- - -fix(runtime): `publish-drafts` no longer discloses driver or subscriber text on `unhideError` / `rebindError` (#8516) - -`POST /api/v1/packages/:id/publish-drafts` answered, on a **200**: - -```json -{ "success": true, "data": { - "unhideError": "SQLITE_ERROR: no such table: sys_metadata", - "rebindError": "TypeError: Cannot read properties of undefined (reading 'triggers') at AutomationPlugin.rebind (/srv/objectstack/packages/services/service-automation/dist/index.js:412:31)" } } -``` - -These are the two remaining producers on the response whose `seedApplied` field -#8443 converted — the ADR-0045 visibility flip and the `metadata:reloaded` -announce. Both ride a success body as **data**, so no HTTP boundary's 5xx -message withhold can reach them; the disclosure had to be closed at the -producer. Both were driven for real before being changed, and both reproduced. - -Both now follow the rule already in force next door: a caught sentence is -quoted only when the error **declared** itself a client-facing refusal (4xx -`status`, ADR-0112); anything else gets the stable sentence the field could -already carry, and the original goes to the server log. The rule is imported -from `@objectstack/metadata-protocol` (`clientFacingFailureText`), not restated -locally. - -**Both halves of the rule, because the two sites started in different states.** -The flip already logged its cause in full at `error` with an operator remedy, so -only its payload changed. The announce had **no log line at all** — withholding -alone would have converted an over-disclosure into a silent failure, so it gains -one at `warn`, naming the cause, the concrete consequence (a newly published -record-triggered flow does not bind its trigger until the process restarts) and -the fix (re-run the idempotent publish, or restart). `warn` rather than `error` -because nothing that claimed to persist failed to: the drafts are published and -the flip is stored, and an unbound trigger is AGENTS.md's own worked example of -a functional degradation — the level the sibling announce of this same event -already uses. - -**Authoring feedback is preserved, not blanked.** The flip's authored refusals -all declare 4xx (`ITEM_LOCKED`, `NOT_OVERRIDABLE`, -`OBJECT_OVERLAY_PACKAGE_MISMATCH`, …), so a locked or non-overridable app still -tells its publisher which app and why, verbatim — and the `unhiddenApps` -half-flip report beside it is untouched. A subscriber that declares a 4xx -refusal is quoted by the same positive list. diff --git a/.changeset/searchable-fields-anchor-provenance.md b/.changeset/searchable-fields-anchor-provenance.md deleted file mode 100644 index e6563eab62..0000000000 --- a/.changeset/searchable-fields-anchor-provenance.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -"@objectstack/lint": minor ---- - -fix(lint): ask the provenance question at the fifth blanket-`SYSTEM_FIELDS` read site — `searchableFields` (#8404) - -`validate-searchable-fields.ts` judged a declared `searchableFields` entry -against the object-independent `SYSTEM_FIELDS` union, exactly as the four -filter/page-binding rules did before #8340 wired them to the per-object index. -Both of its gates were correct about EXISTENCE and structurally blind to -PROVENANCE: `:345` keeps `searchable-field-unknown` silent for any name in the -union, and `resolveAllowedSet` goes further — it manufactures a stub meta for -such an entry so it survives the resolution's existence filter exactly as it -does at runtime. - -On an ADR-0015 `external` object the platform registers its injected anchors -(`owner_id`, `organization_id`, the audit family, …) and provisions no storage -behind them (#7865 / #8116), so: - -``` -searchableFields: ['name', 'owner_id'] // external object -``` - -linted clean, the stub kept the entry in the resolved allow-list, and the -view's `$searchFields` narrowing then scanned a column empty on every record — -#4830's own failure mode (a narrower search than declared, silently) reached by -a different route. - -A new `searchable-field-unprovisioned` rule now warns on such an entry, on the -object's own canonical set and on a list view's narrowing alike, reusing -`unprovisionedAnchorCause` / `unprovisionedAnchorHint` so the sentence matches -the four #8340 rules verbatim rather than becoming a second copy (#4830). WARN, -never gating, per #4330's cost asymmetry: the remote schema is not visible to -this pass, so the finding describes a degradation rather than a refusal. - -**The `:239` stub is KEPT.** It is not incidental — it is what makes the linter's -resolution agree with the runtime's, which resolves the declared branch against -the registry field map. Measured by disabling it: the existing "keeps runtime -parity when the object declares system columns searchable" test goes red -(`expected [] to have a length of 1 but got +0`), because the declaration -existence-filters to empty and resolution falls through to the auto-default. -Dropping it would have been a behaviour change dressed as a warning. - -The warning is emitted per declared entry in the checker's entry loop, never -inside `resolveAllowedSet` — that helper reads the OBJECT's declaration and runs -once per narrowing, so warning there would repeat one object-level fact for -every view and attribute it to the view's path. - -`checkSearchableFieldList` takes the index as an OPTIONAL trailing parameter, -the same shape #8340 gave `checkFieldRefs`: its absence means the caller did not -build the index and the provenance question goes unasked — the previous -behaviour, preserved for out-of-repo callers (cloud graph-lint, the AI authoring -path). Both in-repo callers pass it. diff --git a/.changeset/searchall-title-canonical-namefield.md b/.changeset/searchall-title-canonical-namefield.md deleted file mode 100644 index 877e47b440..0000000000 --- a/.changeset/searchall-title-canonical-namefield.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): global search titles a hit from the canonical `nameField`, not only the deprecated `displayNameField` alias (#8786) - -`searchAll` — the global-search (⌘K) palette — resolved a hit's title from a -candidate list that opened with `obj.displayNameField` **alone**. Under -ADR-0079 `nameField` is the canonical primary-title pointer and -`displayNameField` is the deprecated alias, so this was the one consumer a -canonical designation could not reach. - -It is reachable rather than theoretical because `provisionPrimary` — the -ADR-0079 designation seat the SchemaRegistry runs on every object at -registration — stamps `nameField` **only** and never the alias. An object that -declares its primary title canonically, without also carrying the deprecated -alias, produced `undefined` for that entry, the entry was filtered out of the -candidate list, and the title fell through to `String(row.id)`: the palette -showed a raw record id where the object's own declared, populated title -existed. - -Impact was bounded to objects whose primary title is **outside** -`name` / `full_name` / `title` / `subject` / `label` / `company` — anything in -that conventional list already resolved through the later entries, which is why -this stayed invisible. An object declaring `nameField: 'company_name'` now -titles its hits `Acme Industrial` instead of `acc_1`. - -The fix reads the precedence the rest of the platform already spells — -`obj.nameField ?? obj.displayNameField` — matching `resolveDisplayField` -(`@objectstack/spec`), the #4254 ingress gate, and this same function's -search-field resolution 44 lines below. The deprecated alias is still honored -on its own; only objects that carry **both** pointers naming **different** -fields see a precedence change, and no such object exists in this repo (every -one that carries both spells them identically). - -Presentation only: which rows come back is untouched. diff --git a/.changeset/seed-tenancy-autonumber-split.md b/.changeset/seed-tenancy-autonumber-split.md deleted file mode 100644 index dbca6887e2..0000000000 --- a/.changeset/seed-tenancy-autonumber-split.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch -"@objectstack/runtime": patch ---- - -Stamp seeded rows with the install's organization so one object runs one autonumber scope (#8686) - -Seed writes and API writes disagreed about tenancy. Seed data is loaded during -app start, before any human user exists, so the seed loader had no organization -to stamp and its rows landed `organization_id = NULL`; API writes carried the -signed-in user's organization. The SQL driver keys its autonumber counter by -exactly that column (`__global__` when NULL), so a single object ran two -independent counters — and the uniqueness index is partitioned by the same key -(`COALESCE(organization_id, '__global__'), `), so the duplicates the -second counter minted were invisible to the constraint. On a single-tenant -install seeded with `CASE-00001..38`, the first four API creates returned -`CASE-00001..4` again: four duplicated values on a field declared `unique`, with -201s and no warning. - -Seed writes now carry the organization the same way API writes do. The moment an -install's organization first exists, untenanted seed rows are adopted into it and -the `__global__` counter is merged into the organization-scoped one, so the -`__global__` pseudo-tenant stops acting as a peer of a real organization. Existing -installs are repaired by a one-shot boot-time backfill, guarded to single-tenant -installs; a multi-tenant install where a split is detected is never guessed at — -the backfill skips and logs the condition and the remedy. Business identifiers -that were already minted twice are reported for the operator, never silently -renumbered. Platform namespaces (`sys_`/`cloud_`/`ai_`) stay global, exactly as -the seed loader already treats them. diff --git a/.changeset/serve-multi-node-cap-advisory.md b/.changeset/serve-multi-node-cap-advisory.md deleted file mode 100644 index f0437f3753..0000000000 --- a/.changeset/serve-multi-node-cap-advisory.md +++ /dev/null @@ -1,33 +0,0 @@ ---- -'@objectstack/cli': patch ---- - -`serve`: warn when the declared replica count exceeds the licensed node cap (#8504) - -The 2026-08-13 `max_nodes` ruling requires a licensed overflow to refuse the excess, -run up to the paid limit, and **warn loudly**. The gate learned to express the first -two — `admitted` / `refused` / `capped` — but the only program that consults it, `os -serve`, called it zero-arg and typed the result with a hand-written -`{ allowed, reason }` cast. So the partial-cap verdict was unreachable *and* unread: -the gate could say "3 admitted, 2 refused" and nothing rendered it. - -`serve` now passes the operator-declared `OS_CLUSTER_REPLICAS` into the gate and -emits an advisory on `capped`: - -``` -[cluster] licensed node cap exceeded: the licence admits 3 node(s), but -OS_CLUSTER_REPLICAS declares 5 — 2 beyond the cap. -[cluster] This cap is ADVISORY and is not enforced yet: nothing is refused, and all -5 replicas will still join the cluster. -[cluster] Reduce OS_CLUSTER_REPLICAS to 3, or raise the licensed node limit. -``` - -⚠️ The wording is deliberately advisory. Enforcement needs an atomic slot claim -across replicas and is tracked separately; until it lands **nothing is actually -refused** — every replica computes the same verdict at boot and none can tell whether -it is one of the admitted ones, so all of them join. A message claiming "2 replicas -refused" would be false in exactly the declared-vs-delivered way this warning exists -to close. - -An outright `allowed: false` denial is untouched: it keeps reporting as a -single-node downgrade, and is deliberately not reported as a cap. diff --git a/.changeset/sharing-read-merge-provenance-mark.md b/.changeset/sharing-read-merge-provenance-mark.md deleted file mode 100644 index c2afebc70b..0000000000 --- a/.changeset/sharing-read-merge-provenance-mark.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -"@objectstack/plugin-sharing": patch ---- - -fix(plugin-sharing): stamp the filter-subtree provenance mark at the read merge, so an author's own cross-field refusal stops being redacted on the sharing-composed path (#8430) - -`#8220` declared the filter-subtree provenance mark and set it at two read-scope -merge boundaries — `plugin-security`'s CRUD injection and `service-analytics`' -`withReadScope`. `plugin-sharing`'s read path is a **third**: on every read it -AND-composes an OWD / record-share visibility filter into `ast.where`, and it -stamped nothing. - -Two marks, and they are not the same job: - -- **the scopes it injects are marked `'policy'`** — the OWD/record-share read - filter, the delegator's intersected filter (ADR-0090 D10) and the - `sys_record_share` self-scope (ADR-0111 D5). **No behaviour change**: an - unmarked subtree already withheld, so these refusals kept the `#7929` - redaction before and keep it now. What changes is that the withhold becomes a - *declared* verdict instead of an accident of the mark's absence — which - matters because an unmarked node **inherits its ancestor's mark positionally** - (`resolveFilterSubtreeProvenance`, innermost wins), so an unmarked policy arm - nested inside a vouched subtree would read as the author's. -- **the caller's own predicate is vouched `'author'`** immediately before the - rewrite that would otherwise make it unrecognisable to every later boundary. - This is the one user-visible change: an author's own `{ $field }` refusal on - an object with active sharing again names its columns, its operator and its - reason, instead of the redacted "operands withheld" text. - -**The vouch is an identity check, not a heuristic.** The mark is stamped only -while `ast.where` is still, by object identity, the `where` the caller handed -the engine. If a sibling middleware already composed into it, or the engine -rewrote it resolving filter tokens, identity fails and **nothing** is vouched — -the tree stays unmarked, and unmarked withholds. The arms of a pure -`{ $and: [ … ] }` root are vouched too, because `composeAnd`'s flattening branch -spreads that root's arms into a new object and would otherwise drop the vouch -out of the tree with it (that shape is what the array authoring form lowers to, -so it is the common case, not an edge one). - -**Fail-closed is unchanged in every direction**, and the pins say so at a real -`SqlDriver`: the injected scope still withholds, a policy arm sitting beside an -author-vouched arm in the same `$and` still withholds, and a predicate no -boundary ever vouched still withholds byte-identically to the policy case. - -**The write path is untouched.** `buildWriteFilter`'s composition is a different -question with different consequences and was not declared by `#8220`. - -Measured while implementing, and worth recording because the card says -otherwise: in a stack that composes **both** plugins, the author vouch was -already surviving. `plugin-security` is registered before `plugin-sharing` on -both real boot paths and `resolvePluginOrder` preserves insertion order, so -security vouches first and its mark — which lives on the caller's object — -travels through this composition untouched. The gap this fixes is a stack that -mounts `plugin-sharing` **without** `plugin-security`, where nothing else can -vouch for the caller. diff --git a/.changeset/sso-provider-map-id-param-retired.md b/.changeset/sso-provider-map-id-param-retired.md deleted file mode 100644 index 069d8dc6a6..0000000000 --- a/.changeset/sso-provider-map-id-param-retired.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -"@objectstack/platform-objects": patch -"@objectstack/plugin-auth": patch ---- - -fix(platform-objects): drop the dead `mapId` ("Map: User ID claim") param from `register_sso_provider` — the OIDC subject claim is not configurable (#8222) - - - -The `register_sso_provider` action on `sys_sso_provider` offered an optional -**"Map: User ID claim"** text field (`mapId`), with helpText reading *"Optional. -ID-token claim mapped to the user ID. Defaults to `sub`."* - -**That capability no longer exists.** It was retired upstream in -`@better-auth/sso@1.7.0-rc.2`: - -- `oidcConfig.mapping` is a `z.strictObject` whose members are - `{ email, emailVerified?, name, image?, extraFields? }` — there is no `id`; -- the federated subject is hard-wired to the OIDC `sub` claim - (`id: readStringClaim(rawUserInfo, "sub")` and `id: idToken.sub`), then - cross-checked (`id_token_subject_missing`, - `id_token_userinfo_subject_mismatch`); -- `extraFields` is not an escape hatch — it is spread **before** `id` in the - profile literal, so an `extraFields.id` is overwritten by `sub` before anything - reads it. - -`1.6.20` did honour `mapping.id` (`id: rawUserInfo[mapping.id || "sub"]`); the -version bump deleted the member. - -So the field's only accepted values were "empty" and the `sub` it already -defaulted to. #8193 (PR #8221) stopped the bridge emitting the retired key and — -rather than accept a value it would silently discard — made a non-`sub` value -answer `INVALID_REQUEST`. That left the last half of the problem: **the form -still advertised a free-form optional field that 400s on anything meaningful.** -Removing it restores declared = enforced. Nothing else about registration moves: -the runtime accept set is unchanged, and a registration that never sent `mapId` -behaves exactly as before. - -`mapEmail` and `mapName` are untouched — they map to live `oidcMappingSchema` -members and are still honoured. - -**The bridge-side guard in `plugin-auth`'s `register-sso-provider.ts` is kept**, -and its refusal test with it. The admin form was only one caller: a direct API -client, a script, or a stale cached console bundle can still put `mapId` on the -wire, and telling those callers plainly still beats discarding the value in -silence. Only the guard's doc comment changed, to stop describing `mapId` as a -field the form sends. - -The generated translation bundles (`*.objects.generated.ts`, all four locales) -were **regenerated**, not hand-edited, so the retired label disappears from every -locale rather than lingering as a stale entry. diff --git a/.changeset/sys-email-headers-internal.md b/.changeset/sys-email-headers-internal.md deleted file mode 100644 index eafab8ec83..0000000000 --- a/.changeset/sys-email-headers-internal.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/platform-objects': minor -'@objectstack/plugin-email': patch ---- - -Stop serving custom email headers through the generic data-API read of `sys_email` (#8149). - -**What this closes.** `sys_email.headers_json` — the custom headers handed to `IEmailService.send`, the ordinary place a relay credential or provider token goes — was readable by every caller the data API admits (list, get, an explicit `?select=headers_json`). The column is now declared `internal: true`, so the engine omits it from every generic read with no system carve-out (#7728); `SYSTEM_CTX` does not reopen it either. This is the same shape #8118 ruled on for `sys_http_delivery.headers_json`: this change adopts that remedy rather than deciding it a second time. - -**Delivery is unaffected, and fail-closed.** `sys_email` is not delivered from the in-memory message but FROM THE ROW: the after-insert outbox drain hook, the `email.send.async` queue subscriber and the boot outbox sweep all re-read the row and hand it to `EmailService.deliverPersistedRow`. All three read through `engine.find`, which is exactly what the flag empties — so the recovery ships with the flag. `deliverPersistedRow` now recovers the column through ObjectQL's privileged accessor (`resolveInternalField`, consumed unchanged) and sends every authored header verbatim. A message whose headers cannot be recovered is NOT sent without them: a missing header is not self-announcing — a relay that does not require it accepts the mail while the delivery silently deviates from the authored configuration. That case throws and leaves the row `queued`, not `failed`, so the queue retry or the next boot's sweep delivers it intact. - -**New optional seam.** `EmailPersistence.readHeadersJson(rowIds)` — the readback the plugin wires off the raw engine. It probes the OBJECT SCHEMA flag, never the absence of the key from a result row: `headers_json` is `required: false` and most real rows carry no custom headers at all, so a key-absence inference would treat every ordinary email as redacted (the regression measured on `sys_account`'s optional token columns in #7987/PR #8675). Engines that do not redact are left untouched and trigger no privileged read. - -**What this deliberately does NOT close.** The row still holds the header map in cleartext at rest. Encrypting it (`Field.secret()`) was measured and rejected on #8118 — an orphan `sys_secret` row per message with no cascade or retention, a boot-window fail-open, and a per-row decrypt on every delivery — and this change adopts that ruling unchanged. diff --git a/.changeset/sys-job-global-unique-scope.md b/.changeset/sys-job-global-unique-scope.md deleted file mode 100644 index 9135f097da..0000000000 --- a/.changeset/sys-job-global-unique-scope.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -State `sys_job`'s uniqueness boundary explicitly: `unique: 'global'` on the declared `(name)` index, and correct the `name` field's description (#8578) - -The declared index carried the bare `unique: true` spelling, which ADR-0120 D1 defines as the deprecated positional spelling of `'global'` — the listed columns verbatim. Because `sys_job` also carries a kernel-injected `organization_id`, the tenancy sweep could not tell that shape apart from the #8323 cross-tenant-oracle class, and the field's description published a boundary-free "Unique job identifier" claim that left the question open in the generated reference. - -The reading settles it in the `'global'` direction: nothing writes `sys_job` per organization. `DbJobAdapter` is the sole writer and upserts under a SYSTEM context, locating rows by `where: { name }` with no organization dimension; the `job` metadata type is closed to tenants on all three flags (`allowOrgOverride: false` — "no per-org job fork" — plus `allowRuntimeCreate: false` and `supportsOverlay: false`); `enable.apiMethods` advertises no write verb at all (ADR-0103 engine-owned); and every `schedule()` call site is registration-time and installation-scoped. ADR-0120's own S5 inventory already names `sys_job.name` as one of the nine engine idempotency keys that are platform-wide by construction. - -No migration and no drift: `'global'` **is** the semantics bare `true` already materialized, so the physical index is byte-identical (ADR-0120 D2). What changes is that the boundary is stated rather than inferred from position, and that the published description names it. The reading itself is pinned — the new test asserts the write paths that would have to open for the opposite verdict to become true, so a future per-organization job path fails loudly instead of silently invalidating the constraint. diff --git a/.changeset/sys-position-bundle-locales-per-organization.md b/.changeset/sys-position-bundle-locales-per-organization.md deleted file mode 100644 index 3f7a188ddf..0000000000 --- a/.changeset/sys-position-bundle-locales-per-organization.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -"@objectstack/plugin-security": patch ---- - -Correct `sys_position`'s translated uniqueness text in the `es-ES`, `ja-JP` and `zh-CN` bundles to say the machine name is unique **per organization** - -The English bundle and the object source both already state that a position's machine name is unique per organization — the declared index is `{ fields: ['name'], unique: 'organization' }`. The three other shipped locales still asserted bare, unqualified uniqueness, so an admin reading Setup in Spanish, Japanese or Chinese was told the name had to be free installation-wide, which the declared index does not enforce. - -Both places `sys_position` states the rule are corrected: - -- `fields.name.help`, the field help in the object's detail and edit views. It now also carries the source's current examples (`sales_manager`, `hr_specialist` rather than the superseded `admin`, `editor`, `viewer`). -- `actions.clone_position.params.name.helpText`, the help on the Clone Position dialog's API-name input — the text an admin reads at the moment they type a new name. - -Leaf string values only — no bundle structure was hand-edited. diff --git a/.changeset/sys-setting-null-safe-row-identity.md b/.changeset/sys-setting-null-safe-row-identity.md deleted file mode 100644 index 9fe0d86864..0000000000 --- a/.changeset/sys-setting-null-safe-row-identity.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -fix(metadata-protocol): `sys_setting`'s declared row identity is enforced on the tenant and global layers — a runtime NULL-safe UNIQUE index over `COALESCE(user_id, '')` (#8629) - - - -`sys-setting.object.ts` declares the object's row identity as -`{ fields: ['namespace', 'key', 'scope', 'user_id'], unique: 'organization' }`, -and the object's own header calls that the row identity. It was not one. -`user_id` is NULL on every row that is not `scope='user'` — `SettingsService.set` -computes it as `scope === 'user' ? ctx.userId ?? null : null` — and SQL UNIQUE -treats NULLs as mutually distinct, so the constraint was **void on the `tenant` -and `global` limbs**: exactly the two carrying organization-level and -platform-level configuration. - -Measured on a real engine, before this fix: two identical `scope='tenant'` rows -in ONE organization both landed (`201`, `201`), two identical `scope='global'` -platform defaults both landed, while the same rows with a non-NULL `user_id` -were refused — the control that identifies the mechanism as the NULL rather than -the `scope` value. `SettingsService` then resolves a layer with a positional -`rows.find(...)` and `set()` upserts against `{ namespace, key, scope, user_id }`, -so which value an organization got for a tenant-scoped key was unspecified and -two rows could disagree indefinitely with no way for an admin to see why the -effective value was not the one they set. `lifecycle.retention_overrides` is a -live tenant-scoped key, so this reached real retention behaviour. - -The fix follows the paradigm that has shipped twice in this package -(`ensureOverlayIndex`, `ensureViewDefinitionActiveIndex`): at `kernel:ready` the -declared index is rebuilt in raw SQL with both nullable key parts folded — -`COALESCE(organization_id, '__global__')` (ADR-0120 D3's tenant form, unchanged -from what the driver already emits) and `COALESCE(user_id, '')` (the -`ensureOverlayIndex` spelling for a non-tenant nullable discriminator). Storage -is untouched: the row keeps its NULL, only the index folds it. The index reuses -the **declared name**, so the additive `syncDeclaredIndexes` — which skips by -name — never re-imposes the NULL-distinct form on a later boot, and the drift -reconciler leaves it alone because an index carrying a non-tenant expression key -part is not sync-reproducible. - -**⚠️ Operator-visible: this is a TIGHTENING, and on an installation that has -already accumulated duplicate settings rows it will REFUSE to build the index.** -That is the intended behaviour, not a failure mode to work around. Those -duplicates exist precisely because the constraint has been void, and settings -rows are admin-authored configuration, so no row is discarded automatically and -no deterministic keep-one rule is applied. On refusal: - -- **nothing is deleted, rewritten or reordered**, and the boot continues; -- the **previous index stays in place** — the tightening is proved buildable - under a throwaway probe name before the declared name is ever dropped, so the - table never spends a moment with no unique index at all; -- one `error` line names the key that is not enforced, the consequence (duplicate - tenant-scope and global-scope rows can still be created, and `SettingsService` - has no defined answer for which one wins), and ships the **exact query that - lists the offending rows**, so the operator has the list from the boot log - without waiting for `os migrate plan`; -- the migration keeps refusing on every boot until an operator decides which row - survives, then converges on the next restart. - -Two hosts are deliberately quiet rather than degraded: a kernel composed without -the optional `service-settings` has no `sys_setting` table at all, which is -probed for and is a silent no-op; and a MySQL/MariaDB server that rejects -functional key parts keeps the previous index and is told what is not enforced, -the same degradation `SqlDriver.createNullSafeUniqueIndex` already reports for -this class of event. diff --git a/.changeset/system-overview-by-action-title-parity.md b/.changeset/system-overview-by-action-title-parity.md deleted file mode 100644 index c8900f1945..0000000000 --- a/.changeset/system-overview-by-action-title-parity.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): the System Overview by-action table serves its declared title again, and the default locale bundle is now pinned to the source string (#8721) - -`widget_recent_events` was converted into an ADR-0021 single-form — a -dataset-bound breakdown of `sys_audit_log` events by action — but all four -hand-authored locale bundles kept serving the title the widget had *before* the -conversion (`Recent Audit Events` / `最近审计事件` / `最近の監査イベント` / -`Eventos de Auditoría Recientes`). The translation is what renders, so the -declared string reached nobody in any locale. Its `description` had drifted the -same way and in the same direction, one field over. - -**The duplicate the stale translation was hiding.** With the source string -restored, the board carried the same label twice: `widget_events_by_type` (a -pie) and `widget_recent_events` (a table) both declared `Audit Events by -Action`, over the same dataset and the same dimension. They looked distinct in a -running instance only because one of them was serving a stale translation. The -pair now splits on what each adds — the pie keeps `Audit Events by Action` (the -share picture), the table becomes **`Event Volume by Action`** (the exact -per-action count, which is what its `values: ['event_count']` produces and what -its description already said). All four locales are translated to the new -strings; the widget **ids are unchanged**, so no translation key, persisted -widget state or dataset binding moves. - -**Why nothing caught it, and what now does.** This package's `apps` / -`dashboards` / `pages` i18n is hand-authored and cannot be regenerated — -regenerating would delete ~40 runtime-contributed nav translations per locale — -so it never had the source-tracking the generated half gets from the extractor. -Every gate over it made a **key-set** claim (`app-nav-translation-parity.test.ts` -asserts a translation exists and does not outlive its declaration; -`check:i18n-coverage` ratchets *untranslated* labels; `check:app-nav-i18n` judges -the merged nav tree), and a key whose value is stale satisfies all of them. - -`app-nav-translation-parity.test.ts` now also asserts the **default locale's -content**: every statically declared app label, description and nav label, plus -the dashboard's label, description and every widget title/description, must -appear in `en.ts` **verbatim**. That claim is available for `en` alone because -`en` is a copy of the source rather than a translation of it — the same -invariant the generated half already enforces by rewriting its `en` bundle on -every extract. What a *translated* locale should do when its source string -changes is a separate product decision and is deliberately not decided here. diff --git a/.changeset/system-overview-permission-change-tile-removed.md b/.changeset/system-overview-permission-change-tile-removed.md deleted file mode 100644 index 4b71635101..0000000000 --- a/.changeset/system-overview-permission-change-tile-removed.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): remove the System Overview board's permanently-empty "Permission Changes" tile (#8148, #7675) - - - -The System Overview dashboard shipped a "Permission Changes" metric tile -filtering `sys_audit_log.action = 'permission_change'`. **The tile could never -report anything but `0`, on any deployment that has ever existed** — the value -had no writer anywhere in the repo. There are exactly two `sys_audit_log` -writers: `plugin-audit`'s generic hook writer, whose `actionFor` maps -afterInsert/afterUpdate/afterDelete to `create`/`update`/`delete` and nothing -else, and `plugin-auth`'s admin user-import. Neither has ever emitted -`permission_change`. #8147 then retired the value from the action enum outright, -so the tile's filter now names a value the platform does not even declare. - -**An empty tile on a compliance surface is worse than a missing one.** A -permanently-`0` "Permission Changes" count does not read as "this platform does -not track permission changes" — it reads as a *negative finding*: an auditor -concludes the platform watched for permission changes over the selected window -and found none. The number was live and the query was real; the question it -answered was one no row could ever be an answer to. 审计面宁窄勿谎 — a narrow -audit surface beats a lying one. - -**Removed rather than refiltered onto a live action.** Permission and role edits -*are* captured today, as ordinary `create` / `update` rows written by the generic -hook against the permission objects — so the honest lens on them is `object_name` -on the audit list view, a row-level question rather than a single-number KPI. -Approximating one as a tile would have put a second not-quite-true number on the -same board. The two surviving Row 2 tiles ("Login Events", "Config Changes") -split the 12-column row in half instead of leaving a gap where the removed tile -sat. - -The by-action tile's description stops naming `permission` among its example -actions, in the source **and in all four locale bundles** — the translations are -the strings actually served, so correcting only the source would not have reached -a single user. - -⚠️ **`import` is deliberately untouched.** It was named in the same ruling as -`permission_change`, but its retirement premise was falsified during #8147: it -has a live writer (`plugin-auth`'s admin user-import writes a run-level row) and -a shipped list view that filters it. Removing it from the dashboard while the -platform still emits it would produce the exact inverse defect — an audit action -that can be written but cannot be found. - -Both directions are pinned. A tombstone refuses any board widget filtering a -retired action value, with a live-action control so it cannot pass on a board -that has no widgets or whose predicates moved. The app/dashboard translation -parity test gains the **reverse direction it was missing** for dashboard widgets -— it asserted every declared widget has a translation, but nothing stopped a -translation outliving its widget, which is precisely what these four locale -entries would have done. diff --git a/.changeset/system-write-organization-stamp.md b/.changeset/system-write-organization-stamp.md deleted file mode 100644 index 474f00a09e..0000000000 --- a/.changeset/system-write-organization-stamp.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -"@objectstack/objectql": patch -"@objectstack/spec": patch ---- - -fix(engine-core): a system-context insert on a tenant-scoped object resolves the install's organization the way a session write does, or is refused — the runtime producer of the autonumber fork #8686's backfill cannot reach (#8844) - - - -#8686 fixed **one** producer of untenanted rows — the seed loader — and shipped -a one-shot backfill for what it had already written. This card is the **other -producer, which is still running**: an ordinary application write made under a -system execution context (a hook, a scheduled job, a custom endpoint, a -`runAs: system` flow). A backfill cannot reach it, because it mints a fresh -duplicate on every tick — which makes #8686's repair **self-undoing on any -install with server-side automation**, i.e. every business app. - -**Measured on 17.0.0 GA**, a single-tenant EHR/MES install with ~44 autonumbered -objects: two records, same object, same install, the **same** value on a field -the app declared `unique`, with no error and no warning. The `notification` case -shows both producers side by side — `NT-00002 .. NT-00011` each existing twice, -copy A written by the "maintenance overdue" cron job, copy B by a user action. - -**Mechanism.** A session write carries the caller's active organization, the SQL -driver stamps it onto the row (`injectTenantOnInsert`), and the autonumber -counter reads it back off the row (`fillAutoNumberFields`, resolving -`row[tenantField] ?? options.tenantId ?? null`). A system-context write carries -none, so the column lands `NULL` and the counter files the row under the -`__global__` pseudo-tenant. One object then runs two counters that cannot see -each other, each correct within its own scope, and the partitioned unique index -— `(COALESCE(organization_id, '__global__'), )`, ADR-0120 D3 — cannot see -across the two partitions either. - -⛔ **Not a counter bug**, and not fixed by making the allocator smarter: both -counters are already correct within their own scopes (the reasoning #8686 -recorded, unchanged). The defect is upstream of the counter. - -**The fix, per the 2026-08-15 maintainer ruling (Option 1)** — a system-context -write resolves the install's organization the way a session write does, at the -engine's stamp resolution, so every driver is covered at the source (which -matters here because `fillAutoNumberFields` is duplicated in `driver-sql` and -`driver-turso`; neither driver changed): - -- **Single-tenant, exactly one organization ⇒ derive and stamp.** The - `__global__` fork stops being minted by hooks, cron and system endpoints. -- **Multi-organization ⇒ carry an explicit organization or be REFUSED LOUDLY**, - never silently defaulted. A walled posture (`group` / `isolated`), or a - `single` posture whose data holds several organizations, has no derivable - answer — the refusal is `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (500, - registered in the ADR-0112 ledger), thrown before anything reaches the driver, - and its message names the condition, what would otherwise have been written, - and both remedies. -- **Already-minted duplicates are reported, never rewritten** — the #8686 - posture, ruled again here. Nothing in this change renumbers anything. - -**Three populations are outside the rule by construction, not by exemption**, so -that the refusal cannot break unattended automation that was never at risk: -objects with no organization column, objects declaring `tenancy: { enabled: -false }` (ADR-0066 — the *declared* way to hold org-less rows, rather than a -per-write bypass flag) and federated objects (ADR-0015); the platform namespaces -`sys_` / `cloud_` / `ai_`, whose rows are deliberately global (#8672's reasoning, -which this ruling confirms holds for platform objects and does **not** generalize -to application objects); and any write that already carries an organization — on -the execution context, on the record, or stamped by a `beforeInsert` hook. - -**First boot is untouched:** before any organization exists there is nothing to -derive and no second partition to fork away from, so those rows still land -org-less for #8686's `sys_organization`-insert handoff to adopt. - -Scoped to **insert**, deliberately: the ruling's yardstick is "the way a session -write does", and stamping the organization is an insert-side mechanism — an -update neither stamps it nor can fork a counter. diff --git a/.changeset/tall-jars-invent.md b/.changeset/tall-jars-invent.md deleted file mode 100644 index 712099056c..0000000000 --- a/.changeset/tall-jars-invent.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Global search (`GET /api/v1/search`) now resolves searchable fields the same way `$search` does, so the ⌘K palette recalls what the list quick-search recalls (#7643) - -`searchAll` built its own filter instead of going through the engine's ADR-0061 `$search` expansion, which made the palette's recall a strict subset of the executor's. It now hands the engine `search: ` per object and lets one expansion resolve the fields and compile the clause. - -What a caller observes changing on `GET /api/v1/search` — both are widenings; no query that returned a hit before returns fewer: - -- **Pinyin/initials recall now works on this endpoint.** Where the deployment provisions the hidden `__search` companion column (`OS_SEARCH_PINYIN_ENABLED`), latin terms are OR-ed against it, so `hnkj` and `huaningkeji` now return the CJK-named record that `POST /api/v1/data/:object/query {"search":"hnkj"}` already returned. Previously: 0 hits. -- **Which columns are scanned now follows the object, not a field flag.** Resolution is the object's declared `searchableFields`, else the auto-default (display/name field plus short-text and enum fields) — the set `searchableFields` documents itself as governing. The endpoint previously scanned only text-typed fields carrying the field-level `searchable: true` flag, falling back to the title field alone, so most objects were searched on one column. Hits from a second column (an email, a description, a select's label) are new. -- Enum (`select`/`status`) columns are now matched by option LABEL, and virtual `formula` fields are excluded, both as on the executor path. -- **The endpoint no longer substring-scans primary keys.** An object whose only text-typed column is `id` — system tables, junction tables, append-only logs — used to fall through to "the first text-typed field" and be queried as `{id: {$icontains: term}}` on every keystroke. Such objects are now skipped, as `$search` already skipped them (#4483). Callers relying on a bare `id` fragment matching through this endpoint will no longer get that hit; query the record by id instead. - -Unchanged: which objects are swept and their opt-outs (`enable.searchable`, `enable.apiEnabled`, the `sys_*` skips), the per-object and overall caps, ordering, RLS/RBAC enforcement, and the response shape. The `$search` executor path itself is untouched. A record matched only through the pinyin companion has no `snippet` — no source column contains the typed term. - -Also corrects the stale case declaration on this path (#7850): the doc comment said "case-insensitive LIKE" while the sentence below it named `$contains`, which #4706 Q2 = A defines as case-**sensitive**. Matching folds case via `$icontains`; behaviour is unchanged by that edit. diff --git a/.changeset/temporal-filter-comparand-refused-at-engine-door.md b/.changeset/temporal-filter-comparand-refused-at-engine-door.md deleted file mode 100644 index 9345ea652a..0000000000 --- a/.changeset/temporal-filter-comparand-refused-at-engine-door.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/core": patch -"@objectstack/objectql": patch -"@objectstack/service-analytics": patch ---- - -fix(objectql): a temporal filter comparand the platform cannot interpret is refused at the engine door instead of answering 200 with zero rows (#8690) - - - -A `datetime` / `date` / `time` field filtered with a bare string the platform -cannot read — `last_30_days`, `not-a-date-at-all` — was bound **as written** -all the way to the driver, where the comparison is false for every row. The -caller received `HTTP 200`, an empty result set, and nothing to indicate the -filter was meaningless. An unknown `{placeholder}` in the same position was -already refused loudly (`FILTER_TOKEN_UNKNOWN` / 400, listing the resolvable -tokens), so one API answered two shapes of unusable comparand two different -ways. - -It is concretely reachable rather than theoretical: `last_7_days` / -`last_30_days` / `last_90_days` are **declared preset names** in the dashboard -schema. The shipped console lowers them to `{N_days_ago}` macros before they -reach the API, so the console path was always safe — but a saved report, an -integration, an MCP client or an AI-authored query sends the preset name itself -and got a silent zero. An empty chart is the hardest failure to debug: it is -indistinguishable from "there is genuinely no data". - -Such a comparand is now refused at the ObjectQL engine's single filter -collection point, with `code: 'INVALID_FILTER'` and `status: 400`, naming the -field, the value, the key path and the spellings that would work. That seam is -the one place holding the caller's comparand and the field's **declared type** -at the same moment, and every verb (`find` / `findOne` / `count` / `aggregate` -/ `update` / `delete`) and both filter spellings (the array sugar and the -lowered condition) pass through it, so all four backends inherit one answer -rather than four. `NativeSQLStrategy` additionally **declines** such a query so -the raw-SQL analytics path falls through to that door instead of binding the -value into its own statement. - -Deliberately unchanged, each by ruling: a `{placeholder}` keeps its existing -refusal one layer down (the door runs before token resolution and steps around -them, so `{30_days_ago}` still resolves normally); non-string comparands are -untouched (a number is epoch milliseconds, a `Date` is an instant); and the -**empty string** keeps today's behaviour exactly — it binds as `''` and matches -every non-null row, which is a separate question that remains its own card. diff --git a/.changeset/tenancy-organization-field-stamp-only.md b/.changeset/tenancy-organization-field-stamp-only.md deleted file mode 100644 index 72204020cf..0000000000 --- a/.changeset/tenancy-organization-field-stamp-only.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -"@objectstack/spec": minor -"@objectstack/plugin-audit": minor -"@objectstack/platform-objects": patch ---- - -feat(spec): stamp-only `tenancy.organizationField` — audit rows can follow the record's organization on objects that must stay unwalled (#8778, closes the #8707 remainder) - -The platform had one answer to "what is this object WALLED by" -(`tenancy.tenantField`) and no answer to "which column says who this row is -ABOUT". For ordinary objects the two coincide; for credential tables they -deliberately do not — `sys_api_key` records the organization a key -authenticates into under `active_organization_id` precisely so the credential -table is not org-walled (#8287). #8777's schema-resolved audit stamping could -therefore reach every shipped object except the one that motivated it, and -revocation rows on `sys_api_key` kept stamping the revoker's organization. - -`TenancyConfigSchema` now accepts an optional `organizationField` — a -READ-NEUTRAL, STAMP-ONLY declaration (maintainer-ruled option A on #8778): - -- The audit writer's `resolveRecordOrganizationField` consults it first, ahead - of the ADR-0066 `enabled: false` opt-out — an author declaring it on an - unwalled object is stating exactly that the audit trail should follow the - record's own organization even though no wall does. It is honoured only when - the object really has the field (the #5315 guard `tenantField` carries). -- No read path reads it: `applyTenantScope`, `injectTenantOnInsert`, - `computeTenantLayer0Filter` and `resolveInjectedSystemColumns` are all - measured blind to it, and that read-neutrality is pinned by tests beside - each. Declaring it never walls an object and never hides rows. -- ⛔ Scope pin from the ruling: this is ONE stamp-only key, not the opening - move of a general field-roles mechanism. A consumer other than audit - stamping needs its own ruling before reading it. - -`sys_api_key` now declares -`tenancy: { enabled: false, organizationField: 'active_organization_id' }`, -so revoking another user's key from a different active organization lands the -audit row behind the wall of the KEY's organization — where the tenant admin -who can act on it reads it. The `enabled: false` is measured -behavior-identical to the previous absent block for this object on every read -path (injection bails on `managedBy: 'better-auth'` first; the SQL driver's -tenant field resolves null either way; Layer 0 is exempt either way; the -memory/mongo boot guards count only an explicit `enabled: true`). diff --git a/.changeset/tenant-index-follows-the-wall.md b/.changeset/tenant-index-follows-the-wall.md deleted file mode 100644 index df248d1f10..0000000000 --- a/.changeset/tenant-index-follows-the-wall.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -fix(objectql): the tenant-scope index follows the WALL's derivation, so an object that opts out with `systemFields: false` while declaring its own `organization_id` stops running the wall predicate unindexed (#8608) - - - -Two places answered *"is this object tenant-scoped?"* and read different -declarations. The platform's tenant-scope index was gated on the spec's -**injection plan** (`resolveInjectedSystemColumns(...).tenant`), while -plugin-security's Layer 0 wall derives `tenancyDisabled` from exactly two -clauses: - -```ts -tenancy.enabled === false || systemFields.tenant === false -``` - -`systemFields: false` — the hard object-level opt-out — is in the plan and in -neither of those clauses. So an object using that opt-out **while declaring its -own `organization_id`** had `organization_id = ` AND-composed onto -essentially every read, with no index behind it: the deployment's hottest -predicate, unindexed. Not a security hole — isolation still held; it was slow, -not wrong, which is why nothing surfaced it. - -**Both halves were measured end to end** rather than read off the source. On the -pre-fix tree, for one such object, the registry answered `indexes: null` while -`SecurityPlugin#getReadFilter` answered `{ organization_id: 'org-1' }` for an -ordinary member. - -The wall's derivation is authoritative and the index now follows it: the index -is declared when tenancy is not disabled by the wall's two clauses **and** the -object carries `organization_id` — whether the platform provisions the column or -the author declared it. `managedBy: 'better-auth'` is deliberately not re-added -as a third clause, because the wall does not read it either; the one shipped -platform object whose answer changes is `sys_member`, which is walled on -`organization_id` and whose only tenant-leading index was the composite -`['organization_id', 'user_id']`. - -Unchanged, and pinned beside the fix: `systemFields.tenant: false` and -`tenancy.enabled: false` still declare no index (the wall composes no predicate -there, so an index would serve nothing), a single-tenant deployment still -declares none at all, an author's own tenant index still suppresses the -platform's, and the hard opt-out still injects no platform columns — only the -index decision was ever owed at that exit. diff --git a/.changeset/tough-jars-invite.md b/.changeset/tough-jars-invite.md deleted file mode 100644 index 767777ebd4..0000000000 --- a/.changeset/tough-jars-invite.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -'@objectstack/metadata-protocol': patch ---- - -Stop `GET /api/v1/meta/:type/:name/diff` serving stored credential values. - -`diffMetaItem` compared two stored metadata bodies and emitted the raw values it -found, so a `datasource` row whose credential rotated between versions returned -both the old and the new password in cleartext (inline `config.password` and the -password component of `config.url` alike). - -The diff is still computed on the RAW bodies — a credential rotation continues to -report its path as changed — but the emitted `value` / `from` / `to` are now taken -from the type's redacted projection of those same bodies, on both sides. Types -with no registered redactor are unaffected and keep serving their values by -reference. diff --git a/.changeset/translation-staleness-source-hash.md b/.changeset/translation-staleness-source-hash.md deleted file mode 100644 index f493c66f86..0000000000 --- a/.changeset/translation-staleness-source-hash.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -"@objectstack/platform-objects": patch ---- - -fix(platform-objects): a translated Setup/Studio/Account label whose source string has been edited underneath it now serves the source text instead of the stale translation (#8765) - -The `apps` / `dashboards` / `pages` half of this package's i18n is hand-authored -per locale. Every gate over it judges **presence or ownership** — -`app-nav-translation-parity.test.ts` (a translation exists for every declared -id, and none outlives its declaration), `check:i18n-coverage` (ratchets -*untranslated* labels), `check:app-nav-i18n` (a label per locale on the merged -nav tree). A translated value that has gone **stale** satisfies every one of -them: it is present, it is owned, it is not untranslated. - -So a source-string edit left `zh-CN` / `ja-JP` / `es-ES` serving the previous -translation indefinitely, under a fully green build — which is how -`widget_recent_events` shipped its pre-conversion title in all four locales. -Pinning `en` to the declared source did not create that drift, but it removed -the one accidental symptom that made it visible: the drift stopped being -uniform across four bundles and became locale-specific, invisible to every -reviewer who reads the product in English. - -**Ruled Option B** (#8765): record the source hash at translation time; a hash -mismatch marks the translation stale, and stale falls back to the source text. - -- Each translated locale ships a `.source-hashes.ts` table recording, - per leaf, the digest of the `en` source string that leaf was translated from. - `setup.translation.ts` compares them against the current source when it - assembles the bundle the kernel is handed. -- **Edit a source string** ⇒ that leaf falls back to the source text in every - locale that had translated it. -- **Update one translation** (its value *and* its recorded hash) ⇒ **that locale - alone recovers**; the others keep falling back. -- **A leaf with no recorded hash is legacy-trusted**, not stale. The tables were - backfilled once from the then-current source, so no existing translation - degraded when this landed. - -**No new failure mode, and no new gate.** The fallback substitutes the source -string rather than deleting the key, so no key set moves; a translated locale -carrying the source string verbatim is exactly what the extractor already -writes for an untranslated key under `--fill=default`, and exactly what the -resolver's locale chain has always rendered. Staleness degrades what is -*served* — it never fails a build, which would put a four-locale translation -task in front of every one-word source edit. - -Scope is the hand-authored sections only. `objects` / `metadataForms` are -generated, and the hole cannot occur there: `os i18n extract` rewrites the `en` -bundle from the source on every run and does not merge the default locale, so a -source edit either lands in the generated bundle or fails `check:i18n` as drift. diff --git a/.changeset/ui-record-blocks-unknown-keys-refused.md b/.changeset/ui-record-blocks-unknown-keys-refused.md deleted file mode 100644 index 440ba71b11..0000000000 --- a/.changeset/ui-record-blocks-unknown-keys-refused.md +++ /dev/null @@ -1,90 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): declare `record:alert` / `record:quick_actions` / `record:history` / `record:discussion` in `ComponentPropsMap` — undeclared keys on the four are refused (#8744) - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -These were the four `record:*` types #8691's rail fix left in the rail's own -pre-fix position: a registered objectui renderer, a `PageComponentType` entry -and a console palette slot (bar `record:discussion`, which was authorable only -through the type union's open string arm), and no `ComponentPropsMap` row — so -the #5068 component-props gate's dispatch skipped them as unregistered and -every authored key rode through. A typo'd `severty` on the platform's own -banner surface parsed, typechecked, validated, built and shipped as a silent -no-op while sibling components in the same file drew loud diagnostics. - -The new rows are strict and declare exactly what the renderers read, measured -from their read points at the objectui pin — not from the registrations' -declared-input lists, which are wrong in both directions here: - -- `record:alert` — `severity?`, `title?` / `body?` (string **or inline locale - map** — this renderer resolves both through `pickLocalized`, the opposite - verdict from the rail's literal-string `title`, measured the same way), - `visible?` (boolean | CEL string | `{ dialect, source }` envelope), `icon?` - (read here, unlike the rail's), `action?` `{ actionName, label?, variant? }`, - `dismissible?`, `dismissKey?`. `visibleWhen` / `visibility` rename to - `visible` as aliases — this is the one record component whose props-level - predicate is real, so the wrong-layer visibility guidance does not apply. -- `record:quick_actions` — `actionNames?`, `requiredPermissions?`, `location?` - (the spec's own `ActionLocationSchema`, retirement prescriptions included), - `align?`, `inline?`, `variant?` / `size?` (the Button primitive's delivered - vocabulary). `actions` is refused with a prescription (as a name list it is - `actionNames`; as inline defs it is the host synthesizer's runtime channel). - `aria` is refused rather than declared: the renderer reads `aria.label`, a - spelling the shared `AriaPropsSchema` refuses, and reads nothing else of the - bag — declaring either spelling would be declared-but-unenforced surface - (the renderer-side fix is objectui's, filed). -- `record:history` — `limit?`, `emptyText?` / `unknownUserText?` (literal - strings — the timeline renders them raw; a locale map would paint - `[object Object]`). `entries` / `loading` are refused as the host's data - channel: omit them and the block self-fetches the record's `sys_activity` - history. -- `record:discussion` — `record:chatter`'s own row, deliberately the same - schema object (one renderer registered under two names must keep one accept - face), plus a `PageComponentType` entry so the name is no longer a - string-arm stowaway. - -**What stays accepted:** every declared key byte-identically — the platform -`sys_user` page's banner and self-service action bars and the showcase task -page pass with zero findings. No row carries a schema default (renderer -fallbacks stay the renderer's facts). The one parse-time normalization is -`ExpressionInputSchema`'s own: a bare-string `visible` becomes the canonical -`{ dialect: 'cel', source }` envelope. - -## FROM → TO - -```ts -// before — parsed green everywhere; the banner styled itself `info` anyway -{ - type: 'record:alert', - properties: { - severty: 'warning', // silent no-op typo - title: 'Awaiting review', - }, -} - -// after — the typo is a publish-time refusal naming the rename; write the -// measured shape -{ - type: 'record:alert', - properties: { - severity: 'warning', - title: 'Awaiting review', - visible: "record.status == 'in_review'", - }, -} -``` - -There is deliberately no automatic rewrite: an undeclared key is either a -spelling of a declared one (the rejection names the rename) or names a -capability the renderer does not deliver, and blessing either would be -declared-but-unenforced surface (ADR-0078). `os migrate meta` surfaces the -change as a structured TODO (semantic entry -`ui-record-blocks-unknown-keys-refused`, protocol major 18 — this refusal is -not part of the v17.0.0 cut). - - diff --git a/.changeset/ui-reference-rail-unknown-keys-refused.md b/.changeset/ui-reference-rail-unknown-keys-refused.md deleted file mode 100644 index 870485e942..0000000000 --- a/.changeset/ui-reference-rail-unknown-keys-refused.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -"@objectstack/spec": minor ---- - -feat(spec): declare `record:reference_rail` in `ComponentPropsMap` — undeclared rail keys are refused (#8691) - -**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep -launch-window convention ships it as `minor`; the migration prescription is -registered under protocol major 18, where `os migrate meta` users will look). - -`record:reference_rail` had a registered renderer, a `PageComponentType` entry -and a console palette slot, but no row in `ComponentPropsMap` — so the #5068 -component-props gate's dispatch skipped it as unregistered and every authored -key rode through. Measured on 17.0.0 GA end to end: a planted entry `filter` -passed tsc, `objectstack validate` and `objectstack build`, shipped verbatim in -`dist/objectstack.json`, and the rendered rail kept counting and listing -unfiltered rows — while the very same build loudly reported -`record:related_list` keys in the same file. - -The new row is strict and declares exactly the shape the renderer reads -(measured from its read points at the objectui pin, not from its TS -interface): `entries[]` of `{ objectName, relationshipField, title?, limit?, -displayField? }` plus a component-level `hideEmpty`. - -**What is refused:** any key the shape does not declare, with a prescriptive -message — the planted `filter` (the rail issues one fixed query per entry; -`record:related_list` is where `filter` is real), the interface's `icon` (read -by no render path — declaring it would be declared-but-unenforced surface), -entry-level `hideEmpty` (a component-level key), and the neighbouring-surface -spellings `items`/`related` → `entries`, `object` → `objectName`, `label` → -`title`. `title` is a literal `z.string()` — the renderer paints it as a raw -React child, so an inline locale map is refused rather than shipped as -`[object Object]`. - -**What stays accepted:** every declared key byte-identically. `limit` and -`hideEmpty` carry no schema default (the renderer's `3` / `true` fallbacks stay -the renderer's), so a minimal entry round-trips unchanged. - -## FROM → TO - -```ts -// before — parsed green everywhere; the badge kept counting everything -{ - type: 'record:reference_rail', - properties: { - entries: [{ - objectName: 'task', relationshipField: 'project_id', - filter: [{ field: 'status', op: 'neq', value: 'completed' }], // silent no-op - icon: 'CheckSquare', // read by nothing - }], - }, -} - -// after — both keys are publish-time refusals with prescriptions; write only -// what the renderer reads -{ - type: 'record:reference_rail', - properties: { - entries: [{ objectName: 'task', relationshipField: 'project_id', limit: 3 }], - hideEmpty: false, - }, -} -``` - -There is deliberately no automatic rewrite: an undeclared key is either a -spelling of a declared one (the rejection names the rename) or names a -capability the rail does not deliver — a per-entry `filter` and an inline -`title` locale map are open capability questions for the console seat, and -blessing either spelling now would be declared-but-unenforced surface -(ADR-0078). `os migrate meta` surfaces the change as a structured TODO -(semantic entry `ui-reference-rail-unknown-keys-refused`, protocol major 18 — -this refusal is not part of the v17.0.0 cut). - - diff --git a/.changeset/undeclared-field-preflight.md b/.changeset/undeclared-field-preflight.md deleted file mode 100644 index 3b1aaaf5ab..0000000000 --- a/.changeset/undeclared-field-preflight.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -Refuse undeclared fields on insert at the schema, and keep bound values out of the write-path logs (#8682) - -**A single mistyped field name in a client request no longer writes an entire row's values to disk.** A driver-level write fault is logged by prefixing the fully bound SQL statement — values inlined — to the database's own message, and the logger serializes both `message` and `stack`, so the statement was written twice at ERROR level. Confirmed with planted canaries: the row's values landed in the log alongside the organization id and the acting user id. The insert, update and delete loggers now write the database's own diagnostic — which still names the failing column and the object — with the statement and its bound values cut from both fields. The level, the message and the entry itself are unchanged: a driver fault nobody can debug would be a worse outcome than one logged too loudly. - -**An undeclared field is now refused by the object's field map, before anything runs for a request that was already going to be refused.** Previously an unknown key was caught only at the very end, by the driver, after an id, an auto-number, a normalized name, owner/creator resolution, the column defaults and the app's `beforeInsert` hooks had all been produced for it. The auto-number was the durable damage: the refused request consumed a sequence value and left a permanent gap in a document number an end user reads. `insertMany` now culls such a row per row instead of letting it fail the whole batch. - -The client-facing answer is deliberately unchanged — the same `400 INVALID_FIELD`, with the same message and the same `field` / `object` — and the rethrown error is untouched, so only what reaches the log has moved. Objects whose field map is absent or empty get no verdict at all, and `id` / `created_at` / `updated_at` stay accepted even when a declaration omits them, matching what the read path already tolerates; in every one of those cases the driver remains the backstop it has always been. diff --git a/.changeset/undeclared-update-field-door.md b/.changeset/undeclared-update-field-door.md deleted file mode 100644 index b7a7fdee89..0000000000 --- a/.changeset/undeclared-update-field-door.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"@objectstack/objectql": patch ---- - -Refuse undeclared fields on update at the schema, before the `beforeUpdate` hooks run (#8738) - -**An undeclared key on `engine.update(...)` is now refused by the object's field map, before anything runs for a request that was already going to be refused.** Previously it travelled the whole update path and was refused at the very end by the driver — measured on both branches of the verb: `driver.update` on the by-id path and `driver.updateMany` on the predicate path each received the mistyped key. The `beforeUpdate` hooks ran first, so a hook that stamps a ledger, calls out, or derives a field executed for a write that was then rejected; in the reproduction the hook's derived value travelled into the statement the driver refused. - -**What a caller observes changing.** The refusal itself does not move: an undeclared update key was already rejected, and the client-facing answer is deliberately unchanged — the same `400 INVALID_FIELD` with the same message, `field` and `object`, which `@objectstack/rest` re-emits verbatim. What changes is where the refusal is decided, and therefore what the error carries **inside the process**: an in-process caller of `ObjectQL.update()` that caught the old failure saw the driver's raw error (no `code`, no `status`, its message containing the bound SQL statement) and now sees the ADR-0112 envelope (`code: 'INVALID_FIELD'`, `status: 400`) with a message naming the field. An in-process caller matching on the driver's SQL text — rather than on the envelope — is the one shape that has to change. The write no longer costs a driver round-trip either: the pre-update read is skipped along with the hooks. - -The door is the same one `insert()` has carried since #8682 — one condition, one implementation, now with two callers — including its three deliberate no-opinion cases, which are unchanged and reused rather than re-derived: an absent field map, a field map the door sees as empty, and `id` / `created_at` / `updated_at` when a declaration omits them. Schema drift (a declared field whose physical column is missing) stays the driver's to refuse, as before. Nothing is widened; `declared = enforced` (Prime Directive #10) is restored on the second write verb. diff --git a/.changeset/union-branch-policy-cross-package-parity.md b/.changeset/union-branch-policy-cross-package-parity.md deleted file mode 100644 index a1e231951d..0000000000 --- a/.changeset/union-branch-policy-cross-package-parity.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -"@objectstack/metadata-protocol": patch ---- - -test(metadata-protocol): pin the THIRD union-branch policy copy against `@objectstack/spec` (#8660) - -The union-branch selection policy — kind-mismatch drop, fewest-issues ranking, -`unrecognized_keys` tie-break, declaration-order determinism, depth limit 3, -branch cap 3 — has three implementations. #8318 (PR #8659) consolidated the two -inside `packages/spec` into one package-internal module and pinned them with a -shared-fixture parity test. The third, `zodIssuesToMetadataIssues` in -`protocol.ts` (the walk behind `saveMetaItem`'s `422 INVALID_METADATA` and the -read path's diagnostics), was structurally out of that consolidation's reach: -the shared module is deliberately not a public export (#4001), so a consumer in -another package cannot import it. - -That left this copy exactly where the spec pair sat before #8318 — held in step -by a header comment and nothing else. A future tie-break or ranking tweak lands -in `union-branch-policy.ts` for both spec walks at once and silently not for -this one, and then the same authored metadata gets one prescription from the -terminal, another from the data API, and a third from Studio: the forked verdict -#5014 ruled out. - -`src/union-branch-policy.cross-package-parity.test.ts` is the enforcement the -header stood in for. One fixture corpus, one `safeParse` per fixture, three -walks reached through PUBLIC surfaces only — `formatZodIssue` from -`@objectstack/spec`, `zodIssuesToFields` from `@objectstack/spec/api`, and this -package's own copy — compared as ordered `(path, message)` pairs. The corpus -covers every element of the policy by name, plus a hand-authored expectation per -fixture so both sides drifting the same way still fails. The two deliberate -asymmetries (the prose-only omission line, and raw zod codes here vs the -ADR-0114 catalog on the wire) are asserted in place rather than normalised away. - -**`patch`, deliberately not a skipped changeset.** No production line changes, -no export moves, and every assertion is green on `main` before this lands — but -the bump floor is right rather than absent, for the same reason -`legacy-unique-guard-attribution` took one: what ships is a ratchet on -release-relevant behaviour. The 422 envelope this pins is a published contract -of `@objectstack/metadata-protocol`, and a consumer reading the CHANGELOG should -be able to see when its verdict acquired mechanical protection against drifting -away from the spec's. diff --git a/.changeset/union-branch-policy-one-implementation.md b/.changeset/union-branch-policy-one-implementation.md deleted file mode 100644 index 3913a48a9a..0000000000 --- a/.changeset/union-branch-policy-one-implementation.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -"@objectstack/spec": patch ---- - -refactor(spec): the union-branch selection policy has ONE implementation, and a parity test that keeps it that way (#8318) - -`shared/error-map.zod.ts` (the prose renderer, #4971/#5389) and -`api/zod-issues-to-fields.ts` (the ADR-0114 D3 wire mapper, #8124) carried the -SAME union-branch selection policy as two separate implementations — -kind-mismatch drop, fewest-issues ranking, `unrecognized_keys` tie-break, -declaration-order determinism, depth limit 3, branch cap 3, and the -`invalid_key` / `invalid_element` container codes. While the mapper still lived -in `@objectstack/rest` the duplication was forced; #8124 moved it into this -package, so the two sat one directory apart with their module headers — and -nothing mechanical — asking whoever edits one to edit the other. - -The policy now lives in one package-internal module, -`src/shared/union-branch-policy.ts`, which both walks import. It is deliberately -NOT a public export: it is absent from every barrel, and `api-surface/` and -`export-origins/` do not move. - -The two WALKS stay separate implementations, as they should — one renders -indented `✗ path: message` prose for a terminal, the other produces -`{field, code, message}` entries for a JSON envelope, and only the renderer -emits the trailing "… and N more branches rejected this value" line. That -asymmetry is now explicit rather than implicit: `selectUnionBranches` returns -`{selected, omitted}`, the renderer prints `omitted`, and the mapper -destructures `selected` alone at a commented line, because a `fields[]` entry -must name a real field and carry a catalog code and an omission count has -neither. - -`src/shared/union-branch-policy.parity.test.ts` is the enforcement the module -headers lacked: one `safeParse` per fixture feeds BOTH walks, and their outputs -are compared pair for pair after a normalisation that removes the indent, the -`✗` glyph and the `(root)` spelling — nothing else. The corpus covers every rule -of the policy (kind-mismatch drop, all-kind-mismatch, fewest-issues ranking, the -`unrecognized_keys` tie-break, declaration-order determinism, the depth limit, -the branch cap, and container descent for both `invalid_key` and -`invalid_element`), and the one deliberate asymmetry is asserted rather than -normalised away. - -Behaviour is unchanged for every issue zod produces: the ranking, both limits -and the container-code set are byte-identical to what each walk applied before. -The single deliberate widening is that the shared policy reads a missing or -non-array `path` as the root — the wire mapper's already-shipped normalisation, -now applied to the renderer too, which previously threw on such an issue object. -No value satisfying the renderer's own `ZodIssueMinimal` type is affected. diff --git a/.changeset/unique-violation-absence-sentence-superstring.md b/.changeset/unique-violation-absence-sentence-superstring.md deleted file mode 100644 index 308f96a959..0000000000 --- a/.changeset/unique-violation-absence-sentence-superstring.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -"@objectstack/types": patch ---- - -fix(types): `isUniqueViolationError` stops claiming the sentences that say a unique constraint is ABSENT (#8590) - -The shared predicate's message limb was a bare `unique constraint`, and a word -pair is not a condition. Every dialect that can say "this row violated a unique -constraint" can also say "there is no unique constraint here", and the same two -words sit adjacent in both — so the predicate answered **true** for errors -meaning the exact opposite of what it detects. `rest-server.ts` maps that -verdict to `409 UNIQUE_VIOLATION`, which tells a client to change a value when -nothing was ever compared, on a status an SDK will not retry. - -**Measured on live servers for this fix, all three supported dialect families** -— SQLite via better-sqlite3, PostgreSQL 16.13 via `pg` 8.22.0, MariaDB 10.11.14 -via `mysql2` 3.23.1, all through knex 3.3.0 — driving each dialect through both -conditions plus the NOT NULL / FOREIGN KEY near misses: - -``` -sqlite ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint - -> was true, WRONG (the reported defect, #8590) -postgres there is no unique constraint matching given keys for referenced table "t" - -> was true, WRONG (42830 — found by this fix's dialect sweep) -postgres there is no unique or exclusion constraint matching the ON CONFLICT specification - -> false (the pair is not adjacent here) -mysql the condition cannot arise: knex compiles to ON DUPLICATE KEY UPDATE, - which carries no conflict target (confirmed against a live server) -``` - -**Postgres was not clean either, and that chose the fix.** #8590 was filed -reading the collision as SQLite-only, with Postgres escaping "by luck of word -order". The sweep raised **42830** — a `FOREIGN KEY` referencing a non-unique -column — where Postgres puts `unique constraint` adjacent in its own absence -sentence. The card offered two candidate fixes; only one survives 42830. A -negative lookahead on SQLite's missing-index sentence is a blocklist that can -only enumerate absence sentences somebody already tripped over, and it answers -`true` on 42830. So the limb now requires a **violation phrasing** — -`unique constraint failed` (SQLite) or `violates unique constraint` (Postgres) — -which restores the module's own stated default, *unrecognised is `false`*, to -the message channel. - -**Both spellings the retired limb covered are preserved exactly**, which was the -constraint on the fix: the limb was inherited verbatim from the REST branch -#6250 replaced and covered SQLite's `UNIQUE constraint failed: t.c` *and* -Postgres' `... violates unique constraint "..."`. The `unique violation`, -`duplicate key` and `duplicate entry` limbs are untouched, as are the `code` and -`errno` channels — MySQL's `Duplicate entry` path never went through the -narrowed limb at all. - -**No user-visible behaviour changes today; this closes a latent inversion.** The -one site compiling a caller-supplied conflict target (`SqlDriver.upsert`) -recognises the unbacked target *first* in its catch and throws a refusal -declaring `status: 400`, and `mapDataError` reads `declaredHttpStatus` before it -reaches the unique-violation branch — so the 409 was gated off the wire by -ordering, not by the verdict. That ordering was the only thing standing between -this and a wrong status, which is why the verdict is now pinned rather than left -to it. A repo-wide scan of every string literal whose verdict moves found no -consumer relying on the old answer: all of them are prose, a different -predicate's vocabulary (`looksLikeInternalErrorLeak` keeps its own list), or -fixtures asserted through the status-passthrough path. - -`unbacked-conflict-target.test.ts`'s pin — written by #8567 to point at itself -rather than go quietly green — is **inverted, not deleted**, and -`unique-violation-absence-sentences.test.ts` pins the absence sentences per -dialect in both directions, including the code channel, so re-reading `code` -cannot undo the message-side fix from the other side. diff --git a/.changeset/upsert-id-insert-only.md b/.changeset/upsert-id-insert-only.md deleted file mode 100644 index 353494f711..0000000000 --- a/.changeset/upsert-id-insert-only.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -"@objectstack/driver-sql": patch ---- - -fix(driver-sql): a merge-path upsert stops rewriting the row's primary key (#8622) - - - -`upsert(data, conflictKeys)` on a **business key** — the ordinary way to ingest -external data — silently replaced the `id` of the row it merged into. Every -relationship, audit record, external id mapping and client-held reference -pointing at that row was left dangling, with no error raised on any dialect. - -Measured on a properly BACKED conflict target (`email` declared `unique: true`), -so this was the supported path, not an error path: - -``` -upsert({ email: 'x@b.com', title: 'first' }, ['email']) -upsert({ email: 'x@b.com', title: 'second' }, ['email']) - -[sqlite] before=[{id:'yMh3oywrp0Z6p-oJ', title:'first'}] - after =[{id:'d8T8rUlTxlRlaUhN', title:'second'}] idPreserved=false -[pg] before=[{id:'T3AlYiyDi5buzGvW', title:'first'}] - after =[{id:'TvbCTa5mydWPYP76', title:'second'}] idPreserved=false -``` - -One row throughout, as intended — with a different primary key. `upsert` mints a -nanoid for any call that supplies none, and `id` travelled in the merge set, so -`… on conflict ("email") do update set …, "id" = excluded."id"` wrote the -**losing** insert's fresh id over the winning row's. On the default `['id']` -conflict target that clause is a no-op (both sides hold the same value), which is -exactly why it stayed invisible for so long. - -`id` is now insert-only on the merge path, joining `created_at` and the -`auto_number` columns (#7011) in `insertOnlyUpsertColumns` — the same exclusion -argument at its strongest instance, since the primary key *is* the platform's row -identity. It is resolved through `remoteColumn`, because a federated object can -bind `id` to a differently-named physical column (ADR-0015 §18) and a literal -`'id'` would filter nothing there. - -**The accept set is unchanged**: the same calls still succeed, still merge, and -still advance `updated_at` and every other mergeable column — the merge simply -stops rewriting row identity. Re-keying a row deliberately is still `update()`'s -job, which writes exactly the columns it is handed. - -Measured on SQLite and live PostgreSQL 16.13. Live MySQL 8.0.46 measured the same -rewrite in #8592 and its characterization pin is rewritten here to assert -preservation; that cell had no server available in this container and runs first -in CI's `Temporal Conformance (live PG + MySQL)` job. diff --git a/.changeset/webhook-headers-secret-shape-gate.md b/.changeset/webhook-headers-secret-shape-gate.md deleted file mode 100644 index e9fe381115..0000000000 --- a/.changeset/webhook-headers-secret-shape-gate.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -"@objectstack/plugin-webhooks": patch ---- - -fix(webhooks): refuse a malformed `sys_webhook.headers_secret` at the write door instead of at the next delivery (#8566) - - - -`sys_webhook.headers_secret` is a `Field.secret()` whose plaintext is **not** an -opaque blob: it is a serialized header map with a required shape — a flat JSON -object of string values — and `parseStoredHeaders` is its only reader. Nothing -validated that shape on the way in. The ordinary data API accepted any string, -encrypted it like any other secret, minted a real `sys_secret` row, and left the -column holding a perfectly valid `secret:` ref that read back as the mask with -`active: true`. - -Measured on a real engine through `engine.update()` — the ordinary data API, no -privileged access — every one of these was **accepted** and is a value the -plugin can never use: `{}`, `[]`, `{"X-Count": 5}`, a nested object, and -`{X-Team: crm}` (a typo). The field is directly admin-authorable and its own -description instructs the author to type a JSON object into it, which makes a -typo the *expected* failure rather than an exotic one. - -**This is not an exposure fix and must not be read as one.** #8558/#8565 already -closed the consumer half: a webhook whose stored header map does not come back -as a flat string map parks the subscription and reports at `error`, rather than -delivering header-less with a valid signature. Nothing leaks, and nothing is -silently lost today. What this changes is **when the author finds out** — at the -write door where they typed it, instead of at the next matching record change, -an unbounded time later and in a different surface. - -**What is refused:** a `headers_secret` plaintext that does not parse back as a -flat JSON object of string values with at least one entry, with a located -ADR-0112 `VALIDATION_ERROR` / 400 naming `sys_webhook.headers_secret`, quoting -the shape the field's own description asks for, and diagnosing the specific -spelling (invalid JSON / an array / an empty object / which key's value is not a -string). ⛔ The message never echoes the rejected value — this column carries -credentials, and quoting the input would print an `Authorization: Bearer …` into -logs and error bodies, re-opening in the diagnostic exactly the exposure #7986 -moved this field onto the encrypted channel to close. It names header *keys* and -value *types* only. - -**What stays accepted, byte for byte:** every valid flat string map (as JSON -text, or as an authored object the engine serializes into the same form); `null` -to clear; an omitted key to leave the stored value unchanged; and an **echoed -read-mask**, so the ordinary Setup-form round-trip (GET a row, edit an unrelated -field, PATCH it back) is untouched. `""` is deliberately passed through to -#8559's `EmptyCredentialWriteError` rather than re-refused here — one door, one -owner, one message. - -**Where it runs, and why that is the whole mechanism:** a `beforeInsert` / -`beforeUpdate` hook on `sys_webhook`, bound by `WebhookOutboxPlugin` before its -first seeded write. It has to run *before* the engine's `encryptSecretFields` — -one step later the plaintext is gone and the column holds an opaque ref, so a -validator behind it would have nothing left to validate. The suite measures that -ordering rather than asserting it: every refusal pins that **no `sys_secret` -cipher row was minted**, which is only true if the gate ran first. - -A hook rather than checks on the plugin's own write paths -(`bootstrapDeclaredWebhooks` / `headersPatch` / the migration sweep), because a -direct `PATCH /api/v1/data/sys_webhook` goes through none of them and that is -the measured trigger. Those paths inherit the validation through the hook and -deliberately carry no second check. - -A general `secret`-channel plaintext validator — letting any `secret`-typed -field declare its own plaintext shape — is the principled generalization and is -recorded as the **promotion path**, not built here: it becomes the shape the -moment a second shaped-plaintext `secret` field exists (maintainer ruling -2026-08-13; one consumer does not justify a general capability). diff --git a/examples/app-crm/CHANGELOG.md b/examples/app-crm/CHANGELOG.md index bdd2ec03a7..9237a74ce4 100644 --- a/examples/app-crm/CHANGELOG.md +++ b/examples/app-crm/CHANGELOG.md @@ -1,5 +1,34 @@ # @objectstack/example-crm +## 4.0.93 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [3d61924] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [20067c5] +- Updated dependencies [e783e16] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [4fc4a3c] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [19db5fa] +- Updated dependencies [2b9d33a] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/runtime@17.1.0 + ## 4.0.92 ### Patch Changes diff --git a/examples/app-crm/package.json b/examples/app-crm/package.json index a33bf9ed78..ccdd26ec6e 100644 --- a/examples/app-crm/package.json +++ b/examples/app-crm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-crm", - "version": "4.0.92", + "version": "4.0.93", "description": "Minimal CRM example — a smoke-test workspace that exercises the metadata loading pipeline (objects → views → app → dashboard → hook → flow → seed). For a full-featured enterprise CRM see https://github.com/objectstack-ai/hotcrm.", "license": "Apache-2.0", "private": true, diff --git a/examples/app-showcase/CHANGELOG.md b/examples/app-showcase/CHANGELOG.md index 5b0c85ac2e..f92e571021 100644 --- a/examples/app-showcase/CHANGELOG.md +++ b/examples/app-showcase/CHANGELOG.md @@ -1,5 +1,49 @@ # @objectstack/example-showcase +## 0.3.15 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [2277443] +- Updated dependencies [f57fb38] +- Updated dependencies [3508678] +- Updated dependencies [3d61924] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [a9df51c] +- Updated dependencies [ab8b10f] +- Updated dependencies [20067c5] +- Updated dependencies [e783e16] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [4fc4a3c] +- Updated dependencies [72050cc] +- Updated dependencies [c8806ae] +- Updated dependencies [bb96297] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [19db5fa] +- Updated dependencies [2b9d33a] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [a4acb8d] + - @objectstack/spec@17.1.0 + - @objectstack/runtime@17.1.0 + - @objectstack/cloud-connection@17.1.0 + - @objectstack/service-datasource@17.1.0 + - @objectstack/driver-sql@17.1.0 + - @objectstack/connector-mcp@17.1.0 + - @objectstack/connector-openapi@17.1.0 + - @objectstack/connector-rest@17.1.0 + - @objectstack/connector-slack@17.1.0 + ## 0.3.14 ### Patch Changes diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index 1febc52bc3..7097dd839a 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-showcase", - "version": "0.3.14", + "version": "0.3.15", "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 81824c0803..45cd53a615 100644 --- a/examples/app-todo/CHANGELOG.md +++ b/examples/app-todo/CHANGELOG.md @@ -1,5 +1,47 @@ # @objectstack/example-todo +## 4.0.93 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [caaae2c] +- Updated dependencies [f57fb38] +- Updated dependencies [3d61924] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [20067c5] +- Updated dependencies [e783e16] +- Updated dependencies [ff4ba6a] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [4fc4a3c] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [19db5fa] +- Updated dependencies [2b9d33a] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [7c2f386] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [8a9e7f4] +- Updated dependencies [3d0ded8] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/runtime@17.1.0 + - @objectstack/client@17.1.0 + - @objectstack/mcp@17.1.0 + - @objectstack/objectql@17.1.0 + - @objectstack/metadata@17.1.0 + - @objectstack/driver-sqlite-wasm@17.1.0 + - @objectstack/knowledge-memory@17.1.0 + - @objectstack/service-knowledge@17.1.0 + ## 4.0.92 ### Patch Changes diff --git a/examples/app-todo/package.json b/examples/app-todo/package.json index e724150ed8..5b48d5d20e 100644 --- a/examples/app-todo/package.json +++ b/examples/app-todo/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-todo", - "version": "4.0.92", + "version": "4.0.93", "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 d41afb8bac..e28ef665b3 100644 --- a/examples/embed-objectql/CHANGELOG.md +++ b/examples/embed-objectql/CHANGELOG.md @@ -1,5 +1,31 @@ # @objectstack/example-embed-objectql +## 0.0.33 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [f57fb38] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [7c2f386] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [8a9e7f4] +- Updated dependencies [3d0ded8] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/objectql@17.1.0 + - @objectstack/driver-memory@17.1.0 + ## 0.0.32 ### Patch Changes diff --git a/examples/embed-objectql/package.json b/examples/embed-objectql/package.json index 66cee2edb2..c1b1dea0dd 100644 --- a/examples/embed-objectql/package.json +++ b/examples/embed-objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/example-embed-objectql", - "version": "0.0.32", + "version": "0.0.33", "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 a7596caaf2..6e28e9ddb7 100644 --- a/packages/adapters/hono/CHANGELOG.md +++ b/packages/adapters/hono/CHANGELOG.md @@ -1,5 +1,22 @@ # @objectstack/hono +## 17.1.0 + +### Patch Changes + +- Updated dependencies [e43d63a] +- Updated dependencies [3d61924] +- Updated dependencies [20067c5] +- Updated dependencies [e783e16] +- Updated dependencies [4fc4a3c] +- Updated dependencies [7fc01db] +- Updated dependencies [19db5fa] +- Updated dependencies [2b9d33a] +- Updated dependencies [bbbfcfc] + - @objectstack/runtime@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/plugin-hono-server@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/adapters/hono/package.json b/packages/adapters/hono/package.json index 1c2a2ca203..52e5b9a33a 100644 --- a/packages/adapters/hono/package.json +++ b/packages/adapters/hono/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/hono", - "version": "17.0.0", + "version": "17.1.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 d145514efb..14cbe91f02 100644 --- a/packages/apps/account/CHANGELOG.md +++ b/packages/apps/account/CHANGELOG.md @@ -1,5 +1,34 @@ # @objectstack/account +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/apps/account/package.json b/packages/apps/account/package.json index 68f6c883c9..c798c6892a 100644 --- a/packages/apps/account/package.json +++ b/packages/apps/account/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/account", - "version": "17.0.0", + "version": "17.1.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 9dbe15f58c..643dfd917c 100644 --- a/packages/apps/setup/CHANGELOG.md +++ b/packages/apps/setup/CHANGELOG.md @@ -1,5 +1,34 @@ # @objectstack/setup +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/apps/setup/package.json b/packages/apps/setup/package.json index a37b72aae1..e59041e646 100644 --- a/packages/apps/setup/package.json +++ b/packages/apps/setup/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/setup", - "version": "17.0.0", + "version": "17.1.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 4f6a2026f1..5f2659d02e 100644 --- a/packages/apps/studio/CHANGELOG.md +++ b/packages/apps/studio/CHANGELOG.md @@ -1,5 +1,34 @@ # @objectstack/studio +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/apps/studio/package.json b/packages/apps/studio/package.json index fa734af4cf..9b2daf7f48 100644 --- a/packages/apps/studio/package.json +++ b/packages/apps/studio/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/studio", - "version": "17.0.0", + "version": "17.1.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 306afa757a..853eed73c1 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,5 +1,179 @@ # @objectstack/cli +## 17.1.0 + +### Patch Changes + +- 189a732: `serve`: the cloud-connected marketplace arm now leaves a host config's own marketplace and cloud plugins alone. + + `objectstack serve` auto-wires `MarketplaceProxyPlugin`, `MarketplaceInstallLocalPlugin`, the same-origin cloud-connection surface and `RuntimeConfigPlugin` whenever a cloud URL resolves. Each of those four mounts is now guarded on whether the loaded host config already wired that surface — the same presence check the offline arm has carried since the install-local fix — so CLI auto-wiring is a fallback for hosts that wire nothing rather than a second opinion about a surface the host already composed. + + No behaviour changes for any current deployment: `Kernel.use()` keys plugins by `plugin.name` and the host's registration runs after the CLI's, so the host's instance already won by ordering. What changes is that it now wins by rule instead of by the relative position of two blocks that never referenced each other, and the CLI stops constructing four plugins it was about to discard. It becomes visible the moment a host passes an argument the CLI cannot — a private control plane, a custom install `storageDir`, a credential path, white-label branding. + +- dfedf88: `serve`: warn when the declared replica count exceeds the licensed node cap (#8504) + + The 2026-08-13 `max_nodes` ruling requires a licensed overflow to refuse the excess, + run up to the paid limit, and **warn loudly**. The gate learned to express the first + two — `admitted` / `refused` / `capped` — but the only program that consults it, `os +serve`, called it zero-arg and typed the result with a hand-written + `{ allowed, reason }` cast. So the partial-cap verdict was unreachable _and_ unread: + the gate could say "3 admitted, 2 refused" and nothing rendered it. + + `serve` now passes the operator-declared `OS_CLUSTER_REPLICAS` into the gate and + emits an advisory on `capped`: + + ``` + [cluster] licensed node cap exceeded: the licence admits 3 node(s), but + OS_CLUSTER_REPLICAS declares 5 — 2 beyond the cap. + [cluster] This cap is ADVISORY and is not enforced yet: nothing is refused, and all + 5 replicas will still join the cluster. + [cluster] Reduce OS_CLUSTER_REPLICAS to 3, or raise the licensed node limit. + ``` + + ⚠️ The wording is deliberately advisory. Enforcement needs an atomic slot claim + across replicas and is tracked separately; until it lands **nothing is actually + refused** — every replica computes the same verdict at boot and none can tell whether + it is one of the admitted ones, so all of them join. A message claiming "2 replicas + refused" would be false in exactly the declared-vs-delivered way this warning exists + to close. + + An outright `allowed: false` denial is untouched: it keeps reporting as a + single-node downgrade, and is deliberately not reported as a cap. + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e7bccaa] +- Updated dependencies [e43d63a] +- Updated dependencies [08d6d3c] +- Updated dependencies [5047cb8] +- Updated dependencies [1408fe3] +- Updated dependencies [2277443] +- Updated dependencies [cf0d902] +- Updated dependencies [498f4e8] +- Updated dependencies [cc5c07b] +- Updated dependencies [caaae2c] +- Updated dependencies [13d7864] +- Updated dependencies [f57fb38] +- Updated dependencies [3508678] +- Updated dependencies [04d03c3] +- Updated dependencies [8656d67] +- Updated dependencies [177442d] +- Updated dependencies [950bd94] +- Updated dependencies [3043e98] +- Updated dependencies [3d61924] +- Updated dependencies [6feac91] +- Updated dependencies [5f5e234] +- Updated dependencies [4ea921c] +- Updated dependencies [3ab2488] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [fd6bdf8] +- Updated dependencies [24173e9] +- Updated dependencies [a9df51c] +- Updated dependencies [b705a6c] +- Updated dependencies [f8eb736] +- Updated dependencies [ab8b10f] +- Updated dependencies [20067c5] +- Updated dependencies [d09d0fd] +- Updated dependencies [e783e16] +- Updated dependencies [ff4ba6a] +- Updated dependencies [4bfe1a5] +- Updated dependencies [ead96d0] +- Updated dependencies [b69d0f5] +- Updated dependencies [c15eb23] +- Updated dependencies [4d47afe] +- Updated dependencies [4fc4a3c] +- Updated dependencies [72050cc] +- Updated dependencies [c8806ae] +- Updated dependencies [bb96297] +- Updated dependencies [e6e1de4] +- Updated dependencies [c73eacd] +- Updated dependencies [693c788] +- Updated dependencies [845e164] +- Updated dependencies [8d017eb] +- Updated dependencies [501ed0e] +- Updated dependencies [f047810] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [88ef34d] +- Updated dependencies [19db5fa] +- Updated dependencies [b849e69] +- Updated dependencies [add2d19] +- Updated dependencies [2b9d33a] +- Updated dependencies [b53d38e] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [147eadc] +- Updated dependencies [0f59584] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [159e299] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [7c2f386] +- Updated dependencies [75e66fc] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [8a9e7f4] +- Updated dependencies [3d0ded8] +- Updated dependencies [a726154] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] +- Updated dependencies [a4acb8d] +- Updated dependencies [b278695] + - @objectstack/platform-objects@17.1.0 + - @objectstack/plugin-auth@17.1.0 + - @objectstack/plugin-security@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/rest@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/runtime@17.1.0 + - @objectstack/plugin-approvals@17.1.0 + - @objectstack/metadata-protocol@17.1.0 + - @objectstack/plugin-audit@17.1.0 + - @objectstack/cloud-connection@17.1.0 + - @objectstack/service-automation@17.1.0 + - @objectstack/client@17.1.0 + - @objectstack/lint@17.1.0 + - @objectstack/service-datasource@17.1.0 + - @objectstack/plugin-sharing@17.1.0 + - @objectstack/driver-sql@17.1.0 + - @objectstack/mcp@17.1.0 + - @objectstack/service-analytics@17.1.0 + - @objectstack/service-package@17.1.0 + - @objectstack/service-settings@17.1.0 + - @objectstack/plugin-email@17.1.0 + - @objectstack/objectql@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/plugin-webhooks@17.1.0 + - @objectstack/account@17.1.0 + - @objectstack/setup@17.1.0 + - @objectstack/metadata@17.1.0 + - @objectstack/plugin-reports@17.1.0 + - @objectstack/service-job@17.1.0 + - @objectstack/service-messaging@17.1.0 + - @objectstack/service-queue@17.1.0 + - @objectstack/service-realtime@17.1.0 + - @objectstack/service-storage@17.1.0 + - @objectstack/verify@17.1.0 + - @objectstack/service-sms@17.1.0 + - @objectstack/driver-memory@17.1.0 + - @objectstack/driver-mongodb@17.1.0 + - @objectstack/driver-sqlite-wasm@17.1.0 + - @objectstack/formula@17.1.0 + - @objectstack/observability@17.1.0 + - @objectstack/plugin-hono-server@17.1.0 + - @objectstack/service-cache@17.1.0 + - @objectstack/trigger-api@17.1.0 + - @objectstack/trigger-record-change@17.1.0 + - @objectstack/trigger-schedule@17.1.0 + - @objectstack/plugin-pinyin-search@17.1.0 + - @objectstack/console@17.1.0 + ## 17.0.0 ### Major Changes @@ -3161,7 +3335,7 @@ yaml` payload with `deleted: result.deleted`. That evaluated to `undefined`, and unknown keys, so a passing parse alone cannot prove no stray `deleted` rode along. - + - 3b64c21: fix(cli): `os dev` refuses to serve a console built from a different objectui SHA than the pin (#7752) diff --git a/packages/cli/package.json b/packages/cli/package.json index 16b7d7d81f..5d30c7c5a2 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cli", - "version": "17.0.0", + "version": "17.1.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 283aee9f39..024a020521 100644 --- a/packages/client-react/CHANGELOG.md +++ b/packages/client-react/CHANGELOG.md @@ -1,5 +1,33 @@ # @objectstack/client-react +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [caaae2c] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/client@17.1.0 + ## 17.0.0 ### Major Changes diff --git a/packages/client-react/package.json b/packages/client-react/package.json index 4b22d57e97..e3ebd79c1a 100644 --- a/packages/client-react/package.json +++ b/packages/client-react/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client-react", - "version": "17.0.0", + "version": "17.1.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 95c2601640..c20d106b9e 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -1,5 +1,43 @@ # @objectstack/client +## 17.1.0 + +### Minor Changes + +- caaae2c: feat(client): `security.explain()` accepts the `recordIds` batch spelling (#8480) + + The typed `security.explain()` request now declares the optional + `recordIds?: string[]` field alongside the existing `recordId?: string`, so a + typed-client consumer can reach the batch record-grained explain form added + server-side by #8326 without a cast. Type-level and TSDoc only — the method + still forwards the request body verbatim over POST; the 200-id cap and the + `recordId`/`recordIds` mutual exclusion are validated server-side by + `ExplainRequestSchema` (`@objectstack/spec`), unchanged. + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Major Changes @@ -207,7 +245,7 @@ yaml` payload with `deleted: result.deleted`. That evaluated to `undefined`, and unknown keys, so a passing parse alone cannot prove no stray `deleted` rode along. - + - 90bbf25: refactor(spec,client)!: retire the `cursor` half of `GET /api/v1/notifications` and stop declaring a `limit` default nothing applied (#6361, ADR-0049) diff --git a/packages/client/package.json b/packages/client/package.json index 28a82c804a..1312480494 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/client", - "version": "17.0.0", + "version": "17.1.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 98021980c6..0b6528a1e9 100644 --- a/packages/cloud-connection/CHANGELOG.md +++ b/packages/cloud-connection/CHANGELOG.md @@ -1,5 +1,88 @@ # @objectstack/cloud-connection +## 17.1.0 + +### Patch Changes + +- 2277443: fix(cloud-connection,service-automation): stop two plugin classes renaming themselves in the shipped build, and enforce the class-name identity limb against `Ctor.name` (#8645) + + `Serve.providesCapability` (`packages/cli/src/commands/serve.ts`) decides whether a + host already supplied a capability's provider by comparing, by equality, both a + loaded plugin's `name` and its `constructor.name` against a declared identity + list. Every identity registry in that file therefore declares two spellings per + provider — the registered `plugin.name` id and the exported class name — and the + class-name spelling is a claim about the **built** artifact. + + **Measured against the built packages, two of the 27 declared class-name + identities matched nothing at all:** + + ``` + MISMATCH CAPABILITY_PROVIDERS.automation declared=AutomationServicePlugin runtime=_AutomationServicePlugin + MISMATCH Serve.MARKETPLACE_PROXY_IDENTITIES declared=MarketplaceProxyPlugin runtime=_MarketplaceProxyPlugin + ``` + + Both classes referenced themselves **by name inside their own body** — + `MarketplaceProxyPlugin.prototype.version` building the outbound proxy + User-Agent, and a `private static` backoff helper called from an instance method + in the automation plugin. esbuild rewrites such a class into + `var X = class _X { … _X … }` so the inner reference binds to the class binding + rather than the outer `var`, and the emitted class reports `_X` as its `.name`. + + There was no user-visible impact, because every guard naming these plugins also + declares the registered id, which the instance carries as a plain field no + bundler touches. What was dead is the **redundancy**: a guard running on one + limb it does not know it is running on is one rename away from failing open — + and failing open here means silently mounting a second instance over a host's + own. + + Both source idioms are replaced with module-scope declarations, so the shipped + classes keep their names. The marketplace proxy's self-reference was also + reading a field that was never there (`version` is an instance field, so + `prototype.version` was always `undefined`): its outbound `User-Agent` announced + the `?? '1.0.0'` fallback on every request and now announces the plugin's real + version, `1.1.0`. + + The enforcement half lives in `packages/cli/test/serve-capability-identity.test.ts`: + every declared class-name identity, across `CAPABILITY_PROVIDERS` and the four + marketplace identity lists, is now compared to the runtime `Ctor.name` of the + export it names, and must satisfy `providesCapability` through the class-name + limb alone. The `*_IDENTITIES` statics are re-derived from `Serve` itself, so a + fifth list cannot be added without being enumerated. #8357's local + "modulo one leading underscore" accommodation is retired rather than left as a + third spelling of the same rule. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [3d61924] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [20067c5] +- Updated dependencies [e783e16] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [4fc4a3c] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [19db5fa] +- Updated dependencies [2b9d33a] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/runtime@17.1.0 + - @objectstack/types@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index 79806fb307..63bee6559a 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/cloud-connection", - "version": "17.0.0", + "version": "17.1.0", "license": "Apache-2.0", "description": "Runtime-side client for an ObjectStack cloud control plane — marketplace browse proxy, install-local, device-code binding, org catalog and installed views, and the /api/v1/runtime/config discovery endpoint. Open mechanism (ADR-0008): the hub service, plan policy, and entitlements stay server-side.", "type": "module", diff --git a/packages/connectors/connector-mcp/CHANGELOG.md b/packages/connectors/connector-mcp/CHANGELOG.md index 9de9db6949..bf17afee84 100644 --- a/packages/connectors/connector-mcp/CHANGELOG.md +++ b/packages/connectors/connector-mcp/CHANGELOG.md @@ -1,5 +1,31 @@ # @objectstack/connector-mcp +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/connectors/connector-mcp/package.json b/packages/connectors/connector-mcp/package.json index 95daa3f2a5..1a11510946 100644 --- a/packages/connectors/connector-mcp/package.json +++ b/packages/connectors/connector-mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-mcp", - "version": "17.0.0", + "version": "17.1.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 24b1b2d493..17ab79ee76 100644 --- a/packages/connectors/connector-openapi/CHANGELOG.md +++ b/packages/connectors/connector-openapi/CHANGELOG.md @@ -1,5 +1,31 @@ # @objectstack/connector-openapi +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/connectors/connector-openapi/package.json b/packages/connectors/connector-openapi/package.json index a7f59417a9..347296e3c5 100644 --- a/packages/connectors/connector-openapi/package.json +++ b/packages/connectors/connector-openapi/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-openapi", - "version": "17.0.0", + "version": "17.1.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 9d818d1072..63afa5e3ed 100644 --- a/packages/connectors/connector-rest/CHANGELOG.md +++ b/packages/connectors/connector-rest/CHANGELOG.md @@ -1,5 +1,31 @@ # @objectstack/connector-rest +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/connectors/connector-rest/package.json b/packages/connectors/connector-rest/package.json index dba5badf18..32b09238c3 100644 --- a/packages/connectors/connector-rest/package.json +++ b/packages/connectors/connector-rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-rest", - "version": "17.0.0", + "version": "17.1.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 05f26d09f8..7f0424b845 100644 --- a/packages/connectors/connector-slack/CHANGELOG.md +++ b/packages/connectors/connector-slack/CHANGELOG.md @@ -1,5 +1,31 @@ # @objectstack/connector-slack +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/connectors/connector-slack/package.json b/packages/connectors/connector-slack/package.json index 83aa2ef018..c25126b11f 100644 --- a/packages/connectors/connector-slack/package.json +++ b/packages/connectors/connector-slack/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/connector-slack", - "version": "17.0.0", + "version": "17.1.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 f7e8534fcc..bc922021d1 100644 --- a/packages/console/CHANGELOG.md +++ b/packages/console/CHANGELOG.md @@ -1,5 +1,7 @@ # @objectstack/console +## 17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/console/package.json b/packages/console/package.json index 8ef79d22c4..46b661cae6 100644 --- a/packages/console/package.json +++ b/packages/console/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/console", - "version": "17.0.0", + "version": "17.1.0", "description": "Prebuilt Console SPA pinned to this @objectstack/framework release. Source of truth: @object-ui/console (https://github.com/objectstack-ai/objectui).", "license": "Apache-2.0", "homepage": "https://github.com/objectstack-ai/objectstack/tree/main/packages/console", diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 2bb09b6827..5db1d58b5c 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,335 @@ # @objectstack/core +## 17.1.0 + +### Minor Changes + +- e43d63a: feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287) + + + + On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could + read **nothing at all**. `sys_api_key` carried no organization column, so key + authentication established a user but no active organization — and the + `isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with + no active organization matches no row. Every organization-scoped read answered + `200` with `total 0` while the console went on offering minting, so a tenant + admin could mint a valid-looking secret and discover only at call time that it + read nothing. (There was no cross-tenant leak — the failure was in the other + direction.) + + **The column was absent by an inherited rule, not by oversight.** + `resolveInjectedSystemColumns` injects `organization_id` into every registered + object _except_ `managedBy: 'better-auth'` ones, and `sys_api_key` carries that + flag — even though better-auth's `apiKey` plugin is not loaded and the table is + hand-rolled ObjectStack. So the fix needs the declaration _and_ the ADR-0105 D7 + extension-field registration to stay consistent. The read side, by contrast, + was **already wired**: `resolveApiKeyPrincipal` already read an organization + into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a + column no mint path ever wrote. + + **What changes** + + - `sys_api_key` declares `active_organization_id` (+ index, and the column is + shown in the "My Keys" and "All" list views, because the card's complaint was + a credential whose reach its owner could not see). + - `POST /api/v1/keys` **inherits** the caller's active organization — there is + deliberately no org parameter and no cross-org key — and **re-checks the + caller's `sys_member` membership at mint time**, honouring ADR-0091 validity + windows. Under a walled posture it refuses (400) rather than minting a key + with no organization, and refuses (403) for an organization the caller is not + a member of. The mint response echoes the organization the key is pinned to. + - The verifier reads **one spelling** (Prime Directive #12): the + `row.organization_id ?? row.organizationId` chain it used to carry was a + consumer-side tolerance for a producer that did not exist. + - An **ex-member's key fails closed at verify time** — no principal, not a + degrade to a user-only principal, which would resurrect the same + `200 + total 0` silent-empty. Checked at verify rather than by revoking on + membership loss, because membership ends through many paths (better-auth org + endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and + a hook must catch every one or it silently misses. It costs **zero extra + queries**: the resolver has already read `sys_member` for this user. + - **Pre-existing org-less keys are never backfilled** — that would silently + upgrade credentials minted under a different promise. They keep working under + `single` (no wall) and under `group` (whose wall derives from the owner's + memberships independently of the active organization, so they already work + there), and are **refused under `isolated`**, where they are provably dead + today. + + **The column is deliberately named `active_organization_id`, not + `organization_id`** — the `sys_session` spelling, for the same concept: the + organization a credential makes _active_. `objectHasOrgIdField` tests for the + literal `organization_id`, and Layer 0 exempts objects without it, so the other + name would have made `sys_api_key` itself org-walled. Both walled postures + exclude NULL, so every pre-existing org-less row would have vanished from its + **own owner's** "My Keys" list while, under `group`, continuing to + authenticate — a live credential nobody could see or revoke, which is a fresh + instance of the very class this change removes. + +- 5f5e234: fix(security): `sys_permission_set.active` and `sys_position.active` now actually stop granting access (#8613) + + + + **BREAKING for deployments that already switched a permission set or position + off.** Both objects ship a Deactivate action whose confirmation dialog promises, + in all four locales, that access stops: + + > Deactivate this permission set? Existing assignments stay in place but stop + > granting access until re-activated. + > Deactivate this position? Users keep their assignment but the position stops + > granting permissions until re-activated. + + Nothing read the column. Measured on the real resolver: a position seeded + `active: false` still granted its permission sets, and a permission set seeded + `active: false` still returned `posture: PLATFORM_ADMIN` with its system + permissions. Deactivation moved a badge in Setup and nothing else — while the + admin who had just revoked a compromised or over-broad grant was told the + opposite, and whose likely next step was therefore _not_ the action that would + have worked (delete the set, or remove the assignments). + + **What changes at runtime.** `resolveAuthzContext` / `resolveUserAuthzGrants` + (`@objectstack/core`) — the single seam every transport resolves authorization + through — now drop a deactivated row **before** any derivation: + + - a deactivated `sys_position` no longer contributes its + `sys_position_permission_set` grants, and its name leaves `positions` (so the + name-reuse path cannot resolve the same grant one layer down); + - a deactivated `sys_permission_set` contributes no name, no + `system_permissions`, no `tab_permissions`, **and no `PLATFORM_ADMIN` + posture** — the flag is applied before the posture is derived, not after; + - the `plugin-security` DB loader applies the same predicate, which is what + judges a set reached by NAME through an active position of the same name. + + Both tables were already read at that seam, so this costs **zero new hot-path + queries**. + + **⚠️ Read this before upgrading.** Any `sys_permission_set` or `sys_position` + row currently carrying `active: false` **stops granting the moment this + lands** — on live data, with no migration step to notice. That is the correct + direction (it is what the dialog said when someone clicked Deactivate), but on + an installation that used the switch believing it was inert it is a real + revocation. Before upgrading, list the deactivated rows and re-activate any that + are still meant to grant: + + ``` + GET /api/v1/data/sys_permission_set?filters=[["active","=",false]] + GET /api/v1/data/sys_position?filters=[["active","=",false]] + ``` + + A row whose `active` column is **absent or NULL** is unaffected: the predicate + is "explicitly deactivated", never "explicitly active", so rows that predate the + column keep granting exactly as before. + + **Break-glass, closed in the same change** (`@objectstack/plugin-auth`). + Enforcing the flag opened a one-click, installation-wide lockout: deactivating + `admin_full_access` un-makes every platform admin at once, through a payload + that touches neither `name` nor any identity table, and re-activating requires + the permission the click just took away (the seeders deliberately never + reconcile `active`, so no restart restores it). The last-administrator guard now + judges that write like the delete and rename spellings it already refused, and + an environment whose break-glass set is _already_ off is read as emptied rather + than as a bootstrap window — so it does not silently disarm the guard for every + other identity write. Re-activation itself stays permitted, or the refusal would + have no way out from inside the product. + +- f8eb736: feat(security): bind the break-glass standing-key lists to what the authz resolver actually reads — the correspondence stops being prose (#8734) + + `plugin-auth`'s last-administrator guard (ADR-0024 D5.2) decides whether a + pending write can empty the administrator population by testing the payload + against three standing-key lists (`MEMBER_STANDING_KEYS`, + `GRANT_STANDING_KEYS`, `PERMISSION_SET_STANDING_KEYS`). A payload touching none + of them is skipped without any reads — so a column `resolveAuthzContext` starts + reading that a list omits is a write class the guard **silently stops judging**, + on the one path whose failure mode is an installation-wide administrator lockout + with no in-product recovery. + + Nothing bound the two together. The correspondence lived in a comment, and it + had already gone false once: #6084 wrote — naming `active` explicitly — that + everything a permission-set write touches other than `name` is invisible to "who + is an administrator". That was true when written; #8613 made `active` a + resolution-time predicate and the sentence became false. Nothing mechanical + would have caught it, because the guard's own tests stay green precisely when + the guard is never consulted. + + **The mechanism is two links, and the first one is a measurement.** + + - `@objectstack/core` now exports `ADMIN_STANDING_SURFACE` — declared beside the + resolver, listing every table the administrator-derivation path reads, each + classified `derives` or `reads-only` with its reason, and for the deriving + tables every column read. It is asserted **equal** to what the real + `resolveAuthzContext` reads, observed at runtime through a recording engine + that records every property access and every `where` key per table. Observation + rather than source extraction because the reads that matter have moved into + helpers: `active` is read by `isRowActive(row)` and the ADR-0091 window bounds + by `isGrantActive(row, now)`, neither named at the resolver's own call site — + the exact shape #8613 had. + + - `@objectstack/plugin-auth` now exports its standing-key lists plus + `STANDING_KEYS_BY_TABLE` and `STANDING_KEY_EXCLUSIONS`, and a gate requires + every column of that measured surface to have an answer: it is standing-bearing + (in a list) or it is excluded with the reason it cannot empty the administrator + population. There is no third state — the third state is what `active` was + between #6084 and #8613. + + So a resolver change that starts reading a new column fails at the first link + until the declaration is updated, and at the second until the guard has an + explicit answer for it. Landing #8613 green would have required writing down that + deactivating `admin_full_access` cannot empty the administrator population — + which is false, and which is what the old comment asserted by accident. + + **No guard behaviour changes.** Every list keeps exactly the values it had; the + gate is one-directional by construction (it can only ever demand that the guard + judges _more_), because the other direction would put pressure on a break-glass + guard to fire less often. + + The table-level half is covered too: a resolver that started deriving + administrator standing from a **new** table is invisible to any column-set + comparison, since the table is absent from both sides — so the surface enumerates + every table the path reads, and an unclassified one fails. + +### Patch Changes + +- 24173e9: fix(rest): read an offset-free import cell in the business timezone, not the host `TZ` (#8485) + + `parseDateCell` ended in `new Date(s)`. A spreadsheet cell like + `2026-08-01 06:00:00` carries no offset, so ECMAScript resolves it against the + **process** timezone, and the instant bulk import stored became a property of + the deployment host: + + ``` + TZ=Asia/Shanghai → 2026-07-31T22:00:00.000Z + TZ=UTC → 2026-08-01T06:00:00.000Z + ``` + + Same file, same tenant, same cell — eight hours apart, decided by a setting + nobody authoring the spreadsheet can see, and never consulting the business + timezone the route had already resolved one frame up + (`ExecutionContext.timezone`, the platform-default → global → tenant cascade). + + Since the export renders `datetime` cells in that business timezone (#8373), the + advertised export → edit in a spreadsheet → re-import round trip was lossless + only where the host `TZ` happened to equal the business zone. `import-coerce.ts` + opens by calling itself "the inverse of `export-format.ts`"; it now is one, and + the regression proof asserts inverse-ness on the **pair** — every fixture under + a host `TZ` deliberately different from the business timezone, because a test + that runs only under a matching `TZ` cannot fail. + + **An offset-free datetime cell is now read in the caller's business timezone**, + through `@objectstack/core`'s new `zonedWallClockToUtcMs` — the DST-safe wall + clock → instant primitive that `zonedDateStartToUtcMs` (the date-bucket drill + path) is now the midnight special case of. One implementation of zone + arithmetic, `Intl` offsets from the platform tz database, never hand-rolled; + generalising the existing one rather than hand-rolling a second in `rest` is + what keeps the export and import halves of this seam from drifting apart again. + Two wall clocks are not a bijection with instants, and both degenerate DST + readings resolve to the earlier candidate instant — a gap reading lands just + before the gap, an ambiguous reading on its first occurrence (pinned, measured). + + Three things deliberately do **not** move: + + - **A cell that carries an explicit offset** (`…Z`, `…+08:00`) already names one + instant and is honoured exactly as written. This change affects naive cells + only. + - **The date-only fast path stays UTC.** `YYYY-MM-DD` is UTC per ECMAScript and + a `date` is a timezone-naive calendar day (ADR-0053); sweeping it into the + zoned handling to make the code look uniform would silently re-time every + date-only import to fix nothing. + - **No timezone resolved ⇒ UTC**, never the process clock. That is the fallback + the export's cell path takes in the same case, so the round trip stays exact + for deployments that configure no zone — and a process-`TZ` fallback would + preserve the defect for exactly the deployments that cannot see it. This is + the one **behaviour change for existing deployments**: a host with a non-UTC + `TZ` and no resolved business timezone previously read naive cells in the host + clock and now reads them as UTC. An explicitly resolved `'UTC'` is a resolved + zone, not a missing one. + + Two adjacent legs of the same defect, both on the naive-cell path: + + - **A naive cell landing in a `date` or `time` field** now takes the typed + components verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`). + Those branches also read the process clock, so a host east of the cell stored + the _previous calendar day_ for a `date` column. + - **An xlsx date cell.** An Excel serial date carries no timezone; ExcelJS + materialises it as a `Date` whose UTC components are the sheet's wall clock, + and `import-prepare.ts` rendered it with `toISOString()` — stamping a `Z` the + file never had. That fabricated offset then outranked the business timezone by + the very carve-out above, so every real date cell in a user-authored workbook + imported as UTC whatever the tenant's zone. It now flattens to the same + offset-free `YYYY-MM-DD HH:mm:ss` a CSV export writes, which is what that + function's contract already claimed to produce. + +- 402c125: fix(objectql): a temporal filter comparand the platform cannot interpret is refused at the engine door instead of answering 200 with zero rows (#8690) + + + + A `datetime` / `date` / `time` field filtered with a bare string the platform + cannot read — `last_30_days`, `not-a-date-at-all` — was bound **as written** + all the way to the driver, where the comparison is false for every row. The + caller received `HTTP 200`, an empty result set, and nothing to indicate the + filter was meaningless. An unknown `{placeholder}` in the same position was + already refused loudly (`FILTER_TOKEN_UNKNOWN` / 400, listing the resolvable + tokens), so one API answered two shapes of unusable comparand two different + ways. + + It is concretely reachable rather than theoretical: `last_7_days` / + `last_30_days` / `last_90_days` are **declared preset names** in the dashboard + schema. The shipped console lowers them to `{N_days_ago}` macros before they + reach the API, so the console path was always safe — but a saved report, an + integration, an MCP client or an AI-authored query sends the preset name itself + and got a silent zero. An empty chart is the hardest failure to debug: it is + indistinguishable from "there is genuinely no data". + + Such a comparand is now refused at the ObjectQL engine's single filter + collection point, with `code: 'INVALID_FILTER'` and `status: 400`, naming the + field, the value, the key path and the spellings that would work. That seam is + the one place holding the caller's comparand and the field's **declared type** + at the same moment, and every verb (`find` / `findOne` / `count` / `aggregate` + / `update` / `delete`) and both filter spellings (the array sugar and the + lowered condition) pass through it, so all four backends inherit one answer + rather than four. `NativeSQLStrategy` additionally **declines** such a query so + the raw-SQL analytics path falls through to that door instead of binding the + value into its own statement. + + Deliberately unchanged, each by ruling: a `{placeholder}` keeps its existing + refusal one layer down (the door runs before token resolution and steps around + them, so `{30_days_ago}` still resolves normally); non-string comparands are + untouched (a number is epoch milliseconds, a `Date` is an instant); and the + **empty string** keeps today's behaviour exactly — it binds as `''` and matches + every non-null row, which is a separate question that remains its own card. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [f57fb38] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + ## 17.0.0 ### Major Changes diff --git a/packages/core/package.json b/packages/core/package.json index 4a3f226623..24ba618e9a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/core", - "version": "17.0.0", + "version": "17.1.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 712f0872eb..1b34acd336 100644 --- a/packages/create-objectstack/CHANGELOG.md +++ b/packages/create-objectstack/CHANGELOG.md @@ -1,5 +1,29 @@ # create-objectstack +## 17.1.0 + +### Minor Changes + +- 1eb28a1: Retire the five remote content templates from the scaffolder's catalog. + + `todo`, `compliance`, `content`, `contracts` and `procurement` were delisted + from the official ObjectStack template marketplace and are no longer + maintained, but the CLI carried its own hardcoded catalog and never learned + that: `--help` recommended all five by name with marketing descriptions, and + the `Available:` line on a bad `-t` offered them too. + + - `blank` (bundled, offline) is now the whole catalog, so the help text + advertises only what is actually supported. + - Asking for one of the five by name — `-t todo` in an old script or tutorial — + is refused with a message that says the template was retired, instead of the + generic "Unknown template" error that reads as a typo. + - The GitHub tarball-fetch path that served the remote templates is removed + along with its `tar` dependency; nothing else reached it. + + Note this corrects the catalog at HEAD only. Already-published versions keep + advertising the retired templates until a new version of `create-objectstack` + is released. + ## 17.0.0 ### Major Changes diff --git a/packages/create-objectstack/package.json b/packages/create-objectstack/package.json index 317d96fb64..69db0c1b1a 100644 --- a/packages/create-objectstack/package.json +++ b/packages/create-objectstack/package.json @@ -1,6 +1,6 @@ { "name": "create-objectstack", - "version": "17.0.0", + "version": "17.1.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 1cb162558f..76e6e71876 100644 --- a/packages/drivers/driver-memory/CHANGELOG.md +++ b/packages/drivers/driver-memory/CHANGELOG.md @@ -1,5 +1,33 @@ # @objectstack/driver-memory +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + ## 17.0.0 ### Major Changes @@ -1004,7 +1032,7 @@ $lte, $in, $nin, $contains, $notContains, $exists`. `lower()` folds ASCII only, which IS the contract (#4706 Q1 = A): `$icontains: 'café'` does not match `CAFÉ`. - + `driver-mongodb`'s unknown-operator arm was throwing a bare `Error` with no `code` and no `status`, three lines from the helper in its own file that sets @@ -2657,7 +2685,7 @@ node_modules/@objectstack/*/CHANGELOG.md` now finds the migration it was `lower()` folds ASCII only, which IS the contract (#4706 Q1 = A): `$icontains: 'café'` does not match `CAFÉ`. - + `driver-mongodb`'s unknown-operator arm was throwing a bare `Error` with no `code` and no `status`, three lines from the helper in its own file that sets diff --git a/packages/drivers/driver-memory/package.json b/packages/drivers/driver-memory/package.json index 581906cea8..e6d2ad1134 100644 --- a/packages/drivers/driver-memory/package.json +++ b/packages/drivers/driver-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-memory", - "version": "17.0.0", + "version": "17.1.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 417077d0ad..9a7af1e158 100644 --- a/packages/drivers/driver-mongodb/CHANGELOG.md +++ b/packages/drivers/driver-mongodb/CHANGELOG.md @@ -1,5 +1,33 @@ # @objectstack/driver-mongodb +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + ## 17.0.0 ### Major Changes @@ -422,7 +450,7 @@ `lower()` folds ASCII only, which IS the contract (#4706 Q1 = A): `$icontains: 'café'` does not match `CAFÉ`. - + `driver-mongodb`'s unknown-operator arm was throwing a bare `Error` with no `code` and no `status`, three lines from the helper in its own file that sets @@ -2470,7 +2498,7 @@ time`) was half fiction: grep the repo and no reconnection exists in `driver-sql `lower()` folds ASCII only, which IS the contract (#4706 Q1 = A): `$icontains: 'café'` does not match `CAFÉ`. - + `driver-mongodb`'s unknown-operator arm was throwing a bare `Error` with no `code` and no `status`, three lines from the helper in its own file that sets diff --git a/packages/drivers/driver-mongodb/package.json b/packages/drivers/driver-mongodb/package.json index dac3e8631c..1642200f21 100644 --- a/packages/drivers/driver-mongodb/package.json +++ b/packages/drivers/driver-mongodb/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-mongodb", - "version": "17.0.0", + "version": "17.1.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 9edaf9f55c..b21f48bdde 100644 --- a/packages/drivers/driver-sql/CHANGELOG.md +++ b/packages/drivers/driver-sql/CHANGELOG.md @@ -1,5 +1,283 @@ # @objectstack/driver-sql +## 17.1.0 + +### Minor Changes + +- c8806ae: fix(driver-sql): MySQL refuses an upsert whose `conflictKeys` no PRIMARY KEY or UNIQUE index backs — calls that previously "resolved" now throw (#8621) + + **This narrows MySQL's accept set.** A `SqlDriver.upsert(object, data, conflictKeys)` + call on MySQL whose conflict target is backed by no PRIMARY KEY and no UNIQUE + index used to resolve; it now throws `VALIDATION_ERROR` / 400. That is why this + is a `minor` and not a patch: code that ran without error against MySQL will + start failing, deliberately, and the rows it was writing were not the rows the + caller asked for. + + SQLite and Postgres have refused this exact call since #8445 / #8567, with this + exact sentence. MySQL did not, and could not: knex compiles + `onConflict([...]).merge(...)` on `mysql2` to `ON DUPLICATE KEY UPDATE`, which + takes **no conflict target at all**, so the named keys are dropped before the + statement leaves the process and the server is never asked to find an index for + them. The existing refusal classifies an error the server raised, so on MySQL it + had nothing to classify. + + Measured on live MySQL 8.0.46 — `email` is the column the caller names, `tax_id` + carries the only unique index: + + ``` + seed upsert({email:'a@b.com', tax_id:'T-1', title:'first'}, ['email']) -> resolved + B upsert({email:'other@b.com', tax_id:'T-1', title:'second'}, ['email']) -> resolved + ONE row: merged on `tax_id`, which the caller never named, across two + different `email` values. + D seed, then upsert({email:'a@b.com', tax_id:'T-2'}, ['email']) -> resolved + TWO rows, both `email='a@b.com'`: the merge that WAS asked for did not + happen either. + ``` + + So the failure being replaced is not an illegible error — it is a silent wrong + write. `upsert` now consults the table's physical keys before compiling on MySQL + and answers the wording, `code` and `status` the other two dialects already + answer (#5240 — one condition, one wording). + + **What this means for an existing MySQL deployment.** The calls that change are + exactly those naming a conflict target no key covers — the same calls that have + always been errors on SQLite and Postgres. The most likely one to surface is a + tenant-scoped `unique: true` field: its index materializes as the composite + `(COALESCE(organization_id, '__global__'), field)` (ADR-0120 D3), so + `conflictKeys: ['field']` alone is not backed by it. The remedy is the one the + refusal already prints: declare the column(s) `unique: true` and re-run schema + sync, name the full composite, or upsert on the primary key. + + Deliberately unchanged: + + - **SQLite and Postgres.** They already refuse this from the server, and they + attach the server's own sentence as `cause` — ground truth a pre-flight cannot + reconstruct. Running the pre-flight there would replace a planner verdict with + an introspection verdict for no gain. + - **The default `['id']` path.** The pre-flight runs only when the caller names + a target; the default is this driver's own primary key on every table it + creates, so probing it would add a round trip to every ordinary upsert to + answer a question with only one possible answer. + - **Anything the pre-flight cannot prove.** A failed introspection, a table + reporting no keys at all (indistinguishable from a table that does not exist), + and a possibly stale cache all proceed rather than refuse — the cache is + re-read from the database before any refusal is thrown. + + **Not fixed here, and filed as #8755:** `ON DUPLICATE KEY UPDATE` carries no + conflict target even when the named one IS backed, so on MySQL a second unique + index can still absorb the conflict and merge on a key the caller never named. + This change closes the unbacked-target hole; it does not make MySQL honour + `conflictKeys` as a target. + +- bb96297: fix(driver-sql): refuse a MySQL upsert whose named conflict target another UNIQUE key can absorb (#8755) + + `ON DUPLICATE KEY UPDATE` — the only merge statement MySQL compiles — carries no + conflict target, so the merge lands on whichever UNIQUE key the row collides with + first. `#8621` closed the half where nothing backed the named target; this closes + the half where the target IS backed and a _second_ UNIQUE key absorbs the + conflict instead. + + Measured on live MySQL 8.0.46, `email` and `tax_id` both `unique: true`, the + caller naming `email`: the second upsert merged on `tax_id`, across two different + values of the named key, leaving one row and no error. The identical call on + SQLite and PostgreSQL raises `UNIQUE constraint failed: …tax_id` and leaves the + seeded row untouched. + + **Accept-set change, MySQL only.** An `upsert(object, data, conflictKeys)` naming + a non-primary target on a table that carries any other UNIQUE key is now refused + before the statement is compiled — `code: 'VALIDATION_ERROR'`, `status: 400`, + nothing written and no auto-number reserved. The message names the colliding + index and both workarounds: drop or rename the extra UNIQUE key, or run the + object on a dialect that honours the target. + + Deliberately unchanged: a table whose only UNIQUE key IS the conflict target (the + common shape) merges exactly as before, as do the `conflictKeys`-less default and + an explicitly named primary key. The MySQL dialect limit and that residue are + documented under _Database Drivers → MySQL_. + +### Patch Changes + +- a9df51c: fix(drivers): withhold the target field from a policy-authored `INVALID_FILTER` refusal (#8197) + + `#7929`/B stopped `driver-sql` echoing the operands of a cross-field + `{ $field }` refusal, and `#8220` gave that withhold a spec-declared provenance + mark so an author-written predicate gets its diagnostic back. Neither reached + the rest of the `INVALID_FILTER` family: five other refusals still named the + refused constraint's own **target column** to every caller. + + That column is not always the caller's. The security middleware ANDs an + administrator's compiled CEL rule into `opCtx.ast.where`, and on such a + predicate the target is as administrator-authored as the referent `#7929` + already withholds — the argument that ruling accepted, one step out. The most + reachable case is a permission rule over a `multiple: true` field, which lowers + to a membership test on a JSON-stored column and is refused by `#7398`'s gate + while naming the column the administrator wrote. + + Measured on a real `SqlDriver` (better-sqlite3, `:memory:`) through + `driver.find`, all five answered `INVALID_FILTER` / 400 naming the target, and + the author-marked spelling was byte-identical to the unmarked one — the mark + reached these sites but was never consulted, because none of these builders + passed through the withheld-refusal carrier. + + They now do. The five join the seam `#8220` already owns, with its fail + direction unchanged: + + - the JSON-column operator gate (`#7398`), + - the zero-operator field constraint (`#5240`), + - the unbindable comparand (`#5041`) — which also answers a **malformed** + `{ $field }`, one whose referent is not a string and so never reaches the + cross-field arm, + - the `$between` arity refusal, + + plus `driver-turso`'s copied `RemoteTransport.uncompilableComparand`, so one + deployment does not disclose differently depending on its connection mode. + `driver-sqlite-wasm` inherits `SqlDriver`'s compiler and needed no source + change. + + **Who sees what.** A subtree positively marked `'author'` by a read-scope merge + boundary keeps the whole diagnostic, target column included. Everything else — + `'policy'`, unmarked, and ambiguous — receives the refusal's identity + (`INVALID_FILTER` / 400), which class fired, and the capability statement and + repair prescription with placeholder names; the naming half goes to the server + log. Unmarked withholds by design: the mark is permission to reveal, never a + requirement to prove secrecy, and any design where a missing mark lands on the + disclosing branch re-opens `#7929`. + + **The accepted cost, stated rather than hidden.** The author-vouch surface is + two call sites, and `plugin-security`'s is conditional on `ast.where` still + being the caller's verbatim object — which fails once `plugin-sharing` has + composed (`#8430`). Until that lands, an author on an object with active + sharing rules loses the target-field name from these messages. That is + fail-closed, and it is the price of the ruling rather than a defect. + + Redaction takes everything derived from the predicate — the target field, the + operator, the comparand preview, the filter path — for the reason `#7929` gave + when it withheld both operands rather than one: a comparand preview is the + administrator's literal just as surely as a column name is, and half a + redaction is none. + +- ab8b10f: test(driver-sql): attribute each `legacyUniqueReplacements` guard to exactly one case (#8557) + + **`patch`, and deliberately not `none`.** This adds no runtime code and changes + no behaviour — every assertion is green on `main` before the change. The bump is + the floor rather than a skipped changeset because the file it protects is + release-relevant: what lands is the pin that makes a future single-guard + deletion visible, and the release notes for the version that first carries it + are the place a maintainer looks to learn the pin exists. A `minor` would claim + a capability; `none` would leave the protection undocumented at the only moment + anyone reads for it. + + The declared-index replacement arm's guards were **individually unpinned**: + measured on #8468, deleting the ADR-0120 S6 name-identity guard, or admitting a + declared bare `unique: true` through the scope filter, left the entire suite + green — including the two tests whose names say they cover exactly those cases. + The protection was real but collective, so no test attributed it to a line, and + a refactor could remove any single guard and be told nothing. + + `schema-drift.legacy-unique-guard-attribution.test.ts` adds that attribution. + The existing object-level suites are untouched — they are broader than any one + guard, which is why they could not do this job. + + - **Nine guards are individually attributable.** One input per guard, + constructed so only that guard can reject it, each paired with a **twin** — + the same input with the single property that guard reads changed, which must + produce exactly one replacement. The twin is the reachability witness: without + it a case would still pass while some earlier guard swallowed the input, which + is the failure mode being fixed, one level up. Measured: deleting any one of + the nine turns **exactly one** test red, and its name says which line went. + - **Five guards cannot be attributed at all**, because another guard rejects a + superset of their inputs — deleting one is behaviour-preserving for every + possible argument, so a test claiming to pin it would be lying. For those, + what is pinned is the **fact the domination rests on**, so the day it breaks + and the guard becomes load-bearing alone, something goes red. + + Behind the dominated S6 guard are the hand-written organization composites on + `sys_team`, `sys_business_unit` and `sys_member` — three shipped platform + objects on a spelling valid indefinitely. Those composites are now pinned + directly, in both the shipped bare-`true` spelling and the respelled + `'organization'` form. + + The bare-spelling case is the test-side half of a pair whose first half already + shipped: #8463 (PR #8512) put the same divergence into prose on + `isOrganizationScopedUnique`'s JSDoc, in this same file, with no test attributing + it. Routing the declared branch through the field predicate remains the rejected + option 1 of #8323 (maintainer ruling 2026-08-13), and is now refused by a test + rather than only by a comment. + +- a4acb8d: fix(driver-sql): a merge-path upsert stops rewriting the row's primary key (#8622) + + + + `upsert(data, conflictKeys)` on a **business key** — the ordinary way to ingest + external data — silently replaced the `id` of the row it merged into. Every + relationship, audit record, external id mapping and client-held reference + pointing at that row was left dangling, with no error raised on any dialect. + + Measured on a properly BACKED conflict target (`email` declared `unique: true`), + so this was the supported path, not an error path: + + ``` + upsert({ email: 'x@b.com', title: 'first' }, ['email']) + upsert({ email: 'x@b.com', title: 'second' }, ['email']) + + [sqlite] before=[{id:'yMh3oywrp0Z6p-oJ', title:'first'}] + after =[{id:'d8T8rUlTxlRlaUhN', title:'second'}] idPreserved=false + [pg] before=[{id:'T3AlYiyDi5buzGvW', title:'first'}] + after =[{id:'TvbCTa5mydWPYP76', title:'second'}] idPreserved=false + ``` + + One row throughout, as intended — with a different primary key. `upsert` mints a + nanoid for any call that supplies none, and `id` travelled in the merge set, so + `… on conflict ("email") do update set …, "id" = excluded."id"` wrote the + **losing** insert's fresh id over the winning row's. On the default `['id']` + conflict target that clause is a no-op (both sides hold the same value), which is + exactly why it stayed invisible for so long. + + `id` is now insert-only on the merge path, joining `created_at` and the + `auto_number` columns (#7011) in `insertOnlyUpsertColumns` — the same exclusion + argument at its strongest instance, since the primary key _is_ the platform's row + identity. It is resolved through `remoteColumn`, because a federated object can + bind `id` to a differently-named physical column (ADR-0015 §18) and a literal + `'id'` would filter nothing there. + + **The accept set is unchanged**: the same calls still succeed, still merge, and + still advance `updated_at` and every other mergeable column — the merge simply + stops rewriting row identity. Re-keying a row deliberately is still `update()`'s + job, which writes exactly the columns it is handed. + + Measured on SQLite and live PostgreSQL 16.13. Live MySQL 8.0.46 measured the same + rewrite in #8592 and its characterization pin is rewritten here to assert + preservation; that cell had no server available in this container and runs first + in CI's `Temporal Conformance (live PG + MySQL)` job. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/observability@17.1.0 + ## 17.0.0 ### Major Changes @@ -1161,7 +1439,7 @@ connection. The pool is probably full`, pointing an operator at pool sizing `lower()` folds ASCII only, which IS the contract (#4706 Q1 = A): `$icontains: 'café'` does not match `CAFÉ`. - + `driver-mongodb`'s unknown-operator arm was throwing a bare `Error` with no `code` and no `status`, three lines from the helper in its own file that sets @@ -5036,7 +5314,7 @@ migrate apply` — no `--allow-destructive` is required. Until the retirement is `lower()` folds ASCII only, which IS the contract (#4706 Q1 = A): `$icontains: 'café'` does not match `CAFÉ`. - + `driver-mongodb`'s unknown-operator arm was throwing a bare `Error` with no `code` and no `status`, three lines from the helper in its own file that sets diff --git a/packages/drivers/driver-sql/package.json b/packages/drivers/driver-sql/package.json index c7d3f5fec0..2659c04993 100644 --- a/packages/drivers/driver-sql/package.json +++ b/packages/drivers/driver-sql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sql", - "version": "17.0.0", + "version": "17.1.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 3e3fa0a173..d3f960a2fc 100644 --- a/packages/drivers/driver-sqlite-wasm/CHANGELOG.md +++ b/packages/drivers/driver-sqlite-wasm/CHANGELOG.md @@ -1,5 +1,37 @@ # @objectstack/driver-sqlite-wasm +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [a9df51c] +- Updated dependencies [f8eb736] +- Updated dependencies [ab8b10f] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [c8806ae] +- Updated dependencies [bb96297] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [a4acb8d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/driver-sql@17.1.0 + ## 17.0.0 ### Major Changes @@ -194,7 +226,7 @@ `lower()` folds ASCII only, which IS the contract (#4706 Q1 = A): `$icontains: 'café'` does not match `CAFÉ`. - + `driver-mongodb`'s unknown-operator arm was throwing a bare `Error` with no `code` and no `status`, three lines from the helper in its own file that sets @@ -1795,7 +1827,7 @@ $not`), so a negation has to leave as `$nor`, and a branch's own keys have to `lower()` folds ASCII only, which IS the contract (#4706 Q1 = A): `$icontains: 'café'` does not match `CAFÉ`. - + `driver-mongodb`'s unknown-operator arm was throwing a bare `Error` with no `code` and no `status`, three lines from the helper in its own file that sets diff --git a/packages/drivers/driver-sqlite-wasm/package.json b/packages/drivers/driver-sqlite-wasm/package.json index bb83194cb4..cc021c9c49 100644 --- a/packages/drivers/driver-sqlite-wasm/package.json +++ b/packages/drivers/driver-sqlite-wasm/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-sqlite-wasm", - "version": "17.0.0", + "version": "17.1.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 a346b20055..72d375c3d9 100644 --- a/packages/drivers/driver-turso/CHANGELOG.md +++ b/packages/drivers/driver-turso/CHANGELOG.md @@ -1,5 +1,96 @@ # @objectstack/driver-turso +## 17.1.0 + +### Patch Changes + +- a9df51c: fix(drivers): withhold the target field from a policy-authored `INVALID_FILTER` refusal (#8197) + + `#7929`/B stopped `driver-sql` echoing the operands of a cross-field + `{ $field }` refusal, and `#8220` gave that withhold a spec-declared provenance + mark so an author-written predicate gets its diagnostic back. Neither reached + the rest of the `INVALID_FILTER` family: five other refusals still named the + refused constraint's own **target column** to every caller. + + That column is not always the caller's. The security middleware ANDs an + administrator's compiled CEL rule into `opCtx.ast.where`, and on such a + predicate the target is as administrator-authored as the referent `#7929` + already withholds — the argument that ruling accepted, one step out. The most + reachable case is a permission rule over a `multiple: true` field, which lowers + to a membership test on a JSON-stored column and is refused by `#7398`'s gate + while naming the column the administrator wrote. + + Measured on a real `SqlDriver` (better-sqlite3, `:memory:`) through + `driver.find`, all five answered `INVALID_FILTER` / 400 naming the target, and + the author-marked spelling was byte-identical to the unmarked one — the mark + reached these sites but was never consulted, because none of these builders + passed through the withheld-refusal carrier. + + They now do. The five join the seam `#8220` already owns, with its fail + direction unchanged: + + - the JSON-column operator gate (`#7398`), + - the zero-operator field constraint (`#5240`), + - the unbindable comparand (`#5041`) — which also answers a **malformed** + `{ $field }`, one whose referent is not a string and so never reaches the + cross-field arm, + - the `$between` arity refusal, + + plus `driver-turso`'s copied `RemoteTransport.uncompilableComparand`, so one + deployment does not disclose differently depending on its connection mode. + `driver-sqlite-wasm` inherits `SqlDriver`'s compiler and needed no source + change. + + **Who sees what.** A subtree positively marked `'author'` by a read-scope merge + boundary keeps the whole diagnostic, target column included. Everything else — + `'policy'`, unmarked, and ambiguous — receives the refusal's identity + (`INVALID_FILTER` / 400), which class fired, and the capability statement and + repair prescription with placeholder names; the naming half goes to the server + log. Unmarked withholds by design: the mark is permission to reveal, never a + requirement to prove secrecy, and any design where a missing mark lands on the + disclosing branch re-opens `#7929`. + + **The accepted cost, stated rather than hidden.** The author-vouch surface is + two call sites, and `plugin-security`'s is conditional on `ast.where` still + being the caller's verbatim object — which fails once `plugin-sharing` has + composed (`#8430`). Until that lands, an author on an object with active + sharing rules loses the target-field name from these messages. That is + fail-closed, and it is the price of the ruling rather than a defect. + + Redaction takes everything derived from the predicate — the target field, the + operator, the comparand preview, the filter path — for the reason `#7929` gave + when it withheld both operands rather than one: a comparand preview is the + administrator's literal just as surely as a column name is, and half a + redaction is none. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [a9df51c] +- Updated dependencies [f8eb736] +- Updated dependencies [ab8b10f] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [c8806ae] +- Updated dependencies [bb96297] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [a4acb8d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/driver-sql@17.1.0 + ## 17.0.0 ### Major Changes @@ -340,7 +431,7 @@ `lower()` folds ASCII only, which IS the contract (#4706 Q1 = A): `$icontains: 'café'` does not match `CAFÉ`. - + `driver-mongodb`'s unknown-operator arm was throwing a bare `Error` with no `code` and no `status`, three lines from the helper in its own file that sets @@ -2306,7 +2397,7 @@ `lower()` folds ASCII only, which IS the contract (#4706 Q1 = A): `$icontains: 'café'` does not match `CAFÉ`. - + `driver-mongodb`'s unknown-operator arm was throwing a bare `Error` with no `code` and no `status`, three lines from the helper in its own file that sets diff --git a/packages/drivers/driver-turso/package.json b/packages/drivers/driver-turso/package.json index ec675186e7..3a13c5c9f5 100644 --- a/packages/drivers/driver-turso/package.json +++ b/packages/drivers/driver-turso/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/driver-turso", - "version": "17.0.0", + "version": "17.1.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 d8154806e3..4cd6be7e28 100644 --- a/packages/formula/CHANGELOG.md +++ b/packages/formula/CHANGELOG.md @@ -1,5 +1,25 @@ # @objectstack/formula +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [f57fb38] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/formula/package.json b/packages/formula/package.json index d2de6e48dc..9e46f6653f 100644 --- a/packages/formula/package.json +++ b/packages/formula/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/formula", - "version": "17.0.0", + "version": "17.1.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 202479e1ab..d485bb6d1a 100644 --- a/packages/lint/CHANGELOG.md +++ b/packages/lint/CHANGELOG.md @@ -1,5 +1,85 @@ # @objectstack/lint +## 17.1.0 + +### Minor Changes + +- 13d7864: Dashboard writes are now judged by `validateWidgetBindings` at the runtime publish gate (#7529). A dashboard widget bound to a dataset that resolves to nothing — previously a `200` on both save and publish, failing only as a runtime error on the live board — is refused at **publish** with a located 422 (`INVALID_METADATA`, the offending key path named). Drafts are unaffected: a draft may still hold a forward reference to a dataset not yet authored, and only the draft→active promotion runs the gate. + + Because rule surfaces are registered per-rule, all six of the rule's error-tier findings now gate a dashboard publish as one reference-integrity class: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`, `chart-field-unknown`, `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`. Warning-tier findings (`table-count-only`, `chart-config-missing`, …) ride the non-blocking `advisories` channel on the save response. Config-authored stacks are unaffected — `os validate` / `os build` / `os lint` already ran this rule; the newly gated population is exactly the `sys_metadata` overlay writes (Studio / REST `/meta` / MCP) that previously bypassed it. + + The per-write snapshot (`RuntimeStackContext`) now carries the live `datasets` collection so bindings resolve against the real dataset universe — without it every legitimate board would read as dangling. Existing stored rows are untouched (the gate blocks new publishes only), and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` remains the migration-window escape hatch. + +- b849e69: fix(lint): ask the provenance question at the fifth blanket-`SYSTEM_FIELDS` read site — `searchableFields` (#8404) + + `validate-searchable-fields.ts` judged a declared `searchableFields` entry + against the object-independent `SYSTEM_FIELDS` union, exactly as the four + filter/page-binding rules did before #8340 wired them to the per-object index. + Both of its gates were correct about EXISTENCE and structurally blind to + PROVENANCE: `:345` keeps `searchable-field-unknown` silent for any name in the + union, and `resolveAllowedSet` goes further — it manufactures a stub meta for + such an entry so it survives the resolution's existence filter exactly as it + does at runtime. + + On an ADR-0015 `external` object the platform registers its injected anchors + (`owner_id`, `organization_id`, the audit family, …) and provisions no storage + behind them (#7865 / #8116), so: + + ``` + searchableFields: ['name', 'owner_id'] // external object + ``` + + linted clean, the stub kept the entry in the resolved allow-list, and the + view's `$searchFields` narrowing then scanned a column empty on every record — + #4830's own failure mode (a narrower search than declared, silently) reached by + a different route. + + A new `searchable-field-unprovisioned` rule now warns on such an entry, on the + object's own canonical set and on a list view's narrowing alike, reusing + `unprovisionedAnchorCause` / `unprovisionedAnchorHint` so the sentence matches + the four #8340 rules verbatim rather than becoming a second copy (#4830). WARN, + never gating, per #4330's cost asymmetry: the remote schema is not visible to + this pass, so the finding describes a degradation rather than a refusal. + + **The `:239` stub is KEPT.** It is not incidental — it is what makes the linter's + resolution agree with the runtime's, which resolves the declared branch against + the registry field map. Measured by disabling it: the existing "keeps runtime + parity when the object declares system columns searchable" test goes red + (`expected [] to have a length of 1 but got +0`), because the declaration + existence-filters to empty and resolution falls through to the auto-default. + Dropping it would have been a behaviour change dressed as a warning. + + The warning is emitted per declared entry in the checker's entry loop, never + inside `resolveAllowedSet` — that helper reads the OBJECT's declaration and runs + once per narrowing, so warning there would repeat one object-level fact for + every view and attribute it to the view's path. + + `checkSearchableFieldList` takes the index as an OPTIONAL trailing parameter, + the same shape #8340 gave `checkFieldRefs`: its absence means the caller did not + build the index and the provenance question goes unasked — the previous + behaviour, preserved for out-of-repo callers (cloud graph-lint, the AI authoring + path). Both in-repo callers pass it. + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [f57fb38] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/formula@17.1.0 + - @objectstack/sdui-parser@17.1.0 + ## 17.0.0 ### Minor Changes @@ -3125,7 +3205,7 @@ null` (`has()` is correct in an object _validation_ rule, which is a `packages/lint` public-API decision for the maintainer rather than a rule file's to take. - + - 333769d: feat(lint): the `views[]` visibility-predicate family now gates runtime `view` publishes (#7220) @@ -7383,7 +7463,7 @@ z.string(), ])`, "string, post-build / inline function, pre-build"), a `packages/lint` public-API decision for the maintainer rather than a rule file's to take. - + ### Patch Changes diff --git a/packages/lint/package.json b/packages/lint/package.json index d398a68482..3094fe8db7 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/lint", - "version": "17.0.0", + "version": "17.1.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 831c3b16f9..7b8b563e3b 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,98 @@ # @objectstack/plugin-mcp-server +## 17.1.0 + +### Minor Changes + +- 20067c5: fix(runtime,mcp,service-datasource): the #6504 consumer sweep — three list consumers stop making claims a known-partial read cannot support (#6504) + + + + `IMetadataService.listDiagnosed?(type)` (PR #7721) lets a plural read say whether + its answer can be trusted as complete. This is the consumer half: the callers + that were restating a possibly-short listing as a fact about the environment. + + Each consumer was qualified individually, per PR #6051's discipline, and most + were left alone — a caller publishing a snapshot with no count has nothing to + mis-state. Three make a claim, and each now withholds exactly that claim while + still serving everything it could read: + + - **`removeDatasource` no longer deletes on a bound-object count it could not + take completely.** The guard `if (bound > 0) throw` is the only thing standing + in front of an irreversible delete that also unbinds the datasource's secret, + and its input is derived from the metadata service's object listing. During a + loader outage that listing goes silently short, and the worst value is the + benign one: `0` reads exactly like "nothing is bound", so the guard OPENED. + It now refuses with `SERVICE_UNAVAILABLE` / 503 — a dependency outage the + operator can retry, not a client error — and the record, its credential and + its pool all survive. + - **The MCP `list_objects` tool stops publishing `totalCount` on a known-partial + listing.** This is the same claim PR #7721 removed from the + `objectstack://objects` resource, on the other MCP primitive: same payload + shape, different door, never covered. A degraded read now serves the same + objects with `totalCount` **absent** and `partial` / `returnedCount` / + `warning` plus the 503 envelope in its place, so a client reading the total + gets `undefined` rather than a believable wrong integer. Both bridges + implement it — stdio (`@objectstack/mcp`) and HTTP (`@objectstack/runtime`) — + because a completeness claim must not depend on which transport a client + connected over. + - **The ADR-0015 §5.2 boot gate stops announcing an all-clear over a sweep it + could not complete.** It validated whatever `listObjects()` returned and then + logged _all federated objects match their remote schema_, with a count. + Federated objects behind an unreadable loader were never validated, so + `onMismatch: 'fail'` could not have fired for them. The gate now warns that + the swept set was incomplete and names what it did validate. ⛔ It does **not** + abort boot on a degraded metadata read: turning a transient outage into a + refusal to start would be a new failure mode bought with a diagnosis fix. + + Every new member is optional in the same way `listDiagnosed` itself is: a host + whose metadata service predates the verdict behaves exactly as it did before, + and a service without it reports nothing degraded — precisely what it could + express. + +### Patch Changes + +- ff4ba6a: fix(mcp): the skill prompt bridge reads the protocol's merged metadata listing, so a runtime `PUT /api/v1/meta/skill/` reaches MCP prompts (#8328) + + The bridge read `IMetadataService.list('skill')` — one layer below where the + `sys_metadata` overlay merge happens — so an override returned 200 and never + reached the prompt surface while `GET /api/v1/meta/skill` served it. The + long-lived (stdio) server's bridge now takes its items from the protocol's + `getMetaItems` when the host can supply it, and keeps the #6504 completeness + verdict by asking `listDiagnosed` for it alongside. A host assembled without the + metadata protocol reads exactly as before, and a merged read that throws does not + fall back to the un-merged listing. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/formula@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 95a517332e..edd4d3b56c 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/mcp", - "version": "17.0.0", + "version": "17.1.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 9416141b42..96912e6b15 100644 --- a/packages/metadata-core/CHANGELOG.md +++ b/packages/metadata-core/CHANGELOG.md @@ -1,5 +1,81 @@ # @objectstack/metadata-core +## 17.1.0 + +### Patch Changes + +- 845e164: fix(metadata-protocol): a package publish refused by the namespace-prefix rule now leaves an audit row per violation (#8595) + + `publishPackageDrafts` refuses a whole batch pre-flight when an object draft's + name is missing its package namespace prefix (ADR-0028). That refusal returns + ABOVE the batch's `engine.transaction()`, so it reached neither the post-commit + `allowed` rows nor the rollback handler's `batch_aborted` row: it wrote nothing + to `sys_metadata_audit` at all. The compliance consequence is the defect — a + package rejected for a bad object name was **indistinguishable in the trail from + a package nobody ever pressed Publish on**, so a compliance query could not tell + a refused publish from one that never happened. + + Each violation now leaves its own `publish` / `denied` row keyed on the + offending draft's `(type, name)` — the tuple `auditMetaItem` reads, so the + refusal is visible on that item's own audit-log tab via + `GET /api/v1/meta/:type/:name/audit`. The row carries the violated rule + (`namespace_prefix`) as its `code`, and the rule's actionable message as `note`. + Rows are keyed on the draft's own organization scope, matching the promoted + rows: an env-wide draft audits env-wide even when the publishing session carries + an active org. + + One row per violation rather than one per batch: a pre-flight refusal names N + violating items and no single causal one, so a batch-level row would have had to + mint a synthetic identity — exactly what the `batch_aborted` row declines to do + for its own unattributable case. + +- 7fc01db: REST `/meta` write doors now carry the caller's organization, so audit rows are no longer stamped environment-wide + + `PUT /meta/:type/:name` (both arities), `DELETE /meta/:type/:name`, + `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` passed no + organization, so every `sys_metadata_audit` row a REST-authored metadata write produced was + stamped `organization_id: null`. Composed with the scoped audit read shipped alongside it — + which returns own-org rows **plus** environment-wide ones, a limb that is required rather + than optional — that left every REST-authored audit row readable by every tenant, carrying + its `actor`, `note`, `lock_state` and `request_id`. The read side could not close this: the + rows were genuinely unscoped, so no filter could separate them. + + The organization is taken from the execution context these doors already resolve, and is + threaded through `organizationIdForMetaWrite` — the same registry-derived predicate the + runtime `/metadata` dispatcher uses. Types the registry declares `allowOrgOverride: true` + (`view`, `dashboard`, `report`, `translation`, `email_template`) now scope both the overlay + row and its audit row to the caller's organization; every other type continues to write + environment-wide, because its write genuinely is environment-wide and the protocol refuses + an org-scoped write for it. `null` is now reserved for writes that really are + environment-wide. + + Two behaviour changes ride along, both required for the fix to be usable rather than + separate improvements: `publish` and `rollback` resolve their row through the organization, + so scoping the save without scoping them would have broken the draft → publish loop; and + `GET /meta/:type/:name/published` is now organization-scoped (organization-first, then + environment-wide), without which it would answer 404 for an item the same caller had just + published through the same transport. + + `organizationIdForMetaWrite` / `declaresOrgOverride` moved from `@objectstack/runtime` into + `@objectstack/metadata-core` so both doors share one implementation — `@objectstack/rest` + cannot import from `runtime`, which depends on it. Runtime behaviour is unchanged. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [f57fb38] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + ## 17.0.0 ### Major Changes diff --git a/packages/metadata-core/package.json b/packages/metadata-core/package.json index a111e3fa62..a800bb377a 100644 --- a/packages/metadata-core/package.json +++ b/packages/metadata-core/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-core", - "version": "17.0.0", + "version": "17.1.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 c9da0f14ac..2f69d84d14 100644 --- a/packages/metadata-fs/CHANGELOG.md +++ b/packages/metadata-fs/CHANGELOG.md @@ -1,5 +1,13 @@ # @objectstack/metadata-fs +## 17.1.0 + +### Patch Changes + +- Updated dependencies [845e164] +- Updated dependencies [7fc01db] + - @objectstack/metadata-core@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/metadata-fs/package.json b/packages/metadata-fs/package.json index d0836870c8..54073e1a58 100644 --- a/packages/metadata-fs/package.json +++ b/packages/metadata-fs/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-fs", - "version": "17.0.0", + "version": "17.1.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 90cb919bb7..988c91a5a2 100644 --- a/packages/metadata-protocol/CHANGELOG.md +++ b/packages/metadata-protocol/CHANGELOG.md @@ -1,5 +1,845 @@ # @objectstack/metadata-protocol +## 17.1.0 + +### Minor Changes + +- 13d7864: Dashboard writes are now judged by `validateWidgetBindings` at the runtime publish gate (#7529). A dashboard widget bound to a dataset that resolves to nothing — previously a `200` on both save and publish, failing only as a runtime error on the live board — is refused at **publish** with a located 422 (`INVALID_METADATA`, the offending key path named). Drafts are unaffected: a draft may still hold a forward reference to a dataset not yet authored, and only the draft→active promotion runs the gate. + + Because rule surfaces are registered per-rule, all six of the rule's error-tier findings now gate a dashboard publish as one reference-integrity class: `widget-dataset-unknown`, `widget-dimension-unknown`, `widget-measure-unknown`, `chart-field-unknown`, `widget-legacy-analytics-unrenderable`, `dashboard-filter-field-unknown`. Warning-tier findings (`table-count-only`, `chart-config-missing`, …) ride the non-blocking `advisories` channel on the save response. Config-authored stacks are unaffected — `os validate` / `os build` / `os lint` already ran this rule; the newly gated population is exactly the `sys_metadata` overlay writes (Studio / REST `/meta` / MCP) that previously bypassed it. + + The per-write snapshot (`RuntimeStackContext`) now carries the live `datasets` collection so bindings resolve against the real dataset universe — without it every legitimate board would read as dangling. Existing stored rows are untouched (the gate blocks new publishes only), and `OS_ALLOW_UNLINTED_METADATA_WRITES=1` remains the migration-window escape hatch. + +- b69d0f5: fix(metadata): `PUT /meta/:type` refuses a type name the platform does not have, instead of minting a namespace for it (#8421) + + + + **BREAKING** accept-set narrowing on a published HTTP surface, landing after the + v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). A write + that answered `200 {"success":true}` now answers `400 INVALID_REQUEST`: + + ``` + PUT /api/v1/meta/fieldz/showcase_task.title + before → 200, sys_metadata row persisted with type='fieldz' + after → 400 INVALID_REQUEST, nothing persisted + ``` + + `fieldz` — or any typo — was neither a declared metadata type nor a known plural + spelling of one, so the boundary classified it as PLUGIN-registered, which every + authorization gate is permissive toward by construction. The row was persisted + under a type nothing reads and nothing serves, and the caller was told it had + succeeded. That silence is the real cost: a metadata-type typo, from a human or + from generated code, produced `success: true` and no indication the type is not + real. + + **Why this is only now safe to refuse.** #7894 closed the sibling case (a plural + spelling of a type the platform DECLARES) and left this one open on purpose: a + static predicate cannot tell `fieldz` from a plugin kind, and the live-registry + alternative was measured to be worse than the defect — the live type set is + ITEM-POPULATED, so it omits every legitimate kind that has no items yet, which + is the state each kind is in immediately before its first create. What changed + is the platform, not the boundary's information: #8586 retired + `MetadataPluginConfig.additionalTypes` and with it the last channel by which a + plugin could DECLARE a metadata kind, so an unrecognised name can no longer be a + declaration this refusal has not heard about (maintainer ruling 2026-08-14). + + **What still passes, pinned in both directions.** Every declared type in + `DEFAULT_METADATA_TYPE_REGISTRY`, in canonical and REST-plural spelling; every + manifest spelling and the singular each folds to; and the six plugin kinds that + have no static registry entry at all — `theme`, `webhook`, `connector`, + `sharing_rule`, `analytics_cube`, `rag_pipeline`. `PUT /meta/theme/dark` on a + deployment with zero themes is explicitly covered, because that first create is + exactly what a live-registry check would have broken. + + **The refusal is scoped to the door that mints.** Reads still ANSWER: a running + kernel legitimately holds live type keys the static contract does not — `data`, + `kind` and `package` all enter the registry during an ordinary `registerApp`, + and `GET /api/v1/meta/types` lists that live set — so refusing unrecognised + names on the read path would answer 400 for types the same service advertises. + `DELETE` is untouched for the mirror-image reason: rows minted under an + unrecognised type before this change are real, nothing rewrites them on upgrade, + and refusing their deletion would turn the accumulation this fixes into an + accumulation nobody can clear. + + **…but one published ADVERTISEMENT narrows with it, and that is a second + behaviour change worth reading on its own.** `GET /api/v1/meta/types` keeps + listing every live type, and every entry keeps every field — what changes is the + VALUE of one boolean: + + ``` + GET /api/v1/meta/types → entries[] where type ∈ {policy, data, package, kind} + before → allowRuntimeCreate: true + after → allowRuntimeCreate: false + ``` + + The listing synthesised `allowRuntimeCreate: true` for every live type with no + static registry entry, on the same expired premise as the write door: a name the + registry does not carry might be a kind some plugin declared. It now derives that + flag from the SAME predicate the mint door enforces, so the two endpoints agree + by construction instead of via two rules maintained apart. Nothing ever honoured + a runtime create on those four — they are internal bookkeeping (seed datasets, + package rows, kind descriptors) — so the advertisement was a promise the platform + did not keep, which is the same defect this card is about, relocated to the read + door. Direct precedent: `api` declared `allowRuntimeCreate: true`, the runtime + never honoured it, and the 2026-08-07 ruling removed the declaration rather than + converging the read path onto it. + + ⛔ The six plugin kinds with no registry entry — `theme`, `webhook`, `connector`, + `sharing_rule`, `analytics_cube`, `rag_pipeline` — are **not** affected: they are + in the static spelling contract, stay advertised `allowRuntimeCreate: true`, and + stay mintable. A UI reading this field (Setup → Metadata, the Studio designers) + therefore loses create affordances on exactly the four types whose creates were + already refused, and keeps them everywhere else. + + **The premise behind both halves is a CURRENT posture, not a closed door.** + Maintainer ruling, 2026-08-15, verbatim and untranslated: + 暂时不考虑让插件申明新的元数据类型 — plugins do not declare new metadata types + _for now_. That word is recorded deliberately: plugin-declared kinds were + considered and deferred, not ruled out. If they are ever wanted, the two sites + that encode the deferral name it and its date in place — + `getMetaTypes()`'s synthesis and `isRuntimeCreateAllowed` in + `@objectstack/metadata-protocol` — so the decision is findable rather than + re-derived from the code's silence. + + **Two shapes reaching the mint door are exempt, and each is a fact about the + request rather than a claim the caller makes.** + + 1. _The COMPOUND arity carries an OBJECT name in the `:type` segment._ + `PUT /api/v1/meta/lead/views/all_leads` is `type='lead'`, + `name='views/all_leads'` — one operation reaching one save, the shape both + the runtime dispatcher and the REST route document verbatim. `lead` is an + object, i.e. runtime data no static contract can enumerate, so a type verdict + applied there would refuse every object name that is not coincidentally a + metadata type. The ruling is about metadata TYPE names like `fieldz`. + ⚠️ Residue, stated rather than hidden: `PUT /meta/fieldz/a/b` is therefore + still accepted, because at that arity `fieldz` is a claim about an object and + the only way to check it is the live-registry lookup this card ruled out. + 2. _A namespace that already exists is not being minted._ `duplicatePackage` + re-saves every row of a package under a new name, taking each type from the + stored row — measured: a package holding one pre-existing residue row + answered `{success: false, copiedCount: 0, failedCount: 1}`, i.e. could not + be duplicated at all. That contradicts the `DELETE` reasoning above, so the + store (never the request) exempts a type that already has rows. The probe + runs only once the refusal has already fired, and a store that cannot answer + refuses — a fresh deployment has no residue to protect. + `migrate meta --stored` was read as a third victim and measured NOT to be + one: an unrecognised type has no manifest collection, hence no ADR-0087 + chain, hence no notice, so such a row is reported `canonical` and the mint + door is never reached. + + **What breaks.** A caller creating metadata at runtime, at the simple arity, + under a type name that is in neither half of the static spelling contract and + has no rows already. That set is **not** empty in this repo — measured on + `objectql`, `runtime` and `rest`, three in-tree fixtures minted `trigger` (a kind + ADR-0088 retired outright), `policy`, and a synthetic `my_plugin_kind`. All three + are corrected here rather than exempted, and each for its own reason: the + `trigger` specimens were debt independent of any ruling (a retired kind cannot + demonstrate a live tier, and they were green only through the hole this card + closes), `policy` becomes a refusal case of its own, and #7894's control keeps + its `metaUrlSpellingRefusal` claim while its boundary expectation follows the + narrowing. An out-of-tree plugin that made its kind live by registering an item + of it, and then accepted runtime writes to that kind through `/meta`, needs its + spelling in the contract; there is no declared-kind channel to register one + through today — that is the trade #8586's retirement made, and the `暂时` above + is what makes it revisitable. + + `@objectstack/spec` gains one export, `unrecognisedMetaTypeRefusal`, alongside + the #7894 verdict it deliberately does not merge with: one says _you spelled a + declared type wrongly_ and can name the replacement, the other says _there is no + such type_ and never guesses. The residue pin #7894 left behind + (`metadata-url-spelling.test.ts`, the case that asserted `fieldz` was refused by + nobody) is **flipped, not deleted**. ⚠️ #7894's positive control keeps its own + claim intact — `metaUrlSpellingRefusal` still cannot refuse a kind that is a + misspelling of nothing, which is what makes that control true by construction — + but the BOUNDARY it drives now refuses six of the twelve names it exercises, + and that case says so in place rather than leaving it to inference. + +### Patch Changes + +- 5047cb8: fix(metadata-protocol): scope the metadata audit read to the caller's organization (#8747) + + `ObjectStackProtocolImplementation.auditMetaItem` declared + `organizationId?: string | null` and never read it. The comment directly above + its query described the filter it would have built — "include rows for the + specific org AND env-wide (`organization_id IS NULL`) rows" — while the `where` + was exactly `{ type, name }`. The parameter was dead on the caller side too: + `GET /api/v1/meta/:type/:name/audit` never passed one. + + The consequence was a cross-tenant disclosure, measured rather than inferred: + three saves of one view name under two organizations and env-wide, then one + `auditMetaItem({ type, name })` read, returned all three organizations' rows — + and with each row its `actor`, `note`, `lock_state`, `code`, `operation`, + `source` and `request_id`. Nothing compensated lower down. The driver's tenant + wall never engaged, because it is armed only from an execution context this + read did not pass; the security plugin's Layer 0 never engaged, because the + middleware short-circuits on a principal-less call long before the field gate + that would have carried it; and no tenancy posture would have supplied the + scope either. The route carries no capability gate — unlike its `PUT` twin, + which gates on `manage_metadata` — so the reachable cohort was any + authenticated principal of any tenant, on the published `meta.getAudit` SDK + surface. + + The query now builds the described filter: rows for the caller's organization + plus env-wide (`organization_id IS NULL`) rows, and nothing else. The env-wide + limb is load-bearing rather than defensive — the REST `PUT /meta/:type/:name` + door passes no organization, so every row it writes is stamped + `organization_id: null`, and an equality-only filter would have blanked the + audit tab on those deployments instead of scoping it. A read that resolves no + organization is fail-closed onto the env-wide rows, symmetric with what an + org-less write produces, so omitting the parameter is no longer a skeleton key. + + The REST route supplies the organization from the execution context it already + resolves for 40-plus handlers, adding no new organization-resolution plumbing + to `packages/rest`. The same call also stopped passing `environmentId`, which + the request type never declared and the method body never read; environment + scoping is unaffected, since it comes from which protocol instance is resolved + rather than from the request payload. + + Behaviour change worth stating plainly: a caller that previously saw another + tenant's metadata audit rows for a same-named item no longer sees them. Own-org + and env-wide rows are unchanged. + +- 177442d: fix(metadata-protocol): `getMetaDiagnostics` stops publishing an unreadable metadata store as "0 problems" (#8855) + + + + `GET /api/v1/meta/diagnostics` sweeps every metadata type and publishes four + numeric facts about the corpus. Its per-type read was wrapped in an **untyped** + `catch` that `continue`d, and the comment above it named a benign reason ("type + not listable in this kernel scope") that is genuinely real. The catch took + everything else with it — including the one error the callee exists to raise. + + `getMetaItems` classifies a failed `sys_metadata` read by error **type** and + throws a 503 (`SERVICE_UNAVAILABLE`) for every read failure that is not "the + table has not been provisioned yet" — the discrimination #5532 introduced so an + outage would stop looking like emptiness. `getMetaDiagnostics` caught that 503 + back into emptiness one layer up, then published the emptiness as a **number**. + + **Measured on `origin/main` @ `8664a2c99` before the fix**, prediction written + down first and matched exactly. With an engine whose every read rejects: + + ``` + [outage: connect ECONNREFUSED 10.0.0.5:5432] RESOLVED + total=0 scannedTypes=26 scannedItems=0 Object.keys(stats).length=0 + [benign: SQLITE_ERROR: no such table: sys_metadata] RESOLVED + total=0 scannedTypes=26 scannedItems=0 Object.keys(stats).length=26 + ``` + + Two user-visible harms from one `catch`, and the benign run is what makes them + legible — it is the same payload minus the `stats`: + + - `stats[t]` is never written, so an unreadable type is **absent** from the + response rather than zero. The Studio directory tile the field's own doc names + loses the type, byte-shaped like an environment that declares none of it. + - `total` counts entries that **failed validation**, and a store nobody can read + contributes none — so the endpoint whose whole job is reporting problems + answered `total: 0` at the exact moment it could read nothing. Green was the + failure mode. + + `scannedTypes` reported the full 26 in both runs: it is computed from the intent + (`targetTypes.length`, fixed before the loop) and never decremented on + `continue`. + + **The fix narrows the catch; it does not delete it.** A 503 arriving from the + read is rethrown **unchanged** and the sweep fails loudly (ADR-0110 D3: a miss + and an outage are different facts with opposite dispositions). Every other + failure still skips that one type, so a kernel scope that cannot enumerate one + type does not fail the whole governance sweep. + + **No response field was added.** A per-type degradation marker would be a + public-surface addition, and the payload type is unchanged. + + The envelope is **propagated, not rebuilt**: re-running the driver-error + classification here would re-wrap an already-shaped 503 in a second one and + displace the driver error riding as `cause` — the object `logWithheldServerFault` + prints for the operator. The REST boundary needs no change: the handler already + routes thrown errors through `handleRouteError`, which preserves the 503. + + The pin carries the discriminating control in the same file: an unprovisioned + `sys_metadata` still answers benignly with every type present at `count: 0`, a + type that is genuinely not listable is still skipped at the cost of one type, + and a healthy store still counts its rows — while the outage cases throw. "0 + problems" is the right answer in the benign cell, and it is exactly the answer a + blanket change would have kept producing in the wrong one. + +- 950bd94: perf(metadata-protocol): `diffMetaItem` stops awaiting a `historyMetaItem` read it discarded, halving the history round trips on the live diff endpoint (#8798) + + `diffMetaItem` opened by awaiting a full `historyMetaItem` read, mapped it into a + `versions` array, and threw it away (`const _used = versions; void _used;`) while + the read it actually uses ran a few lines below through the engine. Every request + to the routed `GET /api/v1/meta/:type/:name/diff` paid for two reads of + `sys_metadata_history` where one is used. + + Diff bodies are unchanged. The authorization gate the discarded call passed + through never reached this function's output: `historyMetaItem`'s early return + answers `{ events: [] }` for a type that is neither `isOverlayAllowed` nor + `isRuntimeCreateAllowed`, without throwing and without touching the engine, and + `diffMetaItem` reads the history rows directly — so the five gated-shut types + (`field`, `job`, `api`, `capability`, `agent`) were already served a full diff + regardless. + + One behaviour change, on the outage path only. The discarded call was unguarded, + so an unavailable `sys_metadata_history` was fatal for gated-open types while + gated-shut types fell into the `try`/`catch` below it and answered an empty diff + — one outage, two answers, decided by an authorization gate unrelated to reading + history. Every type now takes the `catch`, which is the function's only stated + intent for that failure. Whether swallowing that outage is the right answer at + all is tracked in #8833. + +- 3043e98: fix(metadata-protocol): `diffMetaItem` folds its type at the request boundary and stops serving a history outage as an empty diff (#8868, #8833) + + `GET /api/v1/meta/:type/:name/diff` is a routed live endpoint with a + caller-supplied `:type`. Two independent defects in that one method, fixed + together because they land in the same function. + + **#8868 — the canonical fold.** `diffMetaItem` was the NINTH `/meta` entry point + on this URL family and the last one still deriving its type key from + `PLURAL_TO_SINGULAR`, the manifest-COLLECTION map that #7894 moved this boundary + off (#8769 routed `publishMetaItem`, #8819 routed `rollbackMetaItem`). It now + routes through `canonicalizeMetaRequestType`, which changes three things: + + - **the answer.** For the four MANIFEST-ABSENT types — `field`, `seed`, + `external_catalog`, `translation`, legitimately absent from that map because + they are not stack collections — a plural spelling stayed plural all the way + into the `sys_metadata_history` query, matched no row, and the endpoint + answered a well-formed **empty diff** (`added: []`, `removed: []`, + `changed: []`) for an item that does have history. Not a refusal and not an + error: a silent "nothing changed". Manifest-present types (`views` → `view`) + folded already and were never affected. + - **unrecognised spellings.** The #7894 boundary refusal never ran on this verb, + so a spelling like `viewes` was forwarded to the plugin path instead of + refused. It is now `400 INVALID_REQUEST`, naming both accepted spellings. The + refusal stays narrow by construction: a name that reaches for no declared type + (a possible plugin kind) is still served. + - **the echoed `type`.** The response echoed the caller's spelling back while the + read had used a different key. It now reports the canonical spelling — the + precedent `saveMetaItem` and `deleteMetaItem` already set, both of which + `return { type: request.type }` after their own fold. + + **#8833 — the swallowed outage.** The history read sat in a `try` whose `catch` + was empty apart from a comment. `histRows` stayed `[]` and the code below read + that never-filled accumulator as a real answer, so a `sys_metadata_history` + outage was served as a successful 200 with an empty diff — byte-identical to + "these two versions are the same", with no log line either. An operator + comparing versions before a rollback, and any SDK or agent reading this + endpoint, acted on "unchanged" with full confidence. + + Per the maintainer ruling on #8833, the `catch` now routes through the + platform's existing discrimination, `rethrowUnlessMetadataStoreUnprovisioned`: + + - a **genuinely absent table** — a minimal deployment that never provisioned + history — keeps its benign empty answer, so first boot does not explode; + - **every other read failure** (connection drop, timeout, permission denial, + query error) propagates `503 SERVICE_UNAVAILABLE`, carrying the driver error + as `cause`. ADR-0110 D3: a miss and an outage are different facts. This is the + same guard #5532 restored for `getMetaItems`. + + ⚠️ **Behaviour change worth reading before upgrading.** This ADDS loudness where + there was none. PR #8841 had removed the last path that threw here, so as of + that change the outage was silent for _every_ type; a diff whose history store + is unreachable now returns 503 where it previously returned 200 with an empty + diff. A diff against a deployment that never provisioned `sys_metadata_history` + is unaffected. No response field was added — a `historyUnavailable` key was + considered and declined. + +- fd6bdf8: Declare `saveMetaItem`'s missing-item refusal as a real ADR-0112 envelope: `400` / `INVALID_REQUEST`, was an undeclared throw served as `500 INTERNAL_ERROR`. + + `PUT /api/v1/meta/:type/:name` unwraps the `{ item }` / `{ metadata }` envelope shapes before calling the protocol, so a caller sending `{"item": null}` or `{"metadata": null}` reached a guard that declared neither `code` nor `status` — the only refusal in the method that did not. With no status to read, the REST boundary defaulted to a server fault, so an authoring mistake was reported as `500 INTERNAL_ERROR` and the guard's own sentence was withheld by the ADR-0112 disclosure rule and replaced with a generic fallback. Callers now receive `400` with the refusal quoted and the remedy named. + + Unchanged: a missing, empty or literal-`null` request body never reached this guard and still answers `422 INVALID_METADATA` from the per-type schema parse. No new error code is introduced — `INVALID_REQUEST` is already registered to this package in the ADR-0112 ledger, and is what the structurally identical opening guard in `rollbackMetaItem` already uses. + +- ead96d0: fix(metadata-protocol): the metadata read path no longer serves stored cleartext credentials (#8154) + + `decorateMetadataItem` returned the whole stored body, so a `datasource` row + written before #8078 closed the write door came back with `config.password` in + cleartext — and the password embedded in `config.url` alongside it — from + `GET /api/v1/meta/datasources`, from the single-item read, and from the layered + read in **both** its `overlay` and `effective` layers. PR #8126 closed the + datasource-admin door (`GET /api/v1/datasources/:name`); this closes the + platform door one over. Meta read permission is granted at a far lower bar than + "may see the production database password", which is what made this reachable. + + The fix consumes the per-type redactor registry #8300 landed in + `@objectstack/spec/kernel` (`getMetadataTypeRedactor`) rather than redacting + `datasource` specifically: `datasource` is that registry's first consumer, and a + type-shaped patch here would be the narrow fix that leaves the next + secret-bearing type exposed. A plugin whose metadata type stores secrets gets + the same protection by calling `registerMetadataTypeRedactor` — no change here. + + Three properties worth knowing, each measured rather than assumed: + + - **`_diagnostics` are still computed on the RAW stored body, before + redaction.** The redacted body is exactly the shape the post-#8078 schema + accepts, so computing them afterwards flips `valid:false` to `valid:true` on + precisely the rows that hold a stored credential — which would delete the + operator's only inventory of what still needs migrating (#8081 item 3). The + two steps are composed inside one function so no call site can invert an + ordering it cannot see. + - **The stored record is never mutated, and the connect path is untouched.** + Redaction is a serving act; datasource connection and boot-time restore read + `sys_metadata` directly through the data engine, not through these exits. + - **The write path carries the credential forward**, and this half is not + optional: `saveMetaItem` accepts a redacted body and persists the credential + away, so a read scrub shipped alone would convert today's loud `422` into + **silent credential deletion** on an ordinary GET → edit → PUT round trip. + `config.url` makes it unavoidable rather than a masking choice — a + URL-embedded password is schema-accepted, so dropping it round-trips to + deletion and masking it round-trips to storing the mask as the literal + password. Stored material is re-applied only where the incoming body is + indistinguishable from what the read served; anything the author actually + wrote wins and is still judged by #8078's write gate on its own merits. This + also restores the #4326 byte-identical round-trip invariant, which + read-redaction alone would have broken. + + It preserves cleartext already at rest and creates none; moving stored + credentials into `sys_secret` is #8081 item 3's migration and is deliberately + not attempted on a write door an author drove. + +- c15eb23: fix(metadata): the `422 INVALID_METADATA` envelope descends `invalid_key` / `invalid_element`, so a rejected record key arrives with the rule it broke (#8783) + + Zod raises a `z.record` / `z.map` **key** rejection as `invalid_key` and a + `z.map` **element** rejection as `invalid_element`, and in both cases the + issue's own `message` is a bare wrapper — `"Invalid key in record"` — with the + real diagnosis one level down in `issue.issues`. That is structurally the + `invalid_union` shape #4971 named: the prescription is produced and then + dropped by a walk that reads only the top level. + + Both `packages/spec` walks learned to descend those codes in #5389. + `zodIssuesToMetadataIssues` — the walk behind `saveMetaItem`'s 422 (#5364) and + the read path's diagnostics (#5598) — expanded `invalid_union` only, so it + stopped at the wrapper. Three walks over one `safeParse`, two of them reaching + the prescription and the Studio-facing one not. + + **It was reachable from ordinary authored metadata, not synthetic.** + `ObjectSchema.fields` is a record whose KEY schema carries the snake_case rule + (`spec/src/data/object.zod.ts`), and `object` is in the builtin + `getMetadataTypeSchema` registry. So the commonest authoring mistake on the + most-authored metadata type — writing `firstName` for a field key, which is + exactly what an agent coming from JS naming writes — produced: + + ``` + { path: 'fields.firstName', code: 'invalid_key', message: 'Invalid key in record' } + ``` + + The author was told a key was invalid and never told what a valid one looks + like, so the next move was to guess. The declared message existed and was + correct; it just did not reach anyone. Now the same save answers: + + ``` + { path: 'fields.firstName', code: 'invalid_key', message: 'Invalid key in record' } + { path: 'fields.firstName', code: 'invalid_format', message: 'Field names must be lowercase snake_case (e.g., "first_name", …)' } + ``` + + **Additive, and matched to the walks that already worked** rather than chosen. + The other two were measured over the card's own repro first: `formatZodIssue` + prints the wrapper line then the indented detail, and `zodIssuesToFields` emits + the `invalid_shape` wrapper entry then the detail entry. So the wrapper stays at + index 0 — it is the only entry naming the slot the client sent, and Studio's + designer keys on it — and the detail joins it on the same path. No entry that + shipped before is removed or renumbered. + + **Targeted, not a widened walk.** Only the two container codes open the descent; + an `issues` array hanging off any other code is still ignored, `invalid_union` + still expands through the unchanged ranking, and the nesting bound now covers + both descents at the same depth of 3. Container issues are deliberately _not_ + ranked the way union branches are: a union's branches are competing candidates, + while a container has one inner schema, so every issue it raised is a true + statement about the value. + + The verdict is unchanged in every case — this moves what a refusal _says_, never + whether it is one. `union-branch-policy.cross-package-parity.test.ts` gains a §5 + comparing the container descent across all three walks; its §1 (the policy is + not publicly exported from `@objectstack/spec`, so this package must run its own + copy) is untouched, and no export was added. + +- 845e164: fix(metadata-protocol): a package publish refused by the namespace-prefix rule now leaves an audit row per violation (#8595) + + `publishPackageDrafts` refuses a whole batch pre-flight when an object draft's + name is missing its package namespace prefix (ADR-0028). That refusal returns + ABOVE the batch's `engine.transaction()`, so it reached neither the post-commit + `allowed` rows nor the rollback handler's `batch_aborted` row: it wrote nothing + to `sys_metadata_audit` at all. The compliance consequence is the defect — a + package rejected for a bad object name was **indistinguishable in the trail from + a package nobody ever pressed Publish on**, so a compliance query could not tell + a refused publish from one that never happened. + + Each violation now leaves its own `publish` / `denied` row keyed on the + offending draft's `(type, name)` — the tuple `auditMetaItem` reads, so the + refusal is visible on that item's own audit-log tab via + `GET /api/v1/meta/:type/:name/audit`. The row carries the violated rule + (`namespace_prefix`) as its `code`, and the rule's actionable message as `note`. + Rows are keyed on the draft's own organization scope, matching the promoted + rows: an env-wide draft audits env-wide even when the publishing session carries + an active org. + + One row per violation rather than one per batch: a pre-flight refusal names N + violating items and no single causal one, so a batch-level row would have had to + mint a synthetic identity — exactly what the `batch_aborted` row declines to do + for its own unattributable case. + +- 8d017eb: fix(metadata-protocol): route `publishMetaItem` through the `/meta` canonical-type fold (#8769) + + `canonicalizeMetaRequestType` is the `/meta` request boundary, and its own + header describes it as the fold "all six entry points funnel through". + `publishMetaItem` is a **seventh** entry point on the same URL family + (`/api/v1/meta/:type/:name/publish` and the `…/published` overlay) and did not + funnel through it: it reached the draftability check through + `PLURAL_TO_SINGULAR`, the MANIFEST-COLLECTION map, which is the exact lookup + #7894 replaced at the other six. One contract, two dialects, decided by which + verb you used (Prime Directive #12). + + The fix is the same one line the other six carry, at the top of the method. What + that line reaches — measured on `origin/main`, not inferred — differs by whether + the type is in the manifest map, and the two halves are not the same severity: + + **The four manifest-absent types — fail-closed, but closed for the wrong reason + and with the wrong verdict.** `field`, `seed`, `external_catalog` and + `translation` are legitimately absent from `PLURAL_TO_SINGULAR` (they are not + stack collections; that absence is precisely why #7894 moved the boundary onto + the URL map). Unfolded, they arrived at the draftability check as unrecognised, + where `isRuntimeCreateAllowed`'s "no static registry entry ⇒ this is a + plugin-registered kind" arm answers **true** — the permissive plugin branch, + taken for a type the platform itself declares. So a publish addressed + `/meta/fields/showcase_task.title` PASSED a gate that `/meta/field/...` answers + `403 NOT_OVERRIDABLE`, and only failed further down, on `404 no_draft`, having + already forgotten which type it was judging. A publish addressed + `/meta/translations/zh_cn` likewise never resolved the draft that + `PUT /meta/translations/zh_cn` had folded and written under `translation`. After + the fold: the first is refused `403 NOT_OVERRIDABLE` by its real registry entry, + the second promotes the row it names. + + **Manifest-present types — one lookup that did NOT fail closed.** + `promoteDraftForPublish` folds through the manifest map before the row lookup, + so a publish addressed `/meta/views/case_grid` always resolved the canonical + row. `getEffectiveLock` does not agree with it: its artifact limb folds, its + **overlay limb queries `sys_metadata` with the raw `type`**. Addressed with the + plural, the ADR-0010 `_lock` carried by the stored active row was looked up + under a `type` no row has and came back `'none'` — which is not a neutral value, + it is the verdict "the author declared no protection" (#5706) — while the + promote one line later read the folded key and overwrote the row the lock + protected. Measured on `origin/main`: `_lock: 'no-overlay'` plus a pending + draft, canonical spelling `403 ITEM_LOCKED`, plural spelling **200 and the + active body replaced**. + + That window is narrow and is stated at its real width rather than rounded up: it + needs an environment kernel (the gate is skipped wholesale when `environmentId` + is `undefined`), a lock carried by a _stored overlay_ row rather than a packaged + artifact, and a draft that predates the lock — because the save door refuses to + mint one once the lock is live. It is nevertheless a lock gate that could be + addressed around from the wire, and "a lock gate must not fail open" is the rule + this file already carries. + + `promoteDraftForPublish`'s own `PLURAL_TO_SINGULAR` fold is **kept**, and the + measurement is the reason: that helper's other caller is `publishPackageDrafts`, + which feeds it stored row types. That is data at rest, where a legacy row + written under a plural `type` is real and nothing rewrites it on upgrade — a + different input class needing a different map, exactly as `canonicalMetaType`'s + header describes. Deleting it as "now redundant" would have changed the batch + path. + + `publishPackageDrafts` and `deletePackage` need no fold of their own: neither + takes a caller-supplied `type` at all (both are addressed by `packageId`), and + the per-row work they delegate is already covered — `deletePackage` routes every + row through `deleteMetaItem`, which folds, and `publishPackageDrafts` reaches + the manifest-map fold described above. + + The audit row and the publish receipt now record the canonical type too; both + read `request.type`, so a publish addressed `/meta/views/case_grid` previously + wrote `type='views'` into `sys_metadata_audit` for a row stored under `view`, + and a compliance query on the canonical spelling did not find it. + + Pinned in `packages/objectql/src/protocol-publish-canonical-fold.test.ts` + against a real engine and repository, with the reverse verification's direction + predicted before it was run: predicted 3 red / 4 green, measured 3 red / 4 + green, each red for its predicted reason. + +- 88ef34d: fix(metadata-protocol): `rollbackMetaItem` routes through the canonical type fold, closing an ADR-0010 `_lock` a plural URL spelling could address around (#8819) + + `rollbackMetaItem` is the **eighth** `/meta` entry point on the + `POST /api/v1/meta/:type/:name/rollback` URL family, and it was the last one + still deriving its type key from `PLURAL_TO_SINGULAR` — the + MANIFEST-COLLECTION map #7894 moved this boundary off — instead of + `canonicalizeMetaRequestType`. The other seven fold; this one did not. + + **The half of that asymmetry that was not fail-closed is the lock.** + `assertLockAllowsWrite` delegates to `getEffectiveLock`, whose artifact limb + folds and whose **overlay limb queries `sys_metadata` with the raw `type`**. The + rollback passed the caller's spelling to the gate while every row operation + below it used the folded key. So for a manifest-present type, a rollback + addressed `/meta/views/case_grid/rollback` looked the `_lock` up under a `type` + no row carries, got `'none'` back — which is not a neutral value but the verdict + "the author declared no protection" (#5706) — and then restored the history body + against the folded key, which resolves the protected row perfectly. A lock gate + addressable around from the wire, on the verb that overwrites the active body. + + **The severity window is narrow and is not rounded up here.** It needs an + environment kernel (`assertLockAllowsWrite` opens with + `if (this.environmentId === undefined) return null`, skipping the gate wholesale + otherwise) **and** a lock carried by a **stored overlay row** rather than a + packaged artifact — the artifact limb folds, so an artifact `_lock` was already + found under either spelling. Inside that window the write landed. + + The fold also reaches three things that were merely incoherent rather than + unsafe: the revertability tier (`isOverlayAllowed` / `isRuntimeCreateAllowed`) + took the permissive **plugin** branch for the four manifest-absent types + (`field`, `seed`, `external_catalog`, `translation`); and the + `[not_overridable]` refusal, both ADR-0010 audit rows and both receipt sentences + reported the **caller's** spelling for a row written under the canonical one. + `recordMetadataAudit` re-folds internally through `PLURAL_TO_SINGULAR`, which + covers a manifest-present plural and misses the four manifest-absent ones — so + folding at the boundary is what makes the audit trail agree with the write for + both classes. + + Placed after the existing `toVersion` envelope guard rather than at the very top + of the method: that is the position `saveMetaItem` documents for this exact pair, + naming this method's opening guard its structural twin — a malformed request + envelope is refused before its type key is canonicalised, and both refusals are + `[invalid_request]`/400 either way. + + **What this does not do.** `getEffectiveLock`'s overlay limb still queries the + raw `type`. Folding it there would close the class at the producer for every + present and future caller, which is the contract-first shape — but it is a + shared gate whose blast radius wants its own measurement, so it is deliberately + left open as its own card rather than ridden in here. + + Pinned in `packages/objectql/src/protocol-publish-canonical-fold.test.ts` as + group D, driving the real `ObjectQL` / protocol / `SysMetadataRepository` over an + in-memory driver on an environment kernel: the canonical spelling is refused by + the lock, the plural spelling is refused by the **same** lock, and — the clause + that matters, since the first two can both pass while the write still lands — + the protected active body is **unchanged** afterwards. A positive control runs + the identical plural call with the lock removed and asserts it really does + restore the earlier body, so the group cannot pass by being unable to roll back + at all. + +- add2d19: fix(metadata-protocol): global search titles a hit from the canonical `nameField`, not only the deprecated `displayNameField` alias (#8786) + + `searchAll` — the global-search (⌘K) palette — resolved a hit's title from a + candidate list that opened with `obj.displayNameField` **alone**. Under + ADR-0079 `nameField` is the canonical primary-title pointer and + `displayNameField` is the deprecated alias, so this was the one consumer a + canonical designation could not reach. + + It is reachable rather than theoretical because `provisionPrimary` — the + ADR-0079 designation seat the SchemaRegistry runs on every object at + registration — stamps `nameField` **only** and never the alias. An object that + declares its primary title canonically, without also carrying the deprecated + alias, produced `undefined` for that entry, the entry was filtered out of the + candidate list, and the title fell through to `String(row.id)`: the palette + showed a raw record id where the object's own declared, populated title + existed. + + Impact was bounded to objects whose primary title is **outside** + `name` / `full_name` / `title` / `subject` / `label` / `company` — anything in + that conventional list already resolved through the later entries, which is why + this stayed invisible. An object declaring `nameField: 'company_name'` now + titles its hits `Acme Industrial` instead of `acc_1`. + + The fix reads the precedence the rest of the platform already spells — + `obj.nameField ?? obj.displayNameField` — matching `resolveDisplayField` + (`@objectstack/spec`), the #4254 ingress gate, and this same function's + search-field resolution 44 lines below. The deprecated alias is still honored + on its own; only objects that carry **both** pointers naming **different** + fields see a precedence change, and no such object exists in this repo (every + one that carries both spells them identically). + + Presentation only: which rows come back is untouched. + +- 2b9d33a: Stamp seeded rows with the install's organization so one object runs one autonumber scope (#8686) + + Seed writes and API writes disagreed about tenancy. Seed data is loaded during + app start, before any human user exists, so the seed loader had no organization + to stamp and its rows landed `organization_id = NULL`; API writes carried the + signed-in user's organization. The SQL driver keys its autonumber counter by + exactly that column (`__global__` when NULL), so a single object ran two + independent counters — and the uniqueness index is partitioned by the same key + (`COALESCE(organization_id, '__global__'), `), so the duplicates the + second counter minted were invisible to the constraint. On a single-tenant + install seeded with `CASE-00001..38`, the first four API creates returned + `CASE-00001..4` again: four duplicated values on a field declared `unique`, with + 201s and no warning. + + Seed writes now carry the organization the same way API writes do. The moment an + install's organization first exists, untenanted seed rows are adopted into it and + the `__global__` counter is merged into the organization-scoped one, so the + `__global__` pseudo-tenant stops acting as a peer of a real organization. Existing + installs are repaired by a one-shot boot-time backfill, guarded to single-tenant + installs; a multi-tenant install where a split is detected is never guessed at — + the backfill skips and logs the condition and the remedy. Business identifiers + that were already minted twice are reported for the operator, never silently + renumbered. Platform namespaces (`sys_`/`cloud_`/`ai_`) stay global, exactly as + the seed loader already treats them. + +- 0f59584: fix(metadata-protocol): `sys_setting`'s declared row identity is enforced on the tenant and global layers — a runtime NULL-safe UNIQUE index over `COALESCE(user_id, '')` (#8629) + + + + `sys-setting.object.ts` declares the object's row identity as + `{ fields: ['namespace', 'key', 'scope', 'user_id'], unique: 'organization' }`, + and the object's own header calls that the row identity. It was not one. + `user_id` is NULL on every row that is not `scope='user'` — `SettingsService.set` + computes it as `scope === 'user' ? ctx.userId ?? null : null` — and SQL UNIQUE + treats NULLs as mutually distinct, so the constraint was **void on the `tenant` + and `global` limbs**: exactly the two carrying organization-level and + platform-level configuration. + + Measured on a real engine, before this fix: two identical `scope='tenant'` rows + in ONE organization both landed (`201`, `201`), two identical `scope='global'` + platform defaults both landed, while the same rows with a non-NULL `user_id` + were refused — the control that identifies the mechanism as the NULL rather than + the `scope` value. `SettingsService` then resolves a layer with a positional + `rows.find(...)` and `set()` upserts against `{ namespace, key, scope, user_id }`, + so which value an organization got for a tenant-scoped key was unspecified and + two rows could disagree indefinitely with no way for an admin to see why the + effective value was not the one they set. `lifecycle.retention_overrides` is a + live tenant-scoped key, so this reached real retention behaviour. + + The fix follows the paradigm that has shipped twice in this package + (`ensureOverlayIndex`, `ensureViewDefinitionActiveIndex`): at `kernel:ready` the + declared index is rebuilt in raw SQL with both nullable key parts folded — + `COALESCE(organization_id, '__global__')` (ADR-0120 D3's tenant form, unchanged + from what the driver already emits) and `COALESCE(user_id, '')` (the + `ensureOverlayIndex` spelling for a non-tenant nullable discriminator). Storage + is untouched: the row keeps its NULL, only the index folds it. The index reuses + the **declared name**, so the additive `syncDeclaredIndexes` — which skips by + name — never re-imposes the NULL-distinct form on a later boot, and the drift + reconciler leaves it alone because an index carrying a non-tenant expression key + part is not sync-reproducible. + + **⚠️ Operator-visible: this is a TIGHTENING, and on an installation that has + already accumulated duplicate settings rows it will REFUSE to build the index.** + That is the intended behaviour, not a failure mode to work around. Those + duplicates exist precisely because the constraint has been void, and settings + rows are admin-authored configuration, so no row is discarded automatically and + no deterministic keep-one rule is applied. On refusal: + + - **nothing is deleted, rewritten or reordered**, and the boot continues; + - the **previous index stays in place** — the tightening is proved buildable + under a throwaway probe name before the declared name is ever dropped, so the + table never spends a moment with no unique index at all; + - one `error` line names the key that is not enforced, the consequence (duplicate + tenant-scope and global-scope rows can still be created, and `SettingsService` + has no defined answer for which one wins), and ships the **exact query that + lists the offending rows**, so the operator has the list from the boot log + without waiting for `os migrate plan`; + - the migration keeps refusing on every boot until an operator decides which row + survives, then converges on the next restart. + + Two hosts are deliberately quiet rather than degraded: a kernel composed without + the optional `service-settings` has no `sys_setting` table at all, which is + probed for and is a silent no-op; and a MySQL/MariaDB server that rejects + functional key parts keeps the previous index and is told what is not enforced, + the same degradation `SqlDriver.createNullSafeUniqueIndex` already reports for + this class of event. + +- 159e299: Global search (`GET /api/v1/search`) now resolves searchable fields the same way `$search` does, so the ⌘K palette recalls what the list quick-search recalls (#7643) + + `searchAll` built its own filter instead of going through the engine's ADR-0061 `$search` expansion, which made the palette's recall a strict subset of the executor's. It now hands the engine `search: ` per object and lets one expansion resolve the fields and compile the clause. + + What a caller observes changing on `GET /api/v1/search` — both are widenings; no query that returned a hit before returns fewer: + + - **Pinyin/initials recall now works on this endpoint.** Where the deployment provisions the hidden `__search` companion column (`OS_SEARCH_PINYIN_ENABLED`), latin terms are OR-ed against it, so `hnkj` and `huaningkeji` now return the CJK-named record that `POST /api/v1/data/:object/query {"search":"hnkj"}` already returned. Previously: 0 hits. + - **Which columns are scanned now follows the object, not a field flag.** Resolution is the object's declared `searchableFields`, else the auto-default (display/name field plus short-text and enum fields) — the set `searchableFields` documents itself as governing. The endpoint previously scanned only text-typed fields carrying the field-level `searchable: true` flag, falling back to the title field alone, so most objects were searched on one column. Hits from a second column (an email, a description, a select's label) are new. + - Enum (`select`/`status`) columns are now matched by option LABEL, and virtual `formula` fields are excluded, both as on the executor path. + - **The endpoint no longer substring-scans primary keys.** An object whose only text-typed column is `id` — system tables, junction tables, append-only logs — used to fall through to "the first text-typed field" and be queried as `{id: {$icontains: term}}` on every keystroke. Such objects are now skipped, as `$search` already skipped them (#4483). Callers relying on a bare `id` fragment matching through this endpoint will no longer get that hit; query the record by id instead. + + Unchanged: which objects are swept and their opt-outs (`enable.searchable`, `enable.apiEnabled`, the `sys_*` skips), the per-object and overall caps, ordering, RLS/RBAC enforcement, and the response shape. The `$search` executor path itself is untouched. A record matched only through the pinyin companion has no `snippet` — no source column contains the typed term. + + Also corrects the stale case declaration on this path (#7850): the doc comment said "case-insensitive LIKE" while the sentence below it named `$contains`, which #4706 Q2 = A defines as case-**sensitive**. Matching folds case via `$icontains`; behaviour is unchanged by that edit. + +- 75e66fc: Stop `GET /api/v1/meta/:type/:name/diff` serving stored credential values. + + `diffMetaItem` compared two stored metadata bodies and emitted the raw values it + found, so a `datasource` row whose credential rotated between versions returned + both the old and the new password in cleartext (inline `config.password` and the + password component of `config.url` alike). + + The diff is still computed on the RAW bodies — a credential rotation continues to + report its path as changed — but the emitted `value` / `from` / `to` are now taken + from the type's redacted projection of those same bodies, on both sides. Types + with no registered redactor are unaffected and keep serving their values by + reference. + +- a726154: test(metadata-protocol): pin the THIRD union-branch policy copy against `@objectstack/spec` (#8660) + + The union-branch selection policy — kind-mismatch drop, fewest-issues ranking, + `unrecognized_keys` tie-break, declaration-order determinism, depth limit 3, + branch cap 3 — has three implementations. #8318 (PR #8659) consolidated the two + inside `packages/spec` into one package-internal module and pinned them with a + shared-fixture parity test. The third, `zodIssuesToMetadataIssues` in + `protocol.ts` (the walk behind `saveMetaItem`'s `422 INVALID_METADATA` and the + read path's diagnostics), was structurally out of that consolidation's reach: + the shared module is deliberately not a public export (#4001), so a consumer in + another package cannot import it. + + That left this copy exactly where the spec pair sat before #8318 — held in step + by a header comment and nothing else. A future tie-break or ranking tweak lands + in `union-branch-policy.ts` for both spec walks at once and silently not for + this one, and then the same authored metadata gets one prescription from the + terminal, another from the data API, and a third from Studio: the forked verdict + #5014 ruled out. + + `src/union-branch-policy.cross-package-parity.test.ts` is the enforcement the + header stood in for. One fixture corpus, one `safeParse` per fixture, three + walks reached through PUBLIC surfaces only — `formatZodIssue` from + `@objectstack/spec`, `zodIssuesToFields` from `@objectstack/spec/api`, and this + package's own copy — compared as ordered `(path, message)` pairs. The corpus + covers every element of the policy by name, plus a hand-authored expectation per + fixture so both sides drifting the same way still fails. The two deliberate + asymmetries (the prose-only omission line, and raw zod codes here vs the + ADR-0114 catalog on the wire) are asserted in place rather than normalised away. + + **`patch`, deliberately not a skipped changeset.** No production line changes, + no export moves, and every assertion is green on `main` before this lands — but + the bump floor is right rather than absent, for the same reason + `legacy-unique-guard-attribution` took one: what ships is a ratchet on + release-relevant behaviour. The 422 envelope this pins is a published contract + of `@objectstack/metadata-protocol`, and a consumer reading the CHANGELOG should + be able to see when its verdict acquired mechanical protection against drifting + away from the spec's. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [13d7864] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [845e164] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [b849e69] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/lint@17.1.0 + - @objectstack/metadata-core@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/metadata@17.1.0 + - @objectstack/formula@17.1.0 + ## 17.0.0 ### Major Changes @@ -364,7 +1204,7 @@ nonsenseKey: 1 }).success === true`). That is tracked in the #4001 campaign map Related: #4674, #4720, #4363, #4371, #4001, ADR-0049. - + ### Minor Changes @@ -1733,7 +2573,7 @@ NULL`. #7705 proved that narrowing orphans every org-scoped row — the same recover, which is the same disposition `rest-requireauth-default-flip` took for its own default flip. - + - ea90179: fix(data,runtime,drivers): four ADR-0112 envelope defects found in the v17 verification sweep (#4431, #4435, #4436, #4483) diff --git a/packages/metadata-protocol/package.json b/packages/metadata-protocol/package.json index 25f03b01f1..dd5c3c405a 100644 --- a/packages/metadata-protocol/package.json +++ b/packages/metadata-protocol/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata-protocol", - "version": "17.0.0", + "version": "17.1.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 1cd39fc688..6cebd09377 100644 --- a/packages/metadata/CHANGELOG.md +++ b/packages/metadata/CHANGELOG.md @@ -1,5 +1,45 @@ # @objectstack/metadata +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [845e164] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/metadata-core@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/metadata-fs@17.1.0 + ## 17.0.0 ### Major Changes diff --git a/packages/metadata/package.json b/packages/metadata/package.json index 2f5b98aec1..d2e2c3ea2e 100644 --- a/packages/metadata/package.json +++ b/packages/metadata/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/metadata", - "version": "17.0.0", + "version": "17.1.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 370e78c41c..f86598cad2 100644 --- a/packages/objectql/CHANGELOG.md +++ b/packages/objectql/CHANGELOG.md @@ -1,5 +1,234 @@ # @objectstack/objectql +## 17.1.0 + +### Patch Changes + +- ff08691: fix(engine-core): a system-context insert on a tenant-scoped object resolves the install's organization the way a session write does, or is refused — the runtime producer of the autonumber fork #8686's backfill cannot reach (#8844) + + + + #8686 fixed **one** producer of untenanted rows — the seed loader — and shipped + a one-shot backfill for what it had already written. This card is the **other + producer, which is still running**: an ordinary application write made under a + system execution context (a hook, a scheduled job, a custom endpoint, a + `runAs: system` flow). A backfill cannot reach it, because it mints a fresh + duplicate on every tick — which makes #8686's repair **self-undoing on any + install with server-side automation**, i.e. every business app. + + **Measured on 17.0.0 GA**, a single-tenant EHR/MES install with ~44 autonumbered + objects: two records, same object, same install, the **same** value on a field + the app declared `unique`, with no error and no warning. The `notification` case + shows both producers side by side — `NT-00002 .. NT-00011` each existing twice, + copy A written by the "maintenance overdue" cron job, copy B by a user action. + + **Mechanism.** A session write carries the caller's active organization, the SQL + driver stamps it onto the row (`injectTenantOnInsert`), and the autonumber + counter reads it back off the row (`fillAutoNumberFields`, resolving + `row[tenantField] ?? options.tenantId ?? null`). A system-context write carries + none, so the column lands `NULL` and the counter files the row under the + `__global__` pseudo-tenant. One object then runs two counters that cannot see + each other, each correct within its own scope, and the partitioned unique index + — `(COALESCE(organization_id, '__global__'), )`, ADR-0120 D3 — cannot see + across the two partitions either. + + ⛔ **Not a counter bug**, and not fixed by making the allocator smarter: both + counters are already correct within their own scopes (the reasoning #8686 + recorded, unchanged). The defect is upstream of the counter. + + **The fix, per the 2026-08-15 maintainer ruling (Option 1)** — a system-context + write resolves the install's organization the way a session write does, at the + engine's stamp resolution, so every driver is covered at the source (which + matters here because `fillAutoNumberFields` is duplicated in `driver-sql` and + `driver-turso`; neither driver changed): + + - **Single-tenant, exactly one organization ⇒ derive and stamp.** The + `__global__` fork stops being minted by hooks, cron and system endpoints. + - **Multi-organization ⇒ carry an explicit organization or be REFUSED LOUDLY**, + never silently defaulted. A walled posture (`group` / `isolated`), or a + `single` posture whose data holds several organizations, has no derivable + answer — the refusal is `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (500, + registered in the ADR-0112 ledger), thrown before anything reaches the driver, + and its message names the condition, what would otherwise have been written, + and both remedies. + - **Already-minted duplicates are reported, never rewritten** — the #8686 + posture, ruled again here. Nothing in this change renumbers anything. + + **Three populations are outside the rule by construction, not by exemption**, so + that the refusal cannot break unattended automation that was never at risk: + objects with no organization column, objects declaring `tenancy: { enabled: +false }` (ADR-0066 — the _declared_ way to hold org-less rows, rather than a + per-write bypass flag) and federated objects (ADR-0015); the platform namespaces + `sys_` / `cloud_` / `ai_`, whose rows are deliberately global (#8672's reasoning, + which this ruling confirms holds for platform objects and does **not** generalize + to application objects); and any write that already carries an organization — on + the execution context, on the record, or stamped by a `beforeInsert` hook. + + **First boot is untouched:** before any organization exists there is nothing to + derive and no second partition to fork away from, so those rows still land + org-less for #8686's `sys_organization`-insert handoff to adopt. + + Scoped to **insert**, deliberately: the ruling's yardstick is "the way a session + write does", and stamping the organization is an insert-side mechanism — an + update neither stamps it nor can fork a counter. + +- 402c125: fix(objectql): a temporal filter comparand the platform cannot interpret is refused at the engine door instead of answering 200 with zero rows (#8690) + + + + A `datetime` / `date` / `time` field filtered with a bare string the platform + cannot read — `last_30_days`, `not-a-date-at-all` — was bound **as written** + all the way to the driver, where the comparison is false for every row. The + caller received `HTTP 200`, an empty result set, and nothing to indicate the + filter was meaningless. An unknown `{placeholder}` in the same position was + already refused loudly (`FILTER_TOKEN_UNKNOWN` / 400, listing the resolvable + tokens), so one API answered two shapes of unusable comparand two different + ways. + + It is concretely reachable rather than theoretical: `last_7_days` / + `last_30_days` / `last_90_days` are **declared preset names** in the dashboard + schema. The shipped console lowers them to `{N_days_ago}` macros before they + reach the API, so the console path was always safe — but a saved report, an + integration, an MCP client or an AI-authored query sends the preset name itself + and got a silent zero. An empty chart is the hardest failure to debug: it is + indistinguishable from "there is genuinely no data". + + Such a comparand is now refused at the ObjectQL engine's single filter + collection point, with `code: 'INVALID_FILTER'` and `status: 400`, naming the + field, the value, the key path and the spellings that would work. That seam is + the one place holding the caller's comparand and the field's **declared type** + at the same moment, and every verb (`find` / `findOne` / `count` / `aggregate` + / `update` / `delete`) and both filter spellings (the array sugar and the + lowered condition) pass through it, so all four backends inherit one answer + rather than four. `NativeSQLStrategy` additionally **declines** such a query so + the raw-SQL analytics path falls through to that door instead of binding the + value into its own statement. + + Deliberately unchanged, each by ruling: a `{placeholder}` keeps its existing + refusal one layer down (the door runs before token resolution and steps around + them, so `{30_days_ago}` still resolves normally); non-string comparands are + untouched (a number is epoch milliseconds, a `Date` is an instant); and the + **empty string** keeps today's behaviour exactly — it binds as `''` and matches + every non-null row, which is a separate question that remains its own card. + +- 7c2f386: fix(objectql): the tenant-scope index follows the WALL's derivation, so an object that opts out with `systemFields: false` while declaring its own `organization_id` stops running the wall predicate unindexed (#8608) + + + + Two places answered _"is this object tenant-scoped?"_ and read different + declarations. The platform's tenant-scope index was gated on the spec's + **injection plan** (`resolveInjectedSystemColumns(...).tenant`), while + plugin-security's Layer 0 wall derives `tenancyDisabled` from exactly two + clauses: + + ```ts + tenancy.enabled === false || systemFields.tenant === false; + ``` + + `systemFields: false` — the hard object-level opt-out — is in the plan and in + neither of those clauses. So an object using that opt-out **while declaring its + own `organization_id`** had `organization_id = ` AND-composed onto + essentially every read, with no index behind it: the deployment's hottest + predicate, unindexed. Not a security hole — isolation still held; it was slow, + not wrong, which is why nothing surfaced it. + + **Both halves were measured end to end** rather than read off the source. On the + pre-fix tree, for one such object, the registry answered `indexes: null` while + `SecurityPlugin#getReadFilter` answered `{ organization_id: 'org-1' }` for an + ordinary member. + + The wall's derivation is authoritative and the index now follows it: the index + is declared when tenancy is not disabled by the wall's two clauses **and** the + object carries `organization_id` — whether the platform provisions the column or + the author declared it. `managedBy: 'better-auth'` is deliberately not re-added + as a third clause, because the wall does not read it either; the one shipped + platform object whose answer changes is `sys_member`, which is walled on + `organization_id` and whose only tenant-leading index was the composite + `['organization_id', 'user_id']`. + + Unchanged, and pinned beside the fix: `systemFields.tenant: false` and + `tenancy.enabled: false` still declare no index (the wall composes no predicate + there, so an index would serve nothing), a single-tenant deployment still + declares none at all, an author's own tenant index still suppresses the + platform's, and the hard opt-out still injects no platform columns — only the + index decision was ever owed at that exit. + +- 8a9e7f4: Refuse undeclared fields on insert at the schema, and keep bound values out of the write-path logs (#8682) + + **A single mistyped field name in a client request no longer writes an entire row's values to disk.** A driver-level write fault is logged by prefixing the fully bound SQL statement — values inlined — to the database's own message, and the logger serializes both `message` and `stack`, so the statement was written twice at ERROR level. Confirmed with planted canaries: the row's values landed in the log alongside the organization id and the acting user id. The insert, update and delete loggers now write the database's own diagnostic — which still names the failing column and the object — with the statement and its bound values cut from both fields. The level, the message and the entry itself are unchanged: a driver fault nobody can debug would be a worse outcome than one logged too loudly. + + **An undeclared field is now refused by the object's field map, before anything runs for a request that was already going to be refused.** Previously an unknown key was caught only at the very end, by the driver, after an id, an auto-number, a normalized name, owner/creator resolution, the column defaults and the app's `beforeInsert` hooks had all been produced for it. The auto-number was the durable damage: the refused request consumed a sequence value and left a permanent gap in a document number an end user reads. `insertMany` now culls such a row per row instead of letting it fail the whole batch. + + The client-facing answer is deliberately unchanged — the same `400 INVALID_FIELD`, with the same message and the same `field` / `object` — and the rethrown error is untouched, so only what reaches the log has moved. Objects whose field map is absent or empty get no verdict at all, and `id` / `created_at` / `updated_at` stay accepted even when a declaration omits them, matching what the read path already tolerates; in every one of those cases the driver remains the backstop it has always been. + +- 3d0ded8: Refuse undeclared fields on update at the schema, before the `beforeUpdate` hooks run (#8738) + + **An undeclared key on `engine.update(...)` is now refused by the object's field map, before anything runs for a request that was already going to be refused.** Previously it travelled the whole update path and was refused at the very end by the driver — measured on both branches of the verb: `driver.update` on the by-id path and `driver.updateMany` on the predicate path each received the mistyped key. The `beforeUpdate` hooks ran first, so a hook that stamps a ledger, calls out, or derives a field executed for a write that was then rejected; in the reproduction the hook's derived value travelled into the statement the driver refused. + + **What a caller observes changing.** The refusal itself does not move: an undeclared update key was already rejected, and the client-facing answer is deliberately unchanged — the same `400 INVALID_FIELD` with the same message, `field` and `object`, which `@objectstack/rest` re-emits verbatim. What changes is where the refusal is decided, and therefore what the error carries **inside the process**: an in-process caller of `ObjectQL.update()` that caught the old failure saw the driver's raw error (no `code`, no `status`, its message containing the bound SQL statement) and now sees the ADR-0112 envelope (`code: 'INVALID_FIELD'`, `status: 400`) with a message naming the field. An in-process caller matching on the driver's SQL text — rather than on the envelope — is the one shape that has to change. The write no longer costs a driver round-trip either: the pre-update read is skipped along with the hooks. + + The door is the same one `insert()` has carried since #8682 — one condition, one implementation, now with two callers — including its three deliberate no-opinion cases, which are unchanged and reused rather than re-derived: an absent field map, a field map the door sees as empty, and `id` / `created_at` / `updated_at` when a declaration omits them. Schema drift (a declared field whose physical column is missing) stays the driver's to refuse, as before. Nothing is widened; `declared = enforced` (Prime Directive #10) is restored on the second write verb. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [5047cb8] +- Updated dependencies [13d7864] +- Updated dependencies [f57fb38] +- Updated dependencies [177442d] +- Updated dependencies [950bd94] +- Updated dependencies [3043e98] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [fd6bdf8] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [ead96d0] +- Updated dependencies [b69d0f5] +- Updated dependencies [c15eb23] +- Updated dependencies [4d47afe] +- Updated dependencies [845e164] +- Updated dependencies [8d017eb] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [88ef34d] +- Updated dependencies [add2d19] +- Updated dependencies [2b9d33a] +- Updated dependencies [0f59584] +- Updated dependencies [ff08691] +- Updated dependencies [159e299] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [75e66fc] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [a726154] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/metadata-protocol@17.1.0 + - @objectstack/metadata-core@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/metadata@17.1.0 + - @objectstack/formula@17.1.0 + ## 17.0.0 ### Major Changes @@ -1010,8 +1239,8 @@ init(ctx) { … } })`; the engine is `ctx.getService('objectql')`, drivers being a hand-listed union and a bare `string` respectively and reference the catalog, so the three cannot drift apart. - Lowercase is deliberate, not an oversight against ADR-0112's SCREAMING_SNAKE: a - top-level code names the condition the _request_ hit, while a field-level code + Lowercase is deliberate, not an oversight against ADR-0112's SCREAMING*SNAKE: a + top-level code names the condition the \_request* hit, while a field-level code names the _constraint_ the value violated — and constraints are declared in the metadata's own snake_case, so `max_length` the code and `max_length: 50` the property are the same word on purpose. @@ -1198,7 +1427,7 @@ vocabulary − this`), which is what stops the next aggregate added to the spec is untouched; it is simply no longer reachable through a spec-valid request. On the dataset path nothing changes: `compileDataset` refused both by name already. - + - 20b1a9e: fix(data): the audit anchor is engine-owned, and a lookup must resolve (#4447, #4441) @@ -3629,7 +3858,7 @@ the write landed anyway. On the reference app that meant one`PATCH` rewrote the `lower()` folds ASCII only, which IS the contract (#4706 Q1 = A): `$icontains: 'café'` does not match `CAFÉ`. - + `driver-mongodb`'s unknown-operator arm was throwing a bare `Error` with no `code` and no `status`, three lines from the helper in its own file that sets @@ -11251,7 +11480,7 @@ vocabulary − this`), which is what stops the next aggregate added to the spec is untouched; it is simply no longer reachable through a spec-valid request. On the dataset path nothing changes: `compileDataset` refused both by name already. - + - 3028326: fix(metadata-protocol,objectql): the #4463 runtime authoring gate now runs on every kernel that has not declared itself the package author's channel (#6710) @@ -11781,7 +12010,7 @@ vocabulary − this`), which is what stops the next aggregate added to the spec `lower()` folds ASCII only, which IS the contract (#4706 Q1 = A): `$icontains: 'café'` does not match `CAFÉ`. - + `driver-mongodb`'s unknown-operator arm was throwing a bare `Error` with no `code` and no `status`, three lines from the helper in its own file that sets diff --git a/packages/objectql/package.json b/packages/objectql/package.json index f81cd3b8ea..7d1eebb0eb 100644 --- a/packages/objectql/package.json +++ b/packages/objectql/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/objectql", - "version": "17.0.0", + "version": "17.1.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 ffc9b232f0..6aab35c27b 100644 --- a/packages/observability/CHANGELOG.md +++ b/packages/observability/CHANGELOG.md @@ -1,5 +1,25 @@ # @objectstack/observability +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [f57fb38] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/observability/package.json b/packages/observability/package.json index 768465b434..39703b12eb 100644 --- a/packages/observability/package.json +++ b/packages/observability/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/observability", - "version": "17.0.0", + "version": "17.1.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 d8607f3864..3856f4903e 100644 --- a/packages/platform-objects/CHANGELOG.md +++ b/packages/platform-objects/CHANGELOG.md @@ -1,5 +1,419 @@ # @objectstack/platform-objects +## 17.1.0 + +### Minor Changes + +- e43d63a: feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287) + + + + On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could + read **nothing at all**. `sys_api_key` carried no organization column, so key + authentication established a user but no active organization — and the + `isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with + no active organization matches no row. Every organization-scoped read answered + `200` with `total 0` while the console went on offering minting, so a tenant + admin could mint a valid-looking secret and discover only at call time that it + read nothing. (There was no cross-tenant leak — the failure was in the other + direction.) + + **The column was absent by an inherited rule, not by oversight.** + `resolveInjectedSystemColumns` injects `organization_id` into every registered + object _except_ `managedBy: 'better-auth'` ones, and `sys_api_key` carries that + flag — even though better-auth's `apiKey` plugin is not loaded and the table is + hand-rolled ObjectStack. So the fix needs the declaration _and_ the ADR-0105 D7 + extension-field registration to stay consistent. The read side, by contrast, + was **already wired**: `resolveApiKeyPrincipal` already read an organization + into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a + column no mint path ever wrote. + + **What changes** + + - `sys_api_key` declares `active_organization_id` (+ index, and the column is + shown in the "My Keys" and "All" list views, because the card's complaint was + a credential whose reach its owner could not see). + - `POST /api/v1/keys` **inherits** the caller's active organization — there is + deliberately no org parameter and no cross-org key — and **re-checks the + caller's `sys_member` membership at mint time**, honouring ADR-0091 validity + windows. Under a walled posture it refuses (400) rather than minting a key + with no organization, and refuses (403) for an organization the caller is not + a member of. The mint response echoes the organization the key is pinned to. + - The verifier reads **one spelling** (Prime Directive #12): the + `row.organization_id ?? row.organizationId` chain it used to carry was a + consumer-side tolerance for a producer that did not exist. + - An **ex-member's key fails closed at verify time** — no principal, not a + degrade to a user-only principal, which would resurrect the same + `200 + total 0` silent-empty. Checked at verify rather than by revoking on + membership loss, because membership ends through many paths (better-auth org + endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and + a hook must catch every one or it silently misses. It costs **zero extra + queries**: the resolver has already read `sys_member` for this user. + - **Pre-existing org-less keys are never backfilled** — that would silently + upgrade credentials minted under a different promise. They keep working under + `single` (no wall) and under `group` (whose wall derives from the owner's + memberships independently of the active organization, so they already work + there), and are **refused under `isolated`**, where they are provably dead + today. + + **The column is deliberately named `active_organization_id`, not + `organization_id`** — the `sys_session` spelling, for the same concept: the + organization a credential makes _active_. `objectHasOrgIdField` tests for the + literal `organization_id`, and Layer 0 exempts objects without it, so the other + name would have made `sys_api_key` itself org-walled. Both walled postures + exclude NULL, so every pre-existing org-less row would have vanished from its + **own owner's** "My Keys" list while, under `group`, continuing to + authenticate — a live credential nobody could see or revoke, which is a fresh + instance of the very class this change removes. + +- 6158146: Stop serving custom email headers through the generic data-API read of `sys_email` (#8149). + + **What this closes.** `sys_email.headers_json` — the custom headers handed to `IEmailService.send`, the ordinary place a relay credential or provider token goes — was readable by every caller the data API admits (list, get, an explicit `?select=headers_json`). The column is now declared `internal: true`, so the engine omits it from every generic read with no system carve-out (#7728); `SYSTEM_CTX` does not reopen it either. This is the same shape #8118 ruled on for `sys_http_delivery.headers_json`: this change adopts that remedy rather than deciding it a second time. + + **Delivery is unaffected, and fail-closed.** `sys_email` is not delivered from the in-memory message but FROM THE ROW: the after-insert outbox drain hook, the `email.send.async` queue subscriber and the boot outbox sweep all re-read the row and hand it to `EmailService.deliverPersistedRow`. All three read through `engine.find`, which is exactly what the flag empties — so the recovery ships with the flag. `deliverPersistedRow` now recovers the column through ObjectQL's privileged accessor (`resolveInternalField`, consumed unchanged) and sends every authored header verbatim. A message whose headers cannot be recovered is NOT sent without them: a missing header is not self-announcing — a relay that does not require it accepts the mail while the delivery silently deviates from the authored configuration. That case throws and leaves the row `queued`, not `failed`, so the queue retry or the next boot's sweep delivers it intact. + + **New optional seam.** `EmailPersistence.readHeadersJson(rowIds)` — the readback the plugin wires off the raw engine. It probes the OBJECT SCHEMA flag, never the absence of the key from a result row: `headers_json` is `required: false` and most real rows carry no custom headers at all, so a key-absence inference would treat every ordinary email as redacted (the regression measured on `sys_account`'s optional token columns in #7987/PR #8675). Engines that do not redact are left untouched and trigger no privileged read. + + **What this deliberately does NOT close.** The row still holds the header map in cleartext at rest. Encrypting it (`Field.secret()`) was measured and rejected on #8118 — an orphan `sys_secret` row per message with no cascade or retention, a boot-window fail-open, and a per-row decrypt on every delivery — and this change adopts that ruling unchanged. + +### Patch Changes + +- c9f5950: fix(security): `sys_account`'s OAuth access/refresh/id tokens stop serializing on the data API — `internal: true`, with better-auth's readback seam widened to cover them (#7987) + + + + `sys_account.access_token`, `.refresh_token` and `.id_token` hold each user's + **live third-party OAuth credentials** — the tokens ObjectStack received from + Google, GitHub or an OIDC IdP — in cleartext (better-auth's + `account.encryptOAuthTokens` is not set, so `setTokenUtil` stores them + verbatim). They were plain `Field.textarea` on an object declaring + `apiEnabled: true, apiMethods: ['get','list']`. + + **Both personas were measured leaking, on a real booted stack** (`bootStack(showcaseStack)`, + in-process HTTP + sqlite-wasm), with a planted token on a member's account row: + + - **admin**, `GET /data/sys_account/{another user's account id}` — 200, that + member's `refresh_token` verbatim, plus `access_token` and `id_token`; + - **member**, `GET /data/sys_account` (self-scoped by the `sys_account_self` RLS + policy) — 200, their **own** `refresh_token` verbatim. + + The member arm is the one this object does not share with its `sys_session` + sibling (#7823), and it is the sharper of the two: it converts a short-lived, + revocable ObjectStack session bearer into a **long-lived third-party refresh + token that this platform cannot revoke at all**. Neither collector reached these + columns — the engine's credential mask collects by field TYPE (`textarea` is + neither `secret` nor `password`) _and_ exempts objects with + `managedBy: 'better-auth'`, which this object is. + + **The fix is three declarations plus one widening**, inheriting #7823's shape + rather than inventing a second mechanism: + + - the three columns are declared `internal: true` — the opt-in, type-independent + flag minted by #7728 meaning _the declared value is never returned on the + generic data path_. Storage, filtering and indexing are untouched: the strip + runs on rows the driver has already produced. + - better-auth **reads these back off adapter result rows** — measured, and the + risk this card was parked on: `internalAdapter.findAccounts(userId)` issues a + `findMany` with no projection, and `/get-access-token`, `/account-info` and + `/refresh-token` then read `account.refreshToken` / `.accessToken` / + `.idToken` off those rows. The read strip alone would answer + `REFRESH_TOKEN_NOT_FOUND` (400) and hand back an empty access token. So the + existing readback seam in `@objectstack/plugin-auth` — which already recovered + `sys_session.token` through `Engine.resolveInternalField` (#8118's privileged + batch accessor) — is widened to cover these three columns and renamed + accordingly. No engine carve-out, no second accessor. + + **Not retyped, deliberately.** `Field.secret()` would route better-auth's own + writes through the engine's encrypt-on-write path, placing the engine between + better-auth and its own adapter. `Field.password()` is inert here for the two + reasons above. + + **`password` / `previous_password_hashes` are deliberately out of scope** — + they are better-auth one-way hashes (ADR-0100's third channel), not reversible + outbound credentials, and the readback seam refuses to touch them. + + The regression proof drives both directions: the fixture PLANTS real token + values and re-reads them out of storage through the privileged accessor before + asserting anything (so "absent from the response" cannot pass vacuously), then + pins that the values are still on disk, still usable as a server-side predicate, + and that password sign-in — which reads a `sys_account` row back through the + same seam on every request — still works. + +- 04f8fdb: fix(platform-objects): drop the dead `mapId` ("Map: User ID claim") param from `register_sso_provider` — the OIDC subject claim is not configurable (#8222) + + + + The `register_sso_provider` action on `sys_sso_provider` offered an optional + **"Map: User ID claim"** text field (`mapId`), with helpText reading _"Optional. + ID-token claim mapped to the user ID. Defaults to `sub`."_ + + **That capability no longer exists.** It was retired upstream in + `@better-auth/sso@1.7.0-rc.2`: + + - `oidcConfig.mapping` is a `z.strictObject` whose members are + `{ email, emailVerified?, name, image?, extraFields? }` — there is no `id`; + - the federated subject is hard-wired to the OIDC `sub` claim + (`id: readStringClaim(rawUserInfo, "sub")` and `id: idToken.sub`), then + cross-checked (`id_token_subject_missing`, + `id_token_userinfo_subject_mismatch`); + - `extraFields` is not an escape hatch — it is spread **before** `id` in the + profile literal, so an `extraFields.id` is overwritten by `sub` before anything + reads it. + + `1.6.20` did honour `mapping.id` (`id: rawUserInfo[mapping.id || "sub"]`); the + version bump deleted the member. + + So the field's only accepted values were "empty" and the `sub` it already + defaulted to. #8193 (PR #8221) stopped the bridge emitting the retired key and — + rather than accept a value it would silently discard — made a non-`sub` value + answer `INVALID_REQUEST`. That left the last half of the problem: **the form + still advertised a free-form optional field that 400s on anything meaningful.** + Removing it restores declared = enforced. Nothing else about registration moves: + the runtime accept set is unchanged, and a registration that never sent `mapId` + behaves exactly as before. + + `mapEmail` and `mapName` are untouched — they map to live `oidcMappingSchema` + members and are still honoured. + + **The bridge-side guard in `plugin-auth`'s `register-sso-provider.ts` is kept**, + and its refusal test with it. The admin form was only one caller: a direct API + client, a script, or a stale cached console bundle can still put `mapId` on the + wire, and telling those callers plainly still beats discarding the value in + silence. Only the guard's doc comment changed, to stop describing `mapId` as a + field the form sends. + + The generated translation bundles (`*.objects.generated.ts`, all four locales) + were **regenerated**, not hand-edited, so the retired label disappears from every + locale rather than lingering as a stale entry. + +- 84cb121: State `sys_job`'s uniqueness boundary explicitly: `unique: 'global'` on the declared `(name)` index, and correct the `name` field's description (#8578) + + The declared index carried the bare `unique: true` spelling, which ADR-0120 D1 defines as the deprecated positional spelling of `'global'` — the listed columns verbatim. Because `sys_job` also carries a kernel-injected `organization_id`, the tenancy sweep could not tell that shape apart from the #8323 cross-tenant-oracle class, and the field's description published a boundary-free "Unique job identifier" claim that left the question open in the generated reference. + + The reading settles it in the `'global'` direction: nothing writes `sys_job` per organization. `DbJobAdapter` is the sole writer and upserts under a SYSTEM context, locating rows by `where: { name }` with no organization dimension; the `job` metadata type is closed to tenants on all three flags (`allowOrgOverride: false` — "no per-org job fork" — plus `allowRuntimeCreate: false` and `supportsOverlay: false`); `enable.apiMethods` advertises no write verb at all (ADR-0103 engine-owned); and every `schedule()` call site is registration-time and installation-scoped. ADR-0120's own S5 inventory already names `sys_job.name` as one of the nine engine idempotency keys that are platform-wide by construction. + + No migration and no drift: `'global'` **is** the semantics bare `true` already materialized, so the physical index is byte-identical (ADR-0120 D2). What changes is that the boundary is stated rather than inferred from position, and that the published description names it. The reading itself is pinned — the new test asserts the write paths that would have to open for the opposite verdict to become true, so a future per-organization job path fails loudly instead of silently invalidating the constraint. + +- a675b4d: fix(platform-objects): the System Overview by-action table serves its declared title again, and the default locale bundle is now pinned to the source string (#8721) + + `widget_recent_events` was converted into an ADR-0021 single-form — a + dataset-bound breakdown of `sys_audit_log` events by action — but all four + hand-authored locale bundles kept serving the title the widget had _before_ the + conversion (`Recent Audit Events` / `最近审计事件` / `最近の監査イベント` / + `Eventos de Auditoría Recientes`). The translation is what renders, so the + declared string reached nobody in any locale. Its `description` had drifted the + same way and in the same direction, one field over. + + **The duplicate the stale translation was hiding.** With the source string + restored, the board carried the same label twice: `widget_events_by_type` (a + pie) and `widget_recent_events` (a table) both declared `Audit Events by +Action`, over the same dataset and the same dimension. They looked distinct in a + running instance only because one of them was serving a stale translation. The + pair now splits on what each adds — the pie keeps `Audit Events by Action` (the + share picture), the table becomes **`Event Volume by Action`** (the exact + per-action count, which is what its `values: ['event_count']` produces and what + its description already said). All four locales are translated to the new + strings; the widget **ids are unchanged**, so no translation key, persisted + widget state or dataset binding moves. + + **Why nothing caught it, and what now does.** This package's `apps` / + `dashboards` / `pages` i18n is hand-authored and cannot be regenerated — + regenerating would delete ~40 runtime-contributed nav translations per locale — + so it never had the source-tracking the generated half gets from the extractor. + Every gate over it made a **key-set** claim (`app-nav-translation-parity.test.ts` + asserts a translation exists and does not outlive its declaration; + `check:i18n-coverage` ratchets _untranslated_ labels; `check:app-nav-i18n` judges + the merged nav tree), and a key whose value is stale satisfies all of them. + + `app-nav-translation-parity.test.ts` now also asserts the **default locale's + content**: every statically declared app label, description and nav label, plus + the dashboard's label, description and every widget title/description, must + appear in `en.ts` **verbatim**. That claim is available for `en` alone because + `en` is a copy of the source rather than a translation of it — the same + invariant the generated half already enforces by rewriting its `en` bundle on + every extract. What a _translated_ locale should do when its source string + changes is a separate product decision and is deliberately not decided here. + +- b887013: fix(platform-objects): remove the System Overview board's permanently-empty "Permission Changes" tile (#8148, #7675) + + + + The System Overview dashboard shipped a "Permission Changes" metric tile + filtering `sys_audit_log.action = 'permission_change'`. **The tile could never + report anything but `0`, on any deployment that has ever existed** — the value + had no writer anywhere in the repo. There are exactly two `sys_audit_log` + writers: `plugin-audit`'s generic hook writer, whose `actionFor` maps + afterInsert/afterUpdate/afterDelete to `create`/`update`/`delete` and nothing + else, and `plugin-auth`'s admin user-import. Neither has ever emitted + `permission_change`. #8147 then retired the value from the action enum outright, + so the tile's filter now names a value the platform does not even declare. + + **An empty tile on a compliance surface is worse than a missing one.** A + permanently-`0` "Permission Changes" count does not read as "this platform does + not track permission changes" — it reads as a _negative finding_: an auditor + concludes the platform watched for permission changes over the selected window + and found none. The number was live and the query was real; the question it + answered was one no row could ever be an answer to. 审计面宁窄勿谎 — a narrow + audit surface beats a lying one. + + **Removed rather than refiltered onto a live action.** Permission and role edits + _are_ captured today, as ordinary `create` / `update` rows written by the generic + hook against the permission objects — so the honest lens on them is `object_name` + on the audit list view, a row-level question rather than a single-number KPI. + Approximating one as a tile would have put a second not-quite-true number on the + same board. The two surviving Row 2 tiles ("Login Events", "Config Changes") + split the 12-column row in half instead of leaving a gap where the removed tile + sat. + + The by-action tile's description stops naming `permission` among its example + actions, in the source **and in all four locale bundles** — the translations are + the strings actually served, so correcting only the source would not have reached + a single user. + + ⚠️ **`import` is deliberately untouched.** It was named in the same ruling as + `permission_change`, but its retirement premise was falsified during #8147: it + has a live writer (`plugin-auth`'s admin user-import writes a run-level row) and + a shipped list view that filters it. Removing it from the dashboard while the + platform still emits it would produce the exact inverse defect — an audit action + that can be written but cannot be found. + + Both directions are pinned. A tombstone refuses any board widget filtering a + retired action value, with a live-action control so it cannot pass on a board + that has no widgets or whose predicates moved. The app/dashboard translation + parity test gains the **reverse direction it was missing** for dashboard widgets + — it asserted every declared widget has a translation, but nothing stopped a + translation outliving its widget, which is precisely what these four locale + entries would have done. + +- 7901b2d: feat(spec): stamp-only `tenancy.organizationField` — audit rows can follow the record's organization on objects that must stay unwalled (#8778, closes the #8707 remainder) + + The platform had one answer to "what is this object WALLED by" + (`tenancy.tenantField`) and no answer to "which column says who this row is + ABOUT". For ordinary objects the two coincide; for credential tables they + deliberately do not — `sys_api_key` records the organization a key + authenticates into under `active_organization_id` precisely so the credential + table is not org-walled (#8287). #8777's schema-resolved audit stamping could + therefore reach every shipped object except the one that motivated it, and + revocation rows on `sys_api_key` kept stamping the revoker's organization. + + `TenancyConfigSchema` now accepts an optional `organizationField` — a + READ-NEUTRAL, STAMP-ONLY declaration (maintainer-ruled option A on #8778): + + - The audit writer's `resolveRecordOrganizationField` consults it first, ahead + of the ADR-0066 `enabled: false` opt-out — an author declaring it on an + unwalled object is stating exactly that the audit trail should follow the + record's own organization even though no wall does. It is honoured only when + the object really has the field (the #5315 guard `tenantField` carries). + - No read path reads it: `applyTenantScope`, `injectTenantOnInsert`, + `computeTenantLayer0Filter` and `resolveInjectedSystemColumns` are all + measured blind to it, and that read-neutrality is pinned by tests beside + each. Declaring it never walls an object and never hides rows. + - ⛔ Scope pin from the ruling: this is ONE stamp-only key, not the opening + move of a general field-roles mechanism. A consumer other than audit + stamping needs its own ruling before reading it. + + `sys_api_key` now declares + `tenancy: { enabled: false, organizationField: 'active_organization_id' }`, + so revoking another user's key from a different active organization lands the + audit row behind the wall of the KEY's organization — where the tenant admin + who can act on it reads it. The `enabled: false` is measured + behavior-identical to the previous absent block for this object on every read + path (injection bails on `managedBy: 'better-auth'` first; the SQL driver's + tenant field resolves null either way; Layer 0 is exempt either way; the + memory/mongo boot guards count only an explicit `enabled: true`). + +- b3f9831: fix(platform-objects): a translated Setup/Studio/Account label whose source string has been edited underneath it now serves the source text instead of the stale translation (#8765) + + The `apps` / `dashboards` / `pages` half of this package's i18n is hand-authored + per locale. Every gate over it judges **presence or ownership** — + `app-nav-translation-parity.test.ts` (a translation exists for every declared + id, and none outlives its declaration), `check:i18n-coverage` (ratchets + _untranslated_ labels), `check:app-nav-i18n` (a label per locale on the merged + nav tree). A translated value that has gone **stale** satisfies every one of + them: it is present, it is owned, it is not untranslated. + + So a source-string edit left `zh-CN` / `ja-JP` / `es-ES` serving the previous + translation indefinitely, under a fully green build — which is how + `widget_recent_events` shipped its pre-conversion title in all four locales. + Pinning `en` to the declared source did not create that drift, but it removed + the one accidental symptom that made it visible: the drift stopped being + uniform across four bundles and became locale-specific, invisible to every + reviewer who reads the product in English. + + **Ruled Option B** (#8765): record the source hash at translation time; a hash + mismatch marks the translation stale, and stale falls back to the source text. + + - Each translated locale ships a `.source-hashes.ts` table recording, + per leaf, the digest of the `en` source string that leaf was translated from. + `setup.translation.ts` compares them against the current source when it + assembles the bundle the kernel is handed. + - **Edit a source string** ⇒ that leaf falls back to the source text in every + locale that had translated it. + - **Update one translation** (its value _and_ its recorded hash) ⇒ **that locale + alone recovers**; the others keep falling back. + - **A leaf with no recorded hash is legacy-trusted**, not stale. The tables were + backfilled once from the then-current source, so no existing translation + degraded when this landed. + + **No new failure mode, and no new gate.** The fallback substitutes the source + string rather than deleting the key, so no key set moves; a translated locale + carrying the source string verbatim is exactly what the extractor already + writes for an untranslated key under `--fill=default`, and exactly what the + resolver's locale chain has always rendered. Staleness degrades what is + _served_ — it never fails a build, which would put a four-locale translation + task in front of every one-word source edit. + + Scope is the hand-authored sections only. `objects` / `metadataForms` are + generated, and the hole cannot occur there: `os i18n extract` rewrites the `en` + bundle from the source on every run and does not merge the default locale, so a + source edit either lands in the generated bundle or fails `check:i18n` as drift. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [f57fb38] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [845e164] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/metadata-core@17.1.0 + ## 17.0.0 ### Major Changes @@ -2866,7 +3280,7 @@ true` there — that is the flag the HITL approval queue reads degrades to "not shown" with no error; removing that badge is a follow-up in that repo. - + - 9aa5510: fix(i18n): ship the missing object-translation keys for the better-auth 1.7 and ADR-0105 D6 fields (#3624 follow-up) diff --git a/packages/platform-objects/package.json b/packages/platform-objects/package.json index bc0c6b5561..cbdca79556 100644 --- a/packages/platform-objects/package.json +++ b/packages/platform-objects/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/platform-objects", - "version": "17.0.0", + "version": "17.1.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 4289455b83..44c3e024c8 100644 --- a/packages/plugins/embedder-openai/CHANGELOG.md +++ b/packages/plugins/embedder-openai/CHANGELOG.md @@ -1,5 +1,25 @@ # @objectstack/embedder-openai +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [f57fb38] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/plugins/embedder-openai/package.json b/packages/plugins/embedder-openai/package.json index 913292be5e..7456b1dd07 100644 --- a/packages/plugins/embedder-openai/package.json +++ b/packages/plugins/embedder-openai/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/embedder-openai", - "version": "17.0.0", + "version": "17.1.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 ca4c4d2456..27eaf5455c 100644 --- a/packages/plugins/knowledge-memory/CHANGELOG.md +++ b/packages/plugins/knowledge-memory/CHANGELOG.md @@ -1,5 +1,32 @@ # @objectstack/knowledge-memory +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/service-knowledge@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/plugins/knowledge-memory/package.json b/packages/plugins/knowledge-memory/package.json index 98103946d8..af2a4b1781 100644 --- a/packages/plugins/knowledge-memory/package.json +++ b/packages/plugins/knowledge-memory/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-memory", - "version": "17.0.0", + "version": "17.1.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 9873a85cad..3ea98eeb82 100644 --- a/packages/plugins/knowledge-ragflow/CHANGELOG.md +++ b/packages/plugins/knowledge-ragflow/CHANGELOG.md @@ -1,5 +1,32 @@ # @objectstack/knowledge-ragflow +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/service-knowledge@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/plugins/knowledge-ragflow/package.json b/packages/plugins/knowledge-ragflow/package.json index b72ca4960f..b5b322b27b 100644 --- a/packages/plugins/knowledge-ragflow/package.json +++ b/packages/plugins/knowledge-ragflow/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/knowledge-ragflow", - "version": "17.0.0", + "version": "17.1.0", "license": "Apache-2.0", "description": "RAGFlow knowledge adapter for ObjectStack — production-grade RAG via the Apache 2.0 RAGFlow REST API.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-approvals/CHANGELOG.md b/packages/plugins/plugin-approvals/CHANGELOG.md index 5c94ec5915..42e54ed415 100644 --- a/packages/plugins/plugin-approvals/CHANGELOG.md +++ b/packages/plugins/plugin-approvals/CHANGELOG.md @@ -1,5 +1,100 @@ # @objectstack/plugin-approvals +## 17.1.0 + +### Minor Changes + +- 08d6d3c: feat(approvals): read-only approval visibility for users who can read the target record, per object, default OFF (#8652) + + A new `ApprovalsPluginOptions.recordReaderVisibleObjects` names the objects on + which **a user who can READ a business record may also see that record's + approval requests and full action history** — read-only. Omitted or empty (the + default) leaves visibility exactly as it is today, so an existing deployment + sees no behaviour change on upgrade. **This is not a no-op change**: on an + object you list, a population that could previously see nothing gains a real + read. + + ```ts + new ApprovalsServicePlugin({ recordReaderVisibleObjects: ["exam_sheet"] }); + ``` + + **Who gains visibility.** Until now the visible set was submitter ∪ current + approver ∪ historical actor, with a platform/tenant admin override as the only + bypass — so a ledger or supervisor role that holds full read on the record but + never appears in the approval itself received `200` with an empty list, and the + Console's approval tab never rendered. On an enabled object, that role now sees + the record's approvals. + + **What becomes visible on an enabled object**, stated plainly because the switch + is an opt-in decision about confidentiality: + + - the approval request row, including its `payload` snapshot of the record as it + stood at submission time; + - the full action history — each actor, their decision, the timestamp, **and the + action's comment text** (意见正文); + - decision attachments on those actions, which are gated on the same rule. + + Enable it on objects whose approval commentary the record's readers are meant to + see; the comment text is often evaluative, and it is per object precisely so + that enabling it for a ledger object does not enable it for anything else. + + **What does NOT change.** + + - **Read-only.** No approval action is delivered through this tier. Approve, + reject, reassign, recall and comment keep authorizing exactly as before — on + the pending-approver slate, the submitter, or admin override — and a viewer + admitted by this tier gets `can_act: false`. Seeing a request confers nothing. + - **No new permission concept.** The tier is anchored on the existing + record-read permission: the service asks the engine to read the record **as + the caller**, so ordinary object CRUD and RLS decide. No new role, grant type + or policy, and no host-injected visibility hook — a security predicate the + platform can neither constrain nor audit was considered and rejected. + - **The inbox.** An untargeted list is unchanged. The rule is anchored on one + record, so it applies only where a record is named — a list filtered by + `object` + `recordId` (what a record page's approval tab sends), or a request + loaded by id. A work queue does not become a browse surface. + - **Tenant isolation, and everything else about the existing visible set.** The + tier only ever adds ids to the participant set; it can never return the "sees + everything" verdict and never relaxes an existing constraint. + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [845e164] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/metadata-core@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/formula@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/plugins/plugin-approvals/package.json b/packages/plugins/plugin-approvals/package.json index 439d6279e8..d523a40e48 100644 --- a/packages/plugins/plugin-approvals/package.json +++ b/packages/plugins/plugin-approvals/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-approvals", - "version": "17.0.0", + "version": "17.1.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 9de69f44c0..b1cdc58dae 100644 --- a/packages/plugins/plugin-audit/CHANGELOG.md +++ b/packages/plugins/plugin-audit/CHANGELOG.md @@ -1,5 +1,124 @@ # @objectstack/plugin-audit +## 17.1.0 + +### Minor Changes + +- 7901b2d: feat(spec): stamp-only `tenancy.organizationField` — audit rows can follow the record's organization on objects that must stay unwalled (#8778, closes the #8707 remainder) + + The platform had one answer to "what is this object WALLED by" + (`tenancy.tenantField`) and no answer to "which column says who this row is + ABOUT". For ordinary objects the two coincide; for credential tables they + deliberately do not — `sys_api_key` records the organization a key + authenticates into under `active_organization_id` precisely so the credential + table is not org-walled (#8287). #8777's schema-resolved audit stamping could + therefore reach every shipped object except the one that motivated it, and + revocation rows on `sys_api_key` kept stamping the revoker's organization. + + `TenancyConfigSchema` now accepts an optional `organizationField` — a + READ-NEUTRAL, STAMP-ONLY declaration (maintainer-ruled option A on #8778): + + - The audit writer's `resolveRecordOrganizationField` consults it first, ahead + of the ADR-0066 `enabled: false` opt-out — an author declaring it on an + unwalled object is stating exactly that the audit trail should follow the + record's own organization even though no wall does. It is honoured only when + the object really has the field (the #5315 guard `tenantField` carries). + - No read path reads it: `applyTenantScope`, `injectTenantOnInsert`, + `computeTenantLayer0Filter` and `resolveInjectedSystemColumns` are all + measured blind to it, and that read-neutrality is pinned by tests beside + each. Declaring it never walls an object and never hides rows. + - ⛔ Scope pin from the ruling: this is ONE stamp-only key, not the opening + move of a general field-roles mechanism. A consumer other than audit + stamping needs its own ruling before reading it. + + `sys_api_key` now declares + `tenancy: { enabled: false, organizationField: 'active_organization_id' }`, + so revoking another user's key from a different active organization lands the + audit row behind the wall of the KEY's organization — where the tenant admin + who can act on it reads it. The `enabled: false` is measured + behavior-identical to the previous absent block for this object on every read + path (injection bails on `managedBy: 'better-auth'` first; the SQL driver's + tenant field resolves null either way; Layer 0 is exempt either way; the + memory/mongo boot guards count only an explicit `enabled: true`). + +### Patch Changes + +- 1408fe3: fix(audit): audit rows are stamped from the record's own organization, not the actor's active one (#8707) + + `sys_audit_log` / `sys_activity` rows took their organization from + `sess.tenantId ?? recordOrgId` — the ACTING session's active organization in + preference to the organization of the record the row is about. A write + performed from a session whose active organization differs from the record's + therefore landed the audit row behind the wrong tenant's wall: unreadable to + the tenant admin it concerns, and readable by an organization with no claim to + the record. That is the invisible-audit-row defect the record-side fallback was + added to prevent, one layer down, and the maintainer's ruling on #8287 settles + it the other way — the stamp comes from the row's own organization. + + The precedence is now `recordOrgId ?? sess.tenantId`. The RLS fallback is + preserved unchanged: an audit row must never be written with a NULL + organization, so the acting session's tenant still answers whenever the record + has no organization of its own (single-tenant stacks, platform-global objects, + a NULL column), and the record's organization still answers on the two cases + the fallback was written for — background/sudo paths with no `tenantId`, and + better-auth's `activeOrganizationId` cache miss right after sign-in. + + Which column carries a record's organization is now resolved from the + REGISTERED SCHEMA rather than the hard-coded `organization_id` literal, with + the same precedence `SqlDriver.computeTenantField` already applies: an ADR-0066 + `tenancy.enabled: false` opt-out resolves to no organization at all (so a + platform-global object's audit trail is not scoped into one tenant and hidden + from the platform admin who acted), then a declared `tenancy.tenantField` when + the object really has that field, then the canonical injected + `organization_id`. + + Most deployments see no change: under the `isolated` posture the Layer 0 wall + makes a cross-organization write of a walled object impossible, so the two + sides agree by construction. The behaviour changes under the `group` and + `shared` postures, and on system paths that write another organization's row + while carrying a session. + + Not addressed here: `sys_api_key.active_organization_id` is still not + reachable by this resolver, so revocation rows on that object continue to fall + back to the actor's organization. Its column is deliberately not the object's + tenant-scope column and must not become one, so closing that half needs a + read-neutral, stamp-only organization declaration in `packages/spec`. #8707 + remains open for it. + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [7c2f386] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [8a9e7f4] +- Updated dependencies [3d0ded8] +- Updated dependencies [44bc51d] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/objectql@17.1.0 + ## 17.0.0 ### Major Changes diff --git a/packages/plugins/plugin-audit/package.json b/packages/plugins/plugin-audit/package.json index b79f2310f3..6ebd850417 100644 --- a/packages/plugins/plugin-audit/package.json +++ b/packages/plugins/plugin-audit/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-audit", - "version": "17.0.0", + "version": "17.1.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 1113112f98..dc51cf65c6 100644 --- a/packages/plugins/plugin-auth/CHANGELOG.md +++ b/packages/plugins/plugin-auth/CHANGELOG.md @@ -1,5 +1,368 @@ # Changelog +## 17.1.0 + +### Minor Changes + +- e43d63a: feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287) + + + + On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could + read **nothing at all**. `sys_api_key` carried no organization column, so key + authentication established a user but no active organization — and the + `isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with + no active organization matches no row. Every organization-scoped read answered + `200` with `total 0` while the console went on offering minting, so a tenant + admin could mint a valid-looking secret and discover only at call time that it + read nothing. (There was no cross-tenant leak — the failure was in the other + direction.) + + **The column was absent by an inherited rule, not by oversight.** + `resolveInjectedSystemColumns` injects `organization_id` into every registered + object _except_ `managedBy: 'better-auth'` ones, and `sys_api_key` carries that + flag — even though better-auth's `apiKey` plugin is not loaded and the table is + hand-rolled ObjectStack. So the fix needs the declaration _and_ the ADR-0105 D7 + extension-field registration to stay consistent. The read side, by contrast, + was **already wired**: `resolveApiKeyPrincipal` already read an organization + into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a + column no mint path ever wrote. + + **What changes** + + - `sys_api_key` declares `active_organization_id` (+ index, and the column is + shown in the "My Keys" and "All" list views, because the card's complaint was + a credential whose reach its owner could not see). + - `POST /api/v1/keys` **inherits** the caller's active organization — there is + deliberately no org parameter and no cross-org key — and **re-checks the + caller's `sys_member` membership at mint time**, honouring ADR-0091 validity + windows. Under a walled posture it refuses (400) rather than minting a key + with no organization, and refuses (403) for an organization the caller is not + a member of. The mint response echoes the organization the key is pinned to. + - The verifier reads **one spelling** (Prime Directive #12): the + `row.organization_id ?? row.organizationId` chain it used to carry was a + consumer-side tolerance for a producer that did not exist. + - An **ex-member's key fails closed at verify time** — no principal, not a + degrade to a user-only principal, which would resurrect the same + `200 + total 0` silent-empty. Checked at verify rather than by revoking on + membership loss, because membership ends through many paths (better-auth org + endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and + a hook must catch every one or it silently misses. It costs **zero extra + queries**: the resolver has already read `sys_member` for this user. + - **Pre-existing org-less keys are never backfilled** — that would silently + upgrade credentials minted under a different promise. They keep working under + `single` (no wall) and under `group` (whose wall derives from the owner's + memberships independently of the active organization, so they already work + there), and are **refused under `isolated`**, where they are provably dead + today. + + **The column is deliberately named `active_organization_id`, not + `organization_id`** — the `sys_session` spelling, for the same concept: the + organization a credential makes _active_. `objectHasOrgIdField` tests for the + literal `organization_id`, and Layer 0 exempts objects without it, so the other + name would have made `sys_api_key` itself org-walled. Both walled postures + exclude NULL, so every pre-existing org-less row would have vanished from its + **own owner's** "My Keys" list while, under `group`, continuing to + authenticate — a live credential nobody could see or revoke, which is a fresh + instance of the very class this change removes. + +- 5f5e234: fix(security): `sys_permission_set.active` and `sys_position.active` now actually stop granting access (#8613) + + + + **BREAKING for deployments that already switched a permission set or position + off.** Both objects ship a Deactivate action whose confirmation dialog promises, + in all four locales, that access stops: + + > Deactivate this permission set? Existing assignments stay in place but stop + > granting access until re-activated. + > Deactivate this position? Users keep their assignment but the position stops + > granting permissions until re-activated. + + Nothing read the column. Measured on the real resolver: a position seeded + `active: false` still granted its permission sets, and a permission set seeded + `active: false` still returned `posture: PLATFORM_ADMIN` with its system + permissions. Deactivation moved a badge in Setup and nothing else — while the + admin who had just revoked a compromised or over-broad grant was told the + opposite, and whose likely next step was therefore _not_ the action that would + have worked (delete the set, or remove the assignments). + + **What changes at runtime.** `resolveAuthzContext` / `resolveUserAuthzGrants` + (`@objectstack/core`) — the single seam every transport resolves authorization + through — now drop a deactivated row **before** any derivation: + + - a deactivated `sys_position` no longer contributes its + `sys_position_permission_set` grants, and its name leaves `positions` (so the + name-reuse path cannot resolve the same grant one layer down); + - a deactivated `sys_permission_set` contributes no name, no + `system_permissions`, no `tab_permissions`, **and no `PLATFORM_ADMIN` + posture** — the flag is applied before the posture is derived, not after; + - the `plugin-security` DB loader applies the same predicate, which is what + judges a set reached by NAME through an active position of the same name. + + Both tables were already read at that seam, so this costs **zero new hot-path + queries**. + + **⚠️ Read this before upgrading.** Any `sys_permission_set` or `sys_position` + row currently carrying `active: false` **stops granting the moment this + lands** — on live data, with no migration step to notice. That is the correct + direction (it is what the dialog said when someone clicked Deactivate), but on + an installation that used the switch believing it was inert it is a real + revocation. Before upgrading, list the deactivated rows and re-activate any that + are still meant to grant: + + ``` + GET /api/v1/data/sys_permission_set?filters=[["active","=",false]] + GET /api/v1/data/sys_position?filters=[["active","=",false]] + ``` + + A row whose `active` column is **absent or NULL** is unaffected: the predicate + is "explicitly deactivated", never "explicitly active", so rows that predate the + column keep granting exactly as before. + + **Break-glass, closed in the same change** (`@objectstack/plugin-auth`). + Enforcing the flag opened a one-click, installation-wide lockout: deactivating + `admin_full_access` un-makes every platform admin at once, through a payload + that touches neither `name` nor any identity table, and re-activating requires + the permission the click just took away (the seeders deliberately never + reconcile `active`, so no restart restores it). The last-administrator guard now + judges that write like the delete and rename spellings it already refused, and + an environment whose break-glass set is _already_ off is read as emptied rather + than as a bootstrap window — so it does not silently disarm the guard for every + other identity write. Re-activation itself stays permitted, or the refusal would + have no way out from inside the product. + +- f8eb736: feat(security): bind the break-glass standing-key lists to what the authz resolver actually reads — the correspondence stops being prose (#8734) + + `plugin-auth`'s last-administrator guard (ADR-0024 D5.2) decides whether a + pending write can empty the administrator population by testing the payload + against three standing-key lists (`MEMBER_STANDING_KEYS`, + `GRANT_STANDING_KEYS`, `PERMISSION_SET_STANDING_KEYS`). A payload touching none + of them is skipped without any reads — so a column `resolveAuthzContext` starts + reading that a list omits is a write class the guard **silently stops judging**, + on the one path whose failure mode is an installation-wide administrator lockout + with no in-product recovery. + + Nothing bound the two together. The correspondence lived in a comment, and it + had already gone false once: #6084 wrote — naming `active` explicitly — that + everything a permission-set write touches other than `name` is invisible to "who + is an administrator". That was true when written; #8613 made `active` a + resolution-time predicate and the sentence became false. Nothing mechanical + would have caught it, because the guard's own tests stay green precisely when + the guard is never consulted. + + **The mechanism is two links, and the first one is a measurement.** + + - `@objectstack/core` now exports `ADMIN_STANDING_SURFACE` — declared beside the + resolver, listing every table the administrator-derivation path reads, each + classified `derives` or `reads-only` with its reason, and for the deriving + tables every column read. It is asserted **equal** to what the real + `resolveAuthzContext` reads, observed at runtime through a recording engine + that records every property access and every `where` key per table. Observation + rather than source extraction because the reads that matter have moved into + helpers: `active` is read by `isRowActive(row)` and the ADR-0091 window bounds + by `isGrantActive(row, now)`, neither named at the resolver's own call site — + the exact shape #8613 had. + + - `@objectstack/plugin-auth` now exports its standing-key lists plus + `STANDING_KEYS_BY_TABLE` and `STANDING_KEY_EXCLUSIONS`, and a gate requires + every column of that measured surface to have an answer: it is standing-bearing + (in a list) or it is excluded with the reason it cannot empty the administrator + population. There is no third state — the third state is what `active` was + between #6084 and #8613. + + So a resolver change that starts reading a new column fails at the first link + until the declaration is updated, and at the second until the guard has an + explicit answer for it. Landing #8613 green would have required writing down that + deactivating `admin_full_access` cannot empty the administrator population — + which is false, and which is what the old comment asserted by accident. + + **No guard behaviour changes.** Every list keeps exactly the values it had; the + gate is one-directional by construction (it can only ever demand that the guard + judges _more_), because the other direction would put pressure on a break-glass + guard to fire less often. + + The table-level half is covered too: a resolver that started deriving + administrator standing from a **new** table is invisible to any column-set + comparison, since the table is absent from both sides — so the surface enumerates + every table the path reads, and an unclassified one fails. + +### Patch Changes + +- c9f5950: fix(security): `sys_account`'s OAuth access/refresh/id tokens stop serializing on the data API — `internal: true`, with better-auth's readback seam widened to cover them (#7987) + + + + `sys_account.access_token`, `.refresh_token` and `.id_token` hold each user's + **live third-party OAuth credentials** — the tokens ObjectStack received from + Google, GitHub or an OIDC IdP — in cleartext (better-auth's + `account.encryptOAuthTokens` is not set, so `setTokenUtil` stores them + verbatim). They were plain `Field.textarea` on an object declaring + `apiEnabled: true, apiMethods: ['get','list']`. + + **Both personas were measured leaking, on a real booted stack** (`bootStack(showcaseStack)`, + in-process HTTP + sqlite-wasm), with a planted token on a member's account row: + + - **admin**, `GET /data/sys_account/{another user's account id}` — 200, that + member's `refresh_token` verbatim, plus `access_token` and `id_token`; + - **member**, `GET /data/sys_account` (self-scoped by the `sys_account_self` RLS + policy) — 200, their **own** `refresh_token` verbatim. + + The member arm is the one this object does not share with its `sys_session` + sibling (#7823), and it is the sharper of the two: it converts a short-lived, + revocable ObjectStack session bearer into a **long-lived third-party refresh + token that this platform cannot revoke at all**. Neither collector reached these + columns — the engine's credential mask collects by field TYPE (`textarea` is + neither `secret` nor `password`) _and_ exempts objects with + `managedBy: 'better-auth'`, which this object is. + + **The fix is three declarations plus one widening**, inheriting #7823's shape + rather than inventing a second mechanism: + + - the three columns are declared `internal: true` — the opt-in, type-independent + flag minted by #7728 meaning _the declared value is never returned on the + generic data path_. Storage, filtering and indexing are untouched: the strip + runs on rows the driver has already produced. + - better-auth **reads these back off adapter result rows** — measured, and the + risk this card was parked on: `internalAdapter.findAccounts(userId)` issues a + `findMany` with no projection, and `/get-access-token`, `/account-info` and + `/refresh-token` then read `account.refreshToken` / `.accessToken` / + `.idToken` off those rows. The read strip alone would answer + `REFRESH_TOKEN_NOT_FOUND` (400) and hand back an empty access token. So the + existing readback seam in `@objectstack/plugin-auth` — which already recovered + `sys_session.token` through `Engine.resolveInternalField` (#8118's privileged + batch accessor) — is widened to cover these three columns and renamed + accordingly. No engine carve-out, no second accessor. + + **Not retyped, deliberately.** `Field.secret()` would route better-auth's own + writes through the engine's encrypt-on-write path, placing the engine between + better-auth and its own adapter. `Field.password()` is inert here for the two + reasons above. + + **`password` / `previous_password_hashes` are deliberately out of scope** — + they are better-auth one-way hashes (ADR-0100's third channel), not reversible + outbound credentials, and the readback seam refuses to touch them. + + The regression proof drives both directions: the fixture PLANTS real token + values and re-reads them out of storage through the privileged accessor before + asserting anything (so "absent from the response" cannot pass vacuously), then + pins that the values are still on disk, still usable as a server-side predicate, + and that password sign-in — which reads a `sys_account` row back through the + same seam on every request — still works. + +- 04f8fdb: fix(platform-objects): drop the dead `mapId` ("Map: User ID claim") param from `register_sso_provider` — the OIDC subject claim is not configurable (#8222) + + + + The `register_sso_provider` action on `sys_sso_provider` offered an optional + **"Map: User ID claim"** text field (`mapId`), with helpText reading _"Optional. + ID-token claim mapped to the user ID. Defaults to `sub`."_ + + **That capability no longer exists.** It was retired upstream in + `@better-auth/sso@1.7.0-rc.2`: + + - `oidcConfig.mapping` is a `z.strictObject` whose members are + `{ email, emailVerified?, name, image?, extraFields? }` — there is no `id`; + - the federated subject is hard-wired to the OIDC `sub` claim + (`id: readStringClaim(rawUserInfo, "sub")` and `id: idToken.sub`), then + cross-checked (`id_token_subject_missing`, + `id_token_userinfo_subject_mismatch`); + - `extraFields` is not an escape hatch — it is spread **before** `id` in the + profile literal, so an `extraFields.id` is overwritten by `sub` before anything + reads it. + + `1.6.20` did honour `mapping.id` (`id: rawUserInfo[mapping.id || "sub"]`); the + version bump deleted the member. + + So the field's only accepted values were "empty" and the `sub` it already + defaulted to. #8193 (PR #8221) stopped the bridge emitting the retired key and — + rather than accept a value it would silently discard — made a non-`sub` value + answer `INVALID_REQUEST`. That left the last half of the problem: **the form + still advertised a free-form optional field that 400s on anything meaningful.** + Removing it restores declared = enforced. Nothing else about registration moves: + the runtime accept set is unchanged, and a registration that never sent `mapId` + behaves exactly as before. + + `mapEmail` and `mapName` are untouched — they map to live `oidcMappingSchema` + members and are still honoured. + + **The bridge-side guard in `plugin-auth`'s `register-sso-provider.ts` is kept**, + and its refusal test with it. The admin form was only one caller: a direct API + client, a script, or a stale cached console bundle can still put `mapId` on the + wire, and telling those callers plainly still beats discarding the value in + silence. Only the guard's doc comment changed, to stop describing `mapId` as a + field the form sends. + + The generated translation bundles (`*.objects.generated.ts`, all four locales) + were **regenerated**, not hand-edited, so the retired label disappears from every + locale rather than lingering as a stale entry. + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e7bccaa] +- Updated dependencies [e43d63a] +- Updated dependencies [5047cb8] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [3ab2488] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [e6e1de4] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/rest@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + ## 17.0.0 ### Major Changes diff --git a/packages/plugins/plugin-auth/package.json b/packages/plugins/plugin-auth/package.json index 63a680841d..91c3dfe08a 100644 --- a/packages/plugins/plugin-auth/package.json +++ b/packages/plugins/plugin-auth/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-auth", - "version": "17.0.0", + "version": "17.1.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 b1e5fcfb5a..718ac6f752 100644 --- a/packages/plugins/plugin-dev/CHANGELOG.md +++ b/packages/plugins/plugin-dev/CHANGELOG.md @@ -1,5 +1,70 @@ # @objectstack/plugin-dev +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e7bccaa] +- Updated dependencies [e43d63a] +- Updated dependencies [5047cb8] +- Updated dependencies [cf0d902] +- Updated dependencies [498f4e8] +- Updated dependencies [cc5c07b] +- Updated dependencies [f57fb38] +- Updated dependencies [8656d67] +- Updated dependencies [3d61924] +- Updated dependencies [6feac91] +- Updated dependencies [5f5e234] +- Updated dependencies [4ea921c] +- Updated dependencies [3ab2488] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [20067c5] +- Updated dependencies [e783e16] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [4fc4a3c] +- Updated dependencies [e6e1de4] +- Updated dependencies [c73eacd] +- Updated dependencies [693c788] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [19db5fa] +- Updated dependencies [2b9d33a] +- Updated dependencies [04f8fdb] +- Updated dependencies [147eadc] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [7c2f386] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [8a9e7f4] +- Updated dependencies [3d0ded8] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/plugin-auth@17.1.0 + - @objectstack/plugin-security@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/rest@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/runtime@17.1.0 + - @objectstack/objectql@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/account@17.1.0 + - @objectstack/setup@17.1.0 + - @objectstack/service-realtime@17.1.0 + - @objectstack/service-storage@17.1.0 + - @objectstack/driver-memory@17.1.0 + - @objectstack/plugin-hono-server@17.1.0 + - @objectstack/service-i18n@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/plugins/plugin-dev/package.json b/packages/plugins/plugin-dev/package.json index 3e19f901fd..b10cfc466f 100644 --- a/packages/plugins/plugin-dev/package.json +++ b/packages/plugins/plugin-dev/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-dev", - "version": "17.0.0", + "version": "17.1.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 5f8b27be13..6404976ff7 100644 --- a/packages/plugins/plugin-email/CHANGELOG.md +++ b/packages/plugins/plugin-email/CHANGELOG.md @@ -1,5 +1,50 @@ # @objectstack/plugin-email +## 17.1.0 + +### Patch Changes + +- 6158146: Stop serving custom email headers through the generic data-API read of `sys_email` (#8149). + + **What this closes.** `sys_email.headers_json` — the custom headers handed to `IEmailService.send`, the ordinary place a relay credential or provider token goes — was readable by every caller the data API admits (list, get, an explicit `?select=headers_json`). The column is now declared `internal: true`, so the engine omits it from every generic read with no system carve-out (#7728); `SYSTEM_CTX` does not reopen it either. This is the same shape #8118 ruled on for `sys_http_delivery.headers_json`: this change adopts that remedy rather than deciding it a second time. + + **Delivery is unaffected, and fail-closed.** `sys_email` is not delivered from the in-memory message but FROM THE ROW: the after-insert outbox drain hook, the `email.send.async` queue subscriber and the boot outbox sweep all re-read the row and hand it to `EmailService.deliverPersistedRow`. All three read through `engine.find`, which is exactly what the flag empties — so the recovery ships with the flag. `deliverPersistedRow` now recovers the column through ObjectQL's privileged accessor (`resolveInternalField`, consumed unchanged) and sends every authored header verbatim. A message whose headers cannot be recovered is NOT sent without them: a missing header is not self-announcing — a relay that does not require it accepts the mail while the delivery silently deviates from the authored configuration. That case throws and leaves the row `queued`, not `failed`, so the queue retry or the next boot's sweep delivers it intact. + + **New optional seam.** `EmailPersistence.readHeadersJson(rowIds)` — the readback the plugin wires off the raw engine. It probes the OBJECT SCHEMA flag, never the absence of the key from a result row: `headers_json` is `required: false` and most real rows carry no custom headers at all, so a key-absence inference would treat every ordinary email as redacted (the regression measured on `sys_account`'s optional token columns in #7987/PR #8675). Engines that do not redact are left untouched and trigger no privileged read. + + **What this deliberately does NOT close.** The row still holds the header map in cleartext at rest. Encrypting it (`Field.secret()`) was measured and rejected on #8118 — an orphan `sys_secret` row per message with no cascade or retention, a boot-window fail-open, and a per-row decrypt on every delivery — and this change adopts that ruling unchanged. + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/formula@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/plugins/plugin-email/package.json b/packages/plugins/plugin-email/package.json index d71f26ff14..d88618cf15 100644 --- a/packages/plugins/plugin-email/package.json +++ b/packages/plugins/plugin-email/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-email", - "version": "17.0.0", + "version": "17.1.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 f7bddc93bd..a8f6adaded 100644 --- a/packages/plugins/plugin-hono-server/CHANGELOG.md +++ b/packages/plugins/plugin-hono-server/CHANGELOG.md @@ -1,5 +1,34 @@ # @objectstack/plugin-hono-server +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/observability@17.1.0 + ## 17.0.0 ### Major Changes diff --git a/packages/plugins/plugin-hono-server/package.json b/packages/plugins/plugin-hono-server/package.json index 016eaf7128..bcbd7a7627 100644 --- a/packages/plugins/plugin-hono-server/package.json +++ b/packages/plugins/plugin-hono-server/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-hono-server", - "version": "17.0.0", + "version": "17.1.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 ac66c7e0d5..d293e676fc 100644 --- a/packages/plugins/plugin-pinyin-search/CHANGELOG.md +++ b/packages/plugins/plugin-pinyin-search/CHANGELOG.md @@ -1,5 +1,23 @@ # @objectstack/plugin-pinyin-search +## 17.1.0 + +### Patch Changes + +- Updated dependencies [e43d63a] +- Updated dependencies [5f5e234] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7c2f386] +- Updated dependencies [8a9e7f4] +- Updated dependencies [3d0ded8] +- Updated dependencies [bbbfcfc] + - @objectstack/core@17.1.0 + - @objectstack/objectql@17.1.0 + - @objectstack/types@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/plugins/plugin-pinyin-search/package.json b/packages/plugins/plugin-pinyin-search/package.json index 7db0178e6b..07e50edb07 100644 --- a/packages/plugins/plugin-pinyin-search/package.json +++ b/packages/plugins/plugin-pinyin-search/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-pinyin-search", - "version": "17.0.0", + "version": "17.1.0", "license": "Apache-2.0", "description": "Pinyin search recall for ObjectStack — populates the hidden `__search` companion column (full pinyin + initials of the display/name field) so `$search` hits CJK names typed as pinyin. Locale-gated via OS_SEARCH_PINYIN_ENABLED (#2486).", "main": "dist/index.js", diff --git a/packages/plugins/plugin-reports/CHANGELOG.md b/packages/plugins/plugin-reports/CHANGELOG.md index a8d9569943..12d52a125e 100644 --- a/packages/plugins/plugin-reports/CHANGELOG.md +++ b/packages/plugins/plugin-reports/CHANGELOG.md @@ -1,5 +1,39 @@ # @objectstack/plugin-reports +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Major Changes diff --git a/packages/plugins/plugin-reports/package.json b/packages/plugins/plugin-reports/package.json index 74a16985b1..4b2ea1fb2c 100644 --- a/packages/plugins/plugin-reports/package.json +++ b/packages/plugins/plugin-reports/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-reports", - "version": "17.0.0", + "version": "17.1.0", "license": "Apache-2.0", "description": "Saved reports + scheduled email digests for ObjectStack — sys_saved_report + sys_report_schedule + IReportService.", "main": "dist/index.js", diff --git a/packages/plugins/plugin-security/CHANGELOG.md b/packages/plugins/plugin-security/CHANGELOG.md index 8ec80c6c51..4150e06bd8 100644 --- a/packages/plugins/plugin-security/CHANGELOG.md +++ b/packages/plugins/plugin-security/CHANGELOG.md @@ -1,5 +1,549 @@ # @objectstack/plugin-security +## 17.1.0 + +### Minor Changes + +- 720ee95: fix(security): the shipped admin permission sets no longer grant export on the `*` wildcard (#8681) + + + + **BREAKING for any deployment whose administrators export today.** Landing after + the v17.0.0 cut, so it ships as `minor` under the lockstep launch-window + convention; the migration prescription is registered under protocol major 18, + where `objectstack migrate meta` users will look. + + `admin_full_access`, `organization_admin` and the derived + `organization_admin_no_bypass` shipped `objects['*'].allowExport = true`. That + single line made the 17.0 export axis **undeniable** for anyone holding an admin + set: an application could declare an object exportable by nobody, ship it, and + the platform would export it anyway. + + Measured on 17.0.0 GA — 40 export probes, 5 principals, 8 objects, real Bearer + tokens — an org owner exported `crm_quote` (9 rows), `crm_campaign` (13) and + `crm_task` (15) with 200 and full data. No app permission set granted export on + any of the three, and the app had no way to say no: + + 1. the wildcard lives in code-package metadata, so editing it answers + `403 [not_overridable] Metadata item 'permission/admin_full_access' is +provided by a code package`; + 2. the org admin holds no app-authored permission set, so there is nowhere to + author the per-object `allowExport: false` that would otherwise have won. + + **This was never a gate defect.** The same run proves the export gate exact for + every other principal: a token refused on one object exports another on the same + route, granting `allowExport` at runtime flips 403 to 200, and revoking it flips + it back. A plain member carrying `'*': { allowExport: true }` exported too — the + wildcard was simply doing what it said. What changes is that the platform stops + shipping that grant. + + This is #5491 applied to the export axis. That change removed `member_default`'s + CRUD wildcard because a wildcard in a set every principal resolves is not a + default but a floor no app can get under; the export wildcard survived by + omission rather than by decision, one tier up. + + **Migration — grant `allowExport` explicitly in an app permission set where + admin export is intended.** There is no automatic replacement, deliberately: + which principals may take a bulk machine-readable copy of a table is the + segregation-of-duties judgement the axis exists to make explicit. + + ```ts + // In YOUR app's permission set — not a platform set (those are not overridable). + { + name: 'system_admin', + objects: { + crm_account: { allowRead: true, allowExport: true }, // export intended + crm_quote: { allowRead: true }, // export withheld + }, + } + ``` + + ⚠️ **Nothing fails at parse time, and the shipped sets are re-seeded on + upgrade.** A deployment that upgrades without editing anything is valid metadata + whose administrators have quietly lost export on every object no app set names — + the first sign is a support report, not an error. Verify behaviourally: sign in + as an org owner and call `GET /api/v1/data//export`, expecting 200 where + export is intended and 403 `EXPORT_NOT_PERMITTED` where it is not. + + **What is deliberately unchanged.** READ is untouched — an admin still sees + every record they saw before; this narrows bulk egress only. `allowExport` on a + `'*'` entry remains a supported, honoured authoring shape in an app's own sets. + Specific-over-wildcard precedence is unchanged (an explicit per-object entry + still overrides the wildcard). The `viewAllRecords` / `modifyAllRecords` + super-user bits still do not imply export, exactly as before. And an app's own + admin set already gets precisely its declared posture — declared `false` answers + 403, declared `true` answers 200 — which is what makes withdrawing the platform + grant safe rather than merely restrictive. + + Both admin sets are fixed together, and the org-admin pair from one declaration + (`organization_admin_no_bypass` is derived from `organization_admin`). Fixing + one and not the other was rejected outright: a half-closed export boundary reads + as closed and is not. + +- cc5c07b: fix(plugin-security)!: an insert that omits a required master-detail parent answers `400 VALIDATION_FAILED` with `fields[]`, not a `[Security]`-prefixed `422` (#8688) + + + + **BREAKING (error contract).** On an `insert` into a `controlled_by_parent` + detail whose master reference is absent, the platform used to answer: + + ``` + HTTP 422 + code : MISSING_REQUIRED_FIELD + error : [Security] Missing master reference: insert on 'crm_contact' did not + supply 'crm_account'. … + fields: (absent) + ``` + + It now answers the same envelope every other missing-required-field case + answers — `400 VALIDATION_FAILED`, carrying `fields[]` with + `{ field, code: 'required' }` — wherever required-field validation provably + refuses that omission. A client branching on `code === 'MISSING_REQUIRED_FIELD'` + for this condition must branch on `VALIDATION_FAILED` instead; a client already + handling the platform's ordinary missing-field envelope needs no change and + gains the field it could not previously highlight. + + **What was wrong.** `assertControlledByParentWrite` runs in the security + middleware chain, _outside_ the executor that calls `validateRecord`, so on an + insert it short-circuited required-field validation on the one field they + share. One user-visible condition therefore had two answers on adjacent + branches of the same field: absent → `422` with no `fields[]`, present but + unresolvable → `400 VALIDATION_FAILED` with `fields[]`. A form could highlight + the offending input in the second case and not the first, and any surface + rendering the message string showed a missing required field as a security + refusal. Measured live on 17.0.0 GA over REST. + + The two harms could not be separated: both transport doors emit `fields[]` only + for the `VALIDATION_FAILED` duck-type and each overwrites `code` when it + matches, so "add `fields[]` while keeping `MISSING_REQUIRED_FIELD`" is not a + reachable throw shape. + + **The stand-down is CONDITIONAL, and the residue is deliberate.** It applies + only where `validateRecord` really does refuse the omission: a `master_detail` + declared `required: true` and not `readonly`/`system`. For three other + declarable shapes — a `master_detail` with no `required`; `required` + + `readonly`; `required` + `system` — the validator skips the field before its + required check ever runs (`if (def.system || def.readonly) continue;`), so the + master gate is the only thing refusing the insert. There it keeps answering + `422 MISSING_REQUIRED_FIELD` exactly as before. A flat hand-over was measured to + mint a detail row with a null master FK, which the `controlled_by_parent` read + filter (`fk IN (readable masters)`) can never match — readable by nobody, and + answering `422` on every later by-id write. + + **So the envelope asymmetry is not gone, it is confined**, to precisely the + shapes #8772's lint now refuses at publish time. An app that publishes cleanly + sees one envelope; an app still carrying one of those declarations sees the old + one until it is republished. One further residual, narrower still: a + `controlled_by_parent` object whose relation resolves through the required-_lookup_ + fallback also keeps the `422` — validation would cover it, but the ruling covers + `master_detail`, and widening a ruling is not the implementer's call. + + **Unchanged, and pinned as unchanged:** a master that is _present but not + writable_ by the caller still answers `403 PERMISSION_DENIED — requires edit +access to its master record`. The stand-down is keyed on the FK being absent; + every access leg still runs when one is supplied. The stored-row shape (a by-id + write whose persisted FK is null) also keeps its `422`: the caller sent no such + field, so a `fields[]` naming it would name a field that was never in the + request, and no payload the caller could send would fix it. + + **One pin was rewritten deliberately**, not adjusted to match new behaviour: the + `[#7474]` six-envelope truth table's **insert** leg in + `controlled-by-parent-sharing.test.ts`. Its successor asserts both sides of the + condition — the covered shape hands over (the executor is reached, and the real + `validateRecord` refuses with `VALIDATION_FAILED` + `fields[]`), and each + uncovered shape still gets the `422` (with the real validator raising nothing on + the same payload, which is why the gate must stay). The truth table's other + legs are update-path and are untouched. + + This supersedes the 2026-08-11 envelope choice on #7474, on that ruling's own + rationale: if a detail without its master is "precisely a required value that is + absent", the platform's contract for a required value that is absent is + `400 VALIDATION_FAILED` with `fields[]`. + +- 6feac91: **Security boundary change — this WIDENS who may write rows that are refused today.** On an ADR-0055 `controlled_by_parent` detail, the ADR-0055 master gate is now the sole row-level write authority: the platform's wildcard ownership floor (`owner_only_writes` / `owner_only_deletes`, `created_by == current_user.id`) is no longer applied to such a detail at the by-id write pre-image gate. A by-id UPDATE or DELETE of a child row **created by another user** now succeeds whenever the caller may edit that child's master — where it previously answered `403` `record_access_denied`. Maintainer ruling 2026-08-15 on #8757 (delegated adjudication). + + What the widening rests on: `assertControlledByParentWrite` — the object's declared write gate — already runs on the same operation, immediately after the pre-image gate, under a superset of its guard, and it refuses whenever the master is not editable. The floor is handed to that gate, not removed. Callers who could not edit the master are refused exactly as before, with the master gate's own sentence instead of the record-access one. + + Why it was wrong before: `controlled_by_parent` means "access derives from the master", and the detail declares nothing about who may write it. Two gates were answering one write, and the stricter — a creator-only rule no author wrote — always won: `SharingService.checkEdit` abstains on the `public`-mapped model before reaching its `modifyAllRecords` branch, so ownership depth, an `edit`-level `sys_record_share` and Modify All Data were all inert on a detail. + + Deliberately unchanged, each measured: + + - **BULK (AST) writes keep the floor.** `assertControlledByParentWrite` returns early with no single id, so nothing would replace it there. The floor is dropped from the by-id call site, never from the object's posture alone. + - **Delegated (on-behalf-of) by-id writes keep both principals' floors**, matching ADR-0090 D10's existing exclusion at this gate. + - **INSERT and the read path are untouched** — an insert has no pre-image and so never carried the floor; the floor is `update`/`delete`-only. + - **App-authored policies are untouched** (provenance, ADR-0105 D3), Layer 0's tenant wall is untouched, and a detail that authors its own `select` policies still derives its write scope from them (#7665). + +- 5f5e234: fix(security): `sys_permission_set.active` and `sys_position.active` now actually stop granting access (#8613) + + + + **BREAKING for deployments that already switched a permission set or position + off.** Both objects ship a Deactivate action whose confirmation dialog promises, + in all four locales, that access stops: + + > Deactivate this permission set? Existing assignments stay in place but stop + > granting access until re-activated. + > Deactivate this position? Users keep their assignment but the position stops + > granting permissions until re-activated. + + Nothing read the column. Measured on the real resolver: a position seeded + `active: false` still granted its permission sets, and a permission set seeded + `active: false` still returned `posture: PLATFORM_ADMIN` with its system + permissions. Deactivation moved a badge in Setup and nothing else — while the + admin who had just revoked a compromised or over-broad grant was told the + opposite, and whose likely next step was therefore _not_ the action that would + have worked (delete the set, or remove the assignments). + + **What changes at runtime.** `resolveAuthzContext` / `resolveUserAuthzGrants` + (`@objectstack/core`) — the single seam every transport resolves authorization + through — now drop a deactivated row **before** any derivation: + + - a deactivated `sys_position` no longer contributes its + `sys_position_permission_set` grants, and its name leaves `positions` (so the + name-reuse path cannot resolve the same grant one layer down); + - a deactivated `sys_permission_set` contributes no name, no + `system_permissions`, no `tab_permissions`, **and no `PLATFORM_ADMIN` + posture** — the flag is applied before the posture is derived, not after; + - the `plugin-security` DB loader applies the same predicate, which is what + judges a set reached by NAME through an active position of the same name. + + Both tables were already read at that seam, so this costs **zero new hot-path + queries**. + + **⚠️ Read this before upgrading.** Any `sys_permission_set` or `sys_position` + row currently carrying `active: false` **stops granting the moment this + lands** — on live data, with no migration step to notice. That is the correct + direction (it is what the dialog said when someone clicked Deactivate), but on + an installation that used the switch believing it was inert it is a real + revocation. Before upgrading, list the deactivated rows and re-activate any that + are still meant to grant: + + ``` + GET /api/v1/data/sys_permission_set?filters=[["active","=",false]] + GET /api/v1/data/sys_position?filters=[["active","=",false]] + ``` + + A row whose `active` column is **absent or NULL** is unaffected: the predicate + is "explicitly deactivated", never "explicitly active", so rows that predate the + column keep granting exactly as before. + + **Break-glass, closed in the same change** (`@objectstack/plugin-auth`). + Enforcing the flag opened a one-click, installation-wide lockout: deactivating + `admin_full_access` un-makes every platform admin at once, through a payload + that touches neither `name` nor any identity table, and re-activating requires + the permission the click just took away (the seeders deliberately never + reconcile `active`, so no restart restores it). The last-administrator guard now + judges that write like the delete and rename spellings it already refused, and + an environment whose break-glass set is _already_ off is read as emptied rather + than as a bootstrap window — so it does not silently disarm the guard for every + other identity write. Re-activation itself stays permitted, or the refusal would + have no way out from inside the product. + +### Patch Changes + +- cf0d902: fix(security): the `controlled_by_parent` master-editability check consults the same app-authored write widener the by-id path does (#8679) + + + + `crm_campaign_member`-shaped objects — ADR-0055 `controlled_by_parent` details — + route every insert/update/delete through `assertControlledByParentWrite`, which + asks whether the caller may EDIT the master. That gate's record-sharing leg + hard-refused on `canEdit === false` **without ever asking whether an app-authored + RLS update-widener admits the master row**. The by-id write path has asked + exactly that since #5493 (merged as PR #6909), where the deferral was installed + on the sharing middleware's refusal branch. + + So one principal, one master record and one operation got **two different + answers depending on who was asking** — measured on 17.0.0 GA with real Bearer + tokens, one variable (who created the master), everything else identical: + + | step | master created by ADMIN | master created by the caller | + | ------------------------------------------------------ | ----------------------- | ---------------------------- | + | PATCH the master itself, by id | **200** | 200 | + | INSERT a child | **403** | 201 | + | UPDATE a child | **403** | 200 | + | `security/explain` update on the master, record-scoped | **`allowed=true`** | `allowed=true` | + + The master write and the platform's own `explain` verdict both said yes; only the + derived write disagreed, refusing with `master '...' not editable by this user +(record sharing)` — naming the very layer #6909 had already taught to defer. + + **The fix consults the same composition, and does not relax the check.** The + verdict comes from `checkAuthoredRowWrite` — the method + `SharingService.probeAuthoredRowWrite` passes straight through to — so the answer + at this call site is byte-for-byte the one a direct by-id write of that master + would get. There is no second copy to drift, which matters because a duplicated + permission composition is how the two paths diverged. The question is asked for + `update`, matching the two legs already above it: this gate's subject is edit + access to the master, never the detail's own verb. + + Nothing else widens. The object-level `update` grant and the master's own + write-RLS leg run first and still refuse on their own terms; `admit` retracts + only the record-sharing leg's refusal, exactly as an `admit` on the by-id path + hands the row to the pre-image gate rather than authorizing anything. Every other + outcome — `abstain`, no authored policy, a `check`-only policy, a principal-less + or delegated context, a throwing probe — leaves the refusal untouched, and the + method is fail-closed in the `abstain` direction, so no failure mode here can + open access. + + The regression proof drives both directions on one fixture and refuses to be + satisfiable by a relaxation: the RLS-widened master **permits** the derived write + **and** a principal with no widener and no share is still refused on the same + route with the same payload. A transferred master (write RLS admits via the + platform floor, record sharing refuses because the owner is someone else) keeps + the record-sharing leg itself pinned live — deleting that leg outright would + otherwise leave the suite green — with an `edit`-level share admitting the same + row and a `read`-level share still refusing it. + +- 498f4e8: fix(security): `controlled_by_parent` detail writes compose the master's ownership floor the same way a direct write does (#8865) + + **This change widens a permission boundary, deliberately and with maintainer + approval (ruling of 2026-08-15, direction 1): children of a master become + writable by every principal whose record-sharing verdict on that master is + `allow`.** That is the same set which already reaches the master itself — the + widening restores a symmetry the platform declares, it does not mint a new + capability — but it is a real widening and it is stated here rather than + softened. + + ## What was measured + + `assertControlledByParentWrite` (ADR-0055, step 2.8) resolves master-edit access + in two legs. Leg 1 — the master's own write RLS — computed + `computeRlsFilter(master, 'update')` with **no** `dropPlatformOwnershipFloor`, + while the by-id write pre-image gate (step 2.7) computes the same filter for the + same object with that knob set whenever `ISharingService` answers `allow`. So the + platform's ownership floor (`created_by == current_user.id`, shipped by + `member_default`) was dropped on the direct path and left standing on the derived + one, and one principal, one master row and one `update` got two answers: + + | step | verdict before | + | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | + | PATCH the master `camp_mkt` directly, by id | allowed | + | UPDATE a child of `camp_mkt` | `403 … requires edit access to its master record (master 'crm_campaign' not editable by this user (row-level security))` | + + The principal in that measurement holds `modifyAllRecords` on the master, which + is exactly what makes the sharing verdict `allow` and drops the floor on the + direct path; it did not create the master, so the undropped floor refused it on + the derived path. Every widening mechanism the platform declares — ownership at + write DEPTH, an `edit`-level `sys_record_share`, `modifyAllRecords` — was + therefore inert **for children** while it worked **for the master itself**. An + app author saw a master they could edit and children they could not. + + This is the divergence #8679 closed in leg 2 (record sharing), surviving one leg + over, and it is closed the same way: one principal, one row, one operation must + not get two answers. + + ## The change + + Leg 1 adopts step 2.7's composition, clause for clause: + + - ask `resolveSharingWriteVerdict('update', master, masterId, …)` — the tri-state + verdict, not `canEdit`'s boolean projection — and drop the platform ownership + floor **only** on `allow`; + - ask it only when a platform floor policy is actually applicable to this + (principal, master, `update`), so an object with no floor in play spends no + sharing probe; + - `abstain` and `deny` both leave the floor standing, and the verdict answers + `deny` when its own probe throws, so no failure mode of this composition can + widen; + - the on-behalf-of path (ADR-0090 D10) is excluded, mirroring step 2.7: a + delegated write keeps **both** principals' floors, exactly as before. + + Only the PLATFORM's floor is droppable (provenance, ADR-0105 D3). An app-authored + policy — including one spelling the identical predicate — reaches the compiler + untouched and still refuses (ADR-0049), and Layer 0 (the tenant wall) is not + affected at all. Step 2.7's composition and the insert leg's #8688 stand-down are + untouched. + + ## Pinned + + The residual assertion the measuring run left in the tree + (`controlled-by-parent-detail-write-authority.test.ts`, labelled `RESIDUAL +(#8865)` with the comment "When #8865 lands the assertion above flips") now + asserts the permission, and keeps its witness — the same principal, the same + master row, the same operation, asked directly — so the two paths cannot drift + apart again without a red. + + A new section pins the flip to the composition rather than to a relaxation, each + case varying one input and asserting the direct write of the master agrees: + + - an `edit`-level `sys_record_share` on the master — and nothing else — is what + moves a child write from refused to permitted; + - an owner-less master, where `checkEdit` abstains for everyone (Modify All Data + included), keeps its floor and refuses on both paths — the case that separates + the ruled `=== 'allow'` composition from the boolean projection; + - an app-authored master policy still refuses a principal whose sharing verdict + is `allow`, while the same write without that policy is permitted. + +- 8656d67: fix(plugin-security): the derived capability seeder's skip is counted and warned instead of leaving the platform bucket silently unseeded (#8536) + + **This does not change what the seeder does. It changes whether an operator can + tell what it did.** No adoption, no backfill, no new writes — the #5876 guard + keeps declining an authored row, which is the ruled behaviour (#8552 settled the + posture on an occupied platform bucket: keep declining, loudly). + + `bootstrapSystemCapabilities` derives a placeholder `sys_capability` row for any + capability a bootstrap permission set grants by name. Its lookup runs under the + system context, which carries no `tenantId`, so it reads **across + organizations** — and when the row it finds is one it does not own, the #5876 + guard `continue`s before any insert is attempted. + + Before #8461 that was harmless, because `name` was unique installation-wide: "a + row resolves this name" and "the platform holds a row for this name" were one + statement, which is exactly what #5876's reasoning rests on ("the capability + resolves and the authored copy is the better one"). Per-organization uniqueness + (ADR-0120 D1) separated them. An organization's row now satisfies the lookup + while the platform's NULL-organization bucket is **never written at all**, and + nothing said so: `skippedAuthored` moved, and that counter cannot distinguish + "an authored copy was left alone" from "the platform's definition exists + nowhere". + + The skip now reads the platform bucket once — on that branch only, the same cost + the curated half already accepted — and warns with the curated half's + provenance-naming shape: it names the `managed_by` and organization it **read** + off the blocking row rather than asserting an ownership verdict, states which of + the three bucket observations it saw (free / held by an unstamped row / held by + a row with a named provenance), and carries the #8552 hand-resolution line only + where a row an operator may legitimately rename is what blocks the bucket. Where + an organization's row is what stands in the way, the message says there is + nothing to remove — that row is a supported ADR-0066 D1 extension. + + The warning fires only where the platform's own placeholder is genuinely + **absent**, so it means one thing. A skip that declines a mere refresh — the + placeholder is present and simply was not the row the cross-organization lookup + selected — stays summary-only, as #4632 decided. + + `CapabilitySeedResult` gains `unseededDerived`, a documented **subset** of + `skippedAuthored` rather than a split of it: the existing counter keeps its + meaning and its value, because the two facts are separable only since #8461 and + neither should be inferred from the other. + +- 4ea921c: Repair the ADR-0090 `sys_role` → `sys_position` rename in the es-ES object + translation bundles, and guard it mechanically. + + The rename half-landed in Spanish: an unreviewed substring find-replace produced + two non-words (`Puestoes` as the plural of `Puesto`, and `contpuesto` where the + replace ate the unrelated word `control`), while nine further leaves in + `plugin-security` and three in `plugin-sharing` were missed entirely and still + named the pre-rename concept. In `plugin-sharing` the same picklist key rendered + two different ways in one file — `position` was `Puesto` on the sharing rule and + `posición` on the record share, and `unit_and_subordinates` read `Rol y +subordinados` (naming the removed role concept) against `Unidad de negocio y +subordinados` on its sibling. + + Spanish-facing admins saw `Puestoes` as the object's plural label in navigation + and list views, and two different words for one recipient kind across two Setup + screens. + + Two regression guards now cover the classes involved: a malformed-compound and + stale-term check on the renamed security objects, and a self-consistency check + asserting that a picklist option key shared by several sharing objects renders + identically within a locale. Neither needs a reader of the locale to review it. + +- c73eacd: Reconcile audience-binding suggestions per organization (ADR-0090 D5/D9) + + `sys_audience_binding_suggestion` rows are per-tenant by construction — a + package suggests, and a TENANT admin confirms — but the reconciler read and + wrote through a module-level `{ isSystem: true }` context carrying no tenant. + On a shared-runtime multi-organization installation that produced ONE + organization-less row that every tenant read: the first admin to confirm or + dismiss answered for all of them, while the binding their confirm created + existed only in their own organization, so every other tenant's users never + received the package's default permission set and the surface reported the + suggestion resolved. + + - every read and write in the module now carries `{ isSystem: true, tenantId }` + — the anchor lookup, the "is it already bound?" lookup, and the + list/confirm/dismiss paths, not just the writes; + - `reconcileAudienceBindingSuggestions` is the new entry point the runtime + calls: one pass per organization under a `group`/`isolated` posture, and the + publishing organization alone on the package-door publish path; + - pre-existing organization-less rows are reaped before the passes and + regenerated per organization. Without that, ADR-0120 D3's platform bucket + keeps showing the old row to every tenant and the per-organization passes + create nothing at all. No permission binding is touched by the reap. + + A `single`-posture deployment is unchanged: exactly one organization-less pass, + and no reap. + +- 693c788: fix(security): the derived capability seeder owns its row by the same conjunction as the curated half + + `bootstrapSystemCapabilities`' DERIVED half tested ownership with `managed_by === 'platform'` alone. That was sufficient while `sys_capability.name` was unique installation-wide; since #8461 made it unique per ORGANIZATION (ADR-0120 D1) it also admits a platform-STAMPED row sitting inside an organization — the shape the file header names ("from seed data or a legacy import") and the shape #8470 refused to let `managed_by` alone stand for on the curated half, because it "would not carry that guarantee". The guard admitted such a row and rewrote its `label`/`description` with `humanize(name)`, which is the precise harm #5876 exists to prevent, while the platform (NULL-organization) bucket was never written. Every counter read zero and nothing was logged, because both #5876's counter and #8536's live on the branch where the guard DECLINES. + + The ownership test is now the same conjunction the curated half uses — `managed_by: 'platform'` AND `organization_id: null`. The lookup is unchanged (still cross-organization, by design). This restores a declared invariant rather than widening an accept set: what the derived half may refresh narrows to the rows it provably owns. + + **Reachability: a DORMANT asymmetry with a LIVE route — not a live defect.** No shipped artifact in this repository produces such a row: both capability seeders run under a system context with no tenant and never write `organization_id`, `normalizeManagedByVocab` does not touch this object, the admin door refuses the stamp outright (`assertSystemRowWriteGate`), and no `sys_capability` seed dataset exists anywhere in the repo. The ROUTE is nevertheless live and needs no unsupported step, and its load-bearing link is measured rather than argued: the seed loader writes as `isSystem` specifically so seeds can target `sys_*` tables, `defineSeed` type-checks `managed_by: 'platform'`, and on a per-organization replay the loader's tenant stamp short-circuits its own `sys_` exemption when an organization is pinned. Measured against the real seed loader, a `sys_capability` seed carrying `managed_by: 'platform'` was inserted with `organization_id` set when an organization was pinned, and inserted unstamped when none was — so the stamp is the pinning's doing, not a fixture artifact. Not claimed: how many organizations a given deployment replays seeds into is a provisioning question this repo cannot answer. So the fix lands as trap-removal and invariant-restoration, at exactly that severity — worth landing because the mistake would be invisible, ADR-0066 asset ownership forbidding the organization's own admin from editing or deleting the row through Setup. + + **Observability.** The newly-declined row flows through #8536's skip branch unchanged, so `skippedAuthored` and `unseededDerived` keep their exact documented meanings and their subset relationship; they simply become reachable on a state the broken guard used to swallow. The misplaced stamp gets its OWN signal, a new `platformStampedInOrg` counter on `CapabilitySeedResult`, rather than being folded into `unseededDerived` — "the platform's definition is missing" and "a row wears the platform's stamp where the platform never writes" are different facts, and the second is worth counting even when the first is false. The warning gains a matching remediation arm; the admin-authored row's "supported extension" sentence would be false here, and its "nothing for an operator to remove" advice would be wrong about the one row Setup cannot touch at all. + + **Not changed:** the platform bucket is still not backfilled when another row satisfies the lookup. That is #8552's ruled posture (no adoption, no backfill), shipped for the admin-authored case in #8536; the fix makes the state observable, not repaired, and the suite pins the bucket ABSENT so a future backfill has to fail rather than pass. + + `patch`, not `minor`: the behaviour change is a guard declining a row it should never have rewritten, plus diagnostics. `platformStampedInOrg` is a new field on a returned result object, but `bootstrapSystemCapabilities` is a boot-time internal whose only caller ignores the result shape — no consumer reads the type, so nothing gains a capability it can build on. + +- 147eadc: Correct `sys_position`'s translated uniqueness text in the `es-ES`, `ja-JP` and `zh-CN` bundles to say the machine name is unique **per organization** + + The English bundle and the object source both already state that a position's machine name is unique per organization — the declared index is `{ fields: ['name'], unique: 'organization' }`. The three other shipped locales still asserted bare, unqualified uniqueness, so an admin reading Setup in Spanish, Japanese or Chinese was told the name had to be free installation-wide, which the declared index does not enforce. + + Both places `sys_position` states the rule are corrected: + + - `fields.name.help`, the field help in the object's detail and edit views. It now also carries the source's current examples (`sales_manager`, `hr_specialist` rather than the superseded `admin`, `editor`, `viewer`). + - `actions.clone_position.params.name.helpText`, the help on the Clone Position dialog's API-name input — the text an admin reads at the moment they type a new name. + + Leaf string values only — no bundle structure was hand-edited. + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [845e164] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/metadata-core@17.1.0 + - @objectstack/formula@17.1.0 + ## 17.0.0 ### Major Changes diff --git a/packages/plugins/plugin-security/package.json b/packages/plugins/plugin-security/package.json index 9e360160c0..39661fe883 100644 --- a/packages/plugins/plugin-security/package.json +++ b/packages/plugins/plugin-security/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-security", - "version": "17.0.0", + "version": "17.1.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 81ce749e89..3c18dd0783 100644 --- a/packages/plugins/plugin-sharing/CHANGELOG.md +++ b/packages/plugins/plugin-sharing/CHANGELOG.md @@ -1,5 +1,221 @@ # @objectstack/plugin-sharing +## 17.1.0 + +### Minor Changes + +- 04d03c3: fix(security): a deactivated `sys_position` stops conferring sharing-rule record shares (#8710) + + + + **BREAKING for deployments that already deactivated a position named as a + sharing-rule recipient.** Their `sys_record_share` rows are revoked on the next + evaluation of that rule. + + #8613 made `sys_position.active` real at the authorization DERIVATION seam: a + deactivated position stops carrying its permission sets and its name leaves + `context.positions`. A sharing rule reaches users by a **second road that never + passes that seam** — `SharingRuleService.expandRecipient` → `PositionGraphService` + — so a rule sharing records with `cfo` kept sharing them after `cfo` was + deactivated, while the `deactivate_position` dialog promises, unqualified: + + > Deactivate this position? Users keep their assignment but the position stops + > granting permissions until re-activated. + + A record share is access, so the promise covers it. Maintainer ruling, + 2026-08-15, verbatim: **"Access-conferring paths filter deactivated positions; + addressing paths do not."** + + **What changes at runtime.** When a sharing rule's recipient is a position, the + evaluator reads the `sys_position` catalogue row and, if it is explicitly + deactivated, the rule expands to **nobody**: + + - no new shares are materialised for that position's holders; + - the shares it had already materialised are **revoked** on the next + reconcile — by `evaluateRule`, by the per-record hook pass, and by the + synchronous recipient-axis revoke (#7729) — because a rule that confers + nothing has an empty desired set and every existing grant is stale; + - the verdict is read with `isRowActive` (`@objectstack/core`), the same + predicate #8613 established, so the 1/0 and `'false'` storage shapes every + driver produces are judged identically. + + This is **not** a refactor and **not** a no-op: it changes who receives record + shares. + + **What deliberately does NOT change**, per the same ruling: + + - **approval ROUTING** keeps reading the raw directory — filtering there is + fail-OPEN (an approval step routing to nobody), #8613's carve-out, reaffirmed; + - **write gates and blast-radius reads** (`assertAudienceAnchorBindingGate`, + `setsBoundToPosition`, the delegated-admin surfaces) stay unfiltered, because + dropping a deactivated row there would make a refused binding permitted and + narrow a delegate's boundary — access _widening_; + - `PositionGraphService.expandPositionUsers`, the ADDRESSING primitive, is + untouched: the filter is at the sharing call site, so moving it down into the + helper would take the paths above with it. A pin fails if it ever does. + + **Rows that keep granting exactly as before:** a position whose `active` column + is absent or NULL (the predicate is "explicitly deactivated", never "explicitly + active"), a recipient name with no `sys_position` row at all (the + `sys_member.role` transition source of ADR-0057 D4), and a position whose + same-name row was deactivated in _another_ organization — `sys_position.name` is + unique per organization (#8468), so the flag is read off this rule's own + tenant's row. + + **Cost.** One `sys_position` read per distinct position per evaluator pass, + memoised for that pass only (a memo outliving the pass would make a deactivation + take effect late). The ruling accepted the extra read explicitly; the sibling + seam in #8613 needed none because both tables were already at hand there. + + **Before upgrading**, list the deactivated positions and check whether any is a + sharing-rule recipient whose shares are still meant to flow — re-activate those, + or move the grant onto the rule: + + ``` + GET /api/v1/data/sys_position?filters=[["active","=",false]] + GET /api/v1/data/sys_sharing_rule?filters=[["recipient_type","=","position"]] + ``` + +### Patch Changes + +- 4ea921c: Repair the ADR-0090 `sys_role` → `sys_position` rename in the es-ES object + translation bundles, and guard it mechanically. + + The rename half-landed in Spanish: an unreviewed substring find-replace produced + two non-words (`Puestoes` as the plural of `Puesto`, and `contpuesto` where the + replace ate the unrelated word `control`), while nine further leaves in + `plugin-security` and three in `plugin-sharing` were missed entirely and still + named the pre-rename concept. In `plugin-sharing` the same picklist key rendered + two different ways in one file — `position` was `Puesto` on the sharing rule and + `posición` on the record share, and `unit_and_subordinates` read `Rol y +subordinados` (naming the removed role concept) against `Unidad de negocio y +subordinados` on its sibling. + + Spanish-facing admins saw `Puestoes` as the object's plural label in navigation + and list views, and two different words for one recipient kind across two Setup + screens. + + Two regression guards now cover the classes involved: a malformed-compound and + stale-term check on the renamed security objects, and a self-consistency check + asserting that a picklist option key shared by several sharing objects renders + identically within a locale. Neither needs a reader of the locale to review it. + +- b705a6c: Repair the ADR-0090 `sys_role` → `sys_position` rename in the ja-JP object + translation bundle, and extend the mechanical guard to cover it. + + `sys_record_share.fields.recipient_id.help` still read "...ユーザー/グループ/ロールの + ID" — naming the pre-rename `role` concept — while the same bundle already + rendered the renamed concept correctly, twice, as `ポジション` + (`recipient_type.options.position` on both sharing objects), and the English + source for this exact leaf says `position`. Japanese-facing admins saw the + stale word in the Setup field-help tooltip for Record Share's `Recipient` field. + + `recipient-vocabulary-consistency.test.ts` (added when the es-ES half of this + same rename damage was repaired) now asserts a ja-JP stale-term rule alongside + the existing es-ES one, generalised into one per-locale table so a future + locale's rule is one entry, not a parallel `describe` block. The ja-JP pattern + excludes `ロールアップ` (rollup) and `ロールバック` (rollback) by lookahead rather + than `\b`, which does not bound katakana in JS regex (`\w` is ASCII-only) and + would otherwise match nothing at all. + +- b53d38e: fix(plugin-sharing): stamp the filter-subtree provenance mark at the read merge, so an author's own cross-field refusal stops being redacted on the sharing-composed path (#8430) + + `#8220` declared the filter-subtree provenance mark and set it at two read-scope + merge boundaries — `plugin-security`'s CRUD injection and `service-analytics`' + `withReadScope`. `plugin-sharing`'s read path is a **third**: on every read it + AND-composes an OWD / record-share visibility filter into `ast.where`, and it + stamped nothing. + + Two marks, and they are not the same job: + + - **the scopes it injects are marked `'policy'`** — the OWD/record-share read + filter, the delegator's intersected filter (ADR-0090 D10) and the + `sys_record_share` self-scope (ADR-0111 D5). **No behaviour change**: an + unmarked subtree already withheld, so these refusals kept the `#7929` + redaction before and keep it now. What changes is that the withhold becomes a + _declared_ verdict instead of an accident of the mark's absence — which + matters because an unmarked node **inherits its ancestor's mark positionally** + (`resolveFilterSubtreeProvenance`, innermost wins), so an unmarked policy arm + nested inside a vouched subtree would read as the author's. + - **the caller's own predicate is vouched `'author'`** immediately before the + rewrite that would otherwise make it unrecognisable to every later boundary. + This is the one user-visible change: an author's own `{ $field }` refusal on + an object with active sharing again names its columns, its operator and its + reason, instead of the redacted "operands withheld" text. + + **The vouch is an identity check, not a heuristic.** The mark is stamped only + while `ast.where` is still, by object identity, the `where` the caller handed + the engine. If a sibling middleware already composed into it, or the engine + rewrote it resolving filter tokens, identity fails and **nothing** is vouched — + the tree stays unmarked, and unmarked withholds. The arms of a pure + `{ $and: [ … ] }` root are vouched too, because `composeAnd`'s flattening branch + spreads that root's arms into a new object and would otherwise drop the vouch + out of the tree with it (that shape is what the array authoring form lowers to, + so it is the common case, not an edge one). + + **Fail-closed is unchanged in every direction**, and the pins say so at a real + `SqlDriver`: the injected scope still withholds, a policy arm sitting beside an + author-vouched arm in the same `$and` still withholds, and a predicate no + boundary ever vouched still withholds byte-identically to the policy case. + + **The write path is untouched.** `buildWriteFilter`'s composition is a different + question with different consequences and was not declared by `#8220`. + + Measured while implementing, and worth recording because the card says + otherwise: in a stack that composes **both** plugins, the author vouch was + already surviving. `plugin-security` is registered before `plugin-sharing` on + both real boot paths and `resolvePluginOrder` preserves insertion order, so + security vouches first and its mark — which lives on the caller's object — + travels through this composition untouched. The gap this fixes is a stack that + mounts `plugin-sharing` **without** `plugin-security`, where nothing else can + vouch for the caller. + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [845e164] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [7c2f386] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [8a9e7f4] +- Updated dependencies [3d0ded8] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/metadata-core@17.1.0 + - @objectstack/objectql@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/formula@17.1.0 + ## 17.0.0 ### Major Changes @@ -1800,10 +2016,10 @@ organization_id IS NULL)` when the caller carries an organization. Two write could never have granted any. **What deliberately did NOT move.** The existence check stays non-system-only. An - unresolvable object name is a NOT_FOUND (REST: 404) for a caller who typed it, + unresolvable object name is a NOT*FOUND (REST: 404) for a caller who typed it, but for the evaluator it is a stored `object_name` meeting an engine that may not have that schema registered at this instant — and absence of a schema is absence - of _evidence_ of inertness, not evidence of it. Hard-failing a reconcile pass on + of \_evidence* of inertness, not evidence of it. Hard-failing a reconcile pass on that would refuse a write nobody showed to be inert. An engine with no `getSchema` at all keeps its existing "it cannot know" skip. diff --git a/packages/plugins/plugin-sharing/package.json b/packages/plugins/plugin-sharing/package.json index 3981b8cdc1..e9706084a8 100644 --- a/packages/plugins/plugin-sharing/package.json +++ b/packages/plugins/plugin-sharing/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-sharing", - "version": "17.0.0", + "version": "17.1.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 a9ec4cd1a1..70edfc250f 100644 --- a/packages/plugins/plugin-webhooks/CHANGELOG.md +++ b/packages/plugins/plugin-webhooks/CHANGELOG.md @@ -1,5 +1,104 @@ # @objectstack/plugin-webhooks +## 17.1.0 + +### Patch Changes + +- b278695: fix(webhooks): refuse a malformed `sys_webhook.headers_secret` at the write door instead of at the next delivery (#8566) + + + + `sys_webhook.headers_secret` is a `Field.secret()` whose plaintext is **not** an + opaque blob: it is a serialized header map with a required shape — a flat JSON + object of string values — and `parseStoredHeaders` is its only reader. Nothing + validated that shape on the way in. The ordinary data API accepted any string, + encrypted it like any other secret, minted a real `sys_secret` row, and left the + column holding a perfectly valid `secret:` ref that read back as the mask with + `active: true`. + + Measured on a real engine through `engine.update()` — the ordinary data API, no + privileged access — every one of these was **accepted** and is a value the + plugin can never use: `{}`, `[]`, `{"X-Count": 5}`, a nested object, and + `{X-Team: crm}` (a typo). The field is directly admin-authorable and its own + description instructs the author to type a JSON object into it, which makes a + typo the _expected_ failure rather than an exotic one. + + **This is not an exposure fix and must not be read as one.** #8558/#8565 already + closed the consumer half: a webhook whose stored header map does not come back + as a flat string map parks the subscription and reports at `error`, rather than + delivering header-less with a valid signature. Nothing leaks, and nothing is + silently lost today. What this changes is **when the author finds out** — at the + write door where they typed it, instead of at the next matching record change, + an unbounded time later and in a different surface. + + **What is refused:** a `headers_secret` plaintext that does not parse back as a + flat JSON object of string values with at least one entry, with a located + ADR-0112 `VALIDATION_ERROR` / 400 naming `sys_webhook.headers_secret`, quoting + the shape the field's own description asks for, and diagnosing the specific + spelling (invalid JSON / an array / an empty object / which key's value is not a + string). ⛔ The message never echoes the rejected value — this column carries + credentials, and quoting the input would print an `Authorization: Bearer …` into + logs and error bodies, re-opening in the diagnostic exactly the exposure #7986 + moved this field onto the encrypted channel to close. It names header _keys_ and + value _types_ only. + + **What stays accepted, byte for byte:** every valid flat string map (as JSON + text, or as an authored object the engine serializes into the same form); `null` + to clear; an omitted key to leave the stored value unchanged; and an **echoed + read-mask**, so the ordinary Setup-form round-trip (GET a row, edit an unrelated + field, PATCH it back) is untouched. `""` is deliberately passed through to + #8559's `EmptyCredentialWriteError` rather than re-refused here — one door, one + owner, one message. + + **Where it runs, and why that is the whole mechanism:** a `beforeInsert` / + `beforeUpdate` hook on `sys_webhook`, bound by `WebhookOutboxPlugin` before its + first seeded write. It has to run _before_ the engine's `encryptSecretFields` — + one step later the plaintext is gone and the column holds an opaque ref, so a + validator behind it would have nothing left to validate. The suite measures that + ordering rather than asserting it: every refusal pins that **no `sys_secret` + cipher row was minted**, which is only true if the gate ran first. + + A hook rather than checks on the plugin's own write paths + (`bootstrapDeclaredWebhooks` / `headersPatch` / the migration sweep), because a + direct `PATCH /api/v1/data/sys_webhook` goes through none of them and that is + the measured trigger. Those paths inherit the validation through the hook and + deliberately carry no second check. + + A general `secret`-channel plaintext validator — letting any `secret`-typed + field declare its own plaintext shape — is the principled generalization and is + recorded as the **promotion path**, not built here: it becomes the shape the + moment a second shaped-plaintext `secret` field exists (maintainer ruling + 2026-08-13; one consumer does not justify a general capability). + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/service-messaging@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/plugins/plugin-webhooks/package.json b/packages/plugins/plugin-webhooks/package.json index f52f762e86..c58c4ac54b 100644 --- a/packages/plugins/plugin-webhooks/package.json +++ b/packages/plugins/plugin-webhooks/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/plugin-webhooks", - "version": "17.0.0", + "version": "17.1.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 5bea6f0a63..753d2cf79f 100644 --- a/packages/qa/dogfood/CHANGELOG.md +++ b/packages/qa/dogfood/CHANGELOG.md @@ -1,5 +1,80 @@ # @objectstack/dogfood +## 0.0.41 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [1408fe3] +- Updated dependencies [cf0d902] +- Updated dependencies [498f4e8] +- Updated dependencies [cc5c07b] +- Updated dependencies [f57fb38] +- Updated dependencies [04d03c3] +- Updated dependencies [8656d67] +- Updated dependencies [6feac91] +- Updated dependencies [5f5e234] +- Updated dependencies [4ea921c] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [b705a6c] +- Updated dependencies [f8eb736] +- Updated dependencies [20067c5] +- Updated dependencies [d09d0fd] +- Updated dependencies [ff4ba6a] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [c73eacd] +- Updated dependencies [693c788] +- Updated dependencies [845e164] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [b53d38e] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [147eadc] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [7c2f386] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [8a9e7f4] +- Updated dependencies [3d0ded8] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] +- Updated dependencies [b278695] + - @objectstack/platform-objects@17.1.0 + - @objectstack/plugin-auth@17.1.0 + - @objectstack/plugin-security@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/plugin-audit@17.1.0 + - @objectstack/plugin-sharing@17.1.0 + - @objectstack/mcp@17.1.0 + - @objectstack/service-analytics@17.1.0 + - @objectstack/metadata-core@17.1.0 + - @objectstack/plugin-email@17.1.0 + - @objectstack/objectql@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/plugin-webhooks@17.1.0 + - @objectstack/metadata@17.1.0 + - @objectstack/service-messaging@17.1.0 + - @objectstack/service-storage@17.1.0 + - @objectstack/verify@17.1.0 + - @objectstack/example-crm@4.0.93 + - @objectstack/example-showcase@0.3.15 + - @objectstack/connector-mcp@17.1.0 + - @objectstack/connector-openapi@17.1.0 + - @objectstack/connector-rest@17.1.0 + ## 0.0.40 ### Patch Changes diff --git a/packages/qa/dogfood/package.json b/packages/qa/dogfood/package.json index 38ba86ff24..60fc8ffe7e 100644 --- a/packages/qa/dogfood/package.json +++ b/packages/qa/dogfood/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/dogfood", - "version": "0.0.40", + "version": "0.0.41", "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 d08dbad6ef..a44896d90b 100644 --- a/packages/qa/downstream-contract/CHANGELOG.md +++ b/packages/qa/downstream-contract/CHANGELOG.md @@ -1,5 +1,25 @@ # @objectstack/downstream-contract +## 0.0.39 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [f57fb38] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + ## 0.0.38 ### Patch Changes diff --git a/packages/qa/downstream-contract/package.json b/packages/qa/downstream-contract/package.json index 3995738708..bb70093570 100644 --- a/packages/qa/downstream-contract/package.json +++ b/packages/qa/downstream-contract/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/downstream-contract", - "version": "0.0.38", + "version": "0.0.39", "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 c28ca96acb..7c8833f574 100644 --- a/packages/qa/http-conformance/CHANGELOG.md +++ b/packages/qa/http-conformance/CHANGELOG.md @@ -1,5 +1,16 @@ # @objectstack/http-conformance +## 0.1.1 + +### Patch Changes + +- Updated dependencies [e43d63a] +- Updated dependencies [5f5e234] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [402c125] + - @objectstack/core@17.1.0 + ## 0.1.0 ### Minor Changes diff --git a/packages/qa/http-conformance/package.json b/packages/qa/http-conformance/package.json index 273fa0227c..ec3ec6c4e9 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.0", + "version": "0.1.1", "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 7c84b9a7eb..23ca701e12 100644 --- a/packages/rest/CHANGELOG.md +++ b/packages/rest/CHANGELOG.md @@ -1,5 +1,313 @@ # @objectstack/rest +## 17.1.0 + +### Patch Changes + +- e7bccaa: fix(rest): anchor `looksLikeMissingRelation` on the driver's quoted template (#8264) + + `mapDataError`'s Postgres limb read `relation` and `does not exist` anywhere in + the message, not necessarily the same sentence — so ordinary business prose + using both words (`This relation does not exist in the diagram`) matched. + `does not exist` is ordinary business English; #8132 already anchored the + shared `@objectstack/types` leak predicate on the driver's own quoted + template for exactly this reason, and pinned the identical string as a + negative case. This file's copy of the same question was not covered by that + change (different package, different call site) and kept the loose reading. + + Anchored the same way here — a quoted identifier required between `relation` + and `does not exist` — as a locally-owned pattern rather than a call into the + shared leak predicate: that + predicate answers a different question ("may this be withheld from the + client"), and its other limbs (`sqlite_`, `unique constraint`, `foreign key`, + a bare SQL statement) have nothing to do with this file's question (is this + specifically an unknown-relation condition, for the 404-vs-500 split + `looksLikeMissingRelation` feeds). `relation-sub-object.ts` documents "two + widths, on purpose" for a neighbouring pair of consumers that ask genuinely + different questions; that does not extend to the two USES inside this file, + which both ask the same question and share one predicate correctly. + + **Both of the predicate's two call sites are covered, not just the reported + one:** the `DATA_STORE_FAULT` (500) gate the issue named, and the + `looksLikeUnknownObject` (404) limb the issue's own text did not measure. A + business message no longer gets mislabelled a `DATABASE_ERROR`, and a + crafted unquoted-but-attributable message no longer gets silently answered + `OBJECT_NOT_FOUND` — both now fall through to the generic, still-sanitised + terminal fault, which is the direction the branch's own #5462 comment already + argues for ("the safe way to be wrong is loud"). + + No reachable production path producing the unanchored shape was found at this + call site — this is consistency/invariant restoration between two spellings + of one question, not a fix for a demonstrated live misclassification. + +- 5047cb8: fix(metadata-protocol): scope the metadata audit read to the caller's organization (#8747) + + `ObjectStackProtocolImplementation.auditMetaItem` declared + `organizationId?: string | null` and never read it. The comment directly above + its query described the filter it would have built — "include rows for the + specific org AND env-wide (`organization_id IS NULL`) rows" — while the `where` + was exactly `{ type, name }`. The parameter was dead on the caller side too: + `GET /api/v1/meta/:type/:name/audit` never passed one. + + The consequence was a cross-tenant disclosure, measured rather than inferred: + three saves of one view name under two organizations and env-wide, then one + `auditMetaItem({ type, name })` read, returned all three organizations' rows — + and with each row its `actor`, `note`, `lock_state`, `code`, `operation`, + `source` and `request_id`. Nothing compensated lower down. The driver's tenant + wall never engaged, because it is armed only from an execution context this + read did not pass; the security plugin's Layer 0 never engaged, because the + middleware short-circuits on a principal-less call long before the field gate + that would have carried it; and no tenancy posture would have supplied the + scope either. The route carries no capability gate — unlike its `PUT` twin, + which gates on `manage_metadata` — so the reachable cohort was any + authenticated principal of any tenant, on the published `meta.getAudit` SDK + surface. + + The query now builds the described filter: rows for the caller's organization + plus env-wide (`organization_id IS NULL`) rows, and nothing else. The env-wide + limb is load-bearing rather than defensive — the REST `PUT /meta/:type/:name` + door passes no organization, so every row it writes is stamped + `organization_id: null`, and an equality-only filter would have blanked the + audit tab on those deployments instead of scoping it. A read that resolves no + organization is fail-closed onto the env-wide rows, symmetric with what an + org-less write produces, so omitting the parameter is no longer a skeleton key. + + The REST route supplies the organization from the execution context it already + resolves for 40-plus handlers, adding no new organization-resolution plumbing + to `packages/rest`. The same call also stopped passing `environmentId`, which + the request type never declared and the method body never read; environment + scoping is unaffected, since it comes from which protocol instance is resolved + rather than from the request payload. + + Behaviour change worth stating plainly: a caller that previously saw another + tenant's metadata audit rows for a same-named item no longer sees them. Own-org + and env-wide rows are unchanged. + +- 3ab2488: fix(rest): stamp the export download's filename in the business timezone (#8484) + + `exportContentDisposition` built the `-YYYYMMDD-HHMMSS` half of the suggested + filename from process-local getters (`now.getFullYear()` / `getHours()` / …), + which read the deployment host's `TZ` — a hosting fact, not the caller's + business timezone. The route had already resolved that timezone one frame up + (`ExecutionContext.timezone`, the platform-default → global → tenant cascade) + and simply never passed it here. + + After #8373 moved the export's **contents** onto the business timezone, the + filename was the last export surface still on the host clock, so the two + disagreed exactly when `TZ` was not the business zone: a container at `TZ=UTC` + serving an Asia/Shanghai tenant downloaded `orders-20260731-220000.csv` whose + first row read `2026-08-01 06:00:00` — off by a day, and at a month boundary by + a month. The name and the rows inside it now read one clock. + + **The no-timezone fallback stays PROCESS-LOCAL, deliberately not UTC.** This is + the opposite of the cell path's UTC fallback, and the asymmetry is the point: + each fallback preserves the historical output of the surface it serves. The + cells were hardcoded to UTC before #8373; this filename has always used the + process clock. Defaulting it to UTC would look safer while silently re-timing + the filename of every deployment that sets a host `TZ` but resolves no business + timezone — a user-visible rename for zero correctness gain. An explicitly + resolved `'UTC'` is a _resolved_ zone, not a missing one, and does produce a UTC + stamp regardless of the host. + + The shared clock helper is split rather than parameterised with a default: + `zonedWallClock` now returns `null` when no usable zone resolves, and each of + the two callers supplies its own fallback at the call site where it can be read + and pinned. Baking either fallback into the shared helper would silently + re-time the other surface. + + Filename **naming** is untouched — label selection, sanitization and the RFC + 5987/6266 `filename*` encoding all behave exactly as before, and the export's + contents are not touched at all. + +- 24173e9: fix(rest): read an offset-free import cell in the business timezone, not the host `TZ` (#8485) + + `parseDateCell` ended in `new Date(s)`. A spreadsheet cell like + `2026-08-01 06:00:00` carries no offset, so ECMAScript resolves it against the + **process** timezone, and the instant bulk import stored became a property of + the deployment host: + + ``` + TZ=Asia/Shanghai → 2026-07-31T22:00:00.000Z + TZ=UTC → 2026-08-01T06:00:00.000Z + ``` + + Same file, same tenant, same cell — eight hours apart, decided by a setting + nobody authoring the spreadsheet can see, and never consulting the business + timezone the route had already resolved one frame up + (`ExecutionContext.timezone`, the platform-default → global → tenant cascade). + + Since the export renders `datetime` cells in that business timezone (#8373), the + advertised export → edit in a spreadsheet → re-import round trip was lossless + only where the host `TZ` happened to equal the business zone. `import-coerce.ts` + opens by calling itself "the inverse of `export-format.ts`"; it now is one, and + the regression proof asserts inverse-ness on the **pair** — every fixture under + a host `TZ` deliberately different from the business timezone, because a test + that runs only under a matching `TZ` cannot fail. + + **An offset-free datetime cell is now read in the caller's business timezone**, + through `@objectstack/core`'s new `zonedWallClockToUtcMs` — the DST-safe wall + clock → instant primitive that `zonedDateStartToUtcMs` (the date-bucket drill + path) is now the midnight special case of. One implementation of zone + arithmetic, `Intl` offsets from the platform tz database, never hand-rolled; + generalising the existing one rather than hand-rolling a second in `rest` is + what keeps the export and import halves of this seam from drifting apart again. + Two wall clocks are not a bijection with instants, and both degenerate DST + readings resolve to the earlier candidate instant — a gap reading lands just + before the gap, an ambiguous reading on its first occurrence (pinned, measured). + + Three things deliberately do **not** move: + + - **A cell that carries an explicit offset** (`…Z`, `…+08:00`) already names one + instant and is honoured exactly as written. This change affects naive cells + only. + - **The date-only fast path stays UTC.** `YYYY-MM-DD` is UTC per ECMAScript and + a `date` is a timezone-naive calendar day (ADR-0053); sweeping it into the + zoned handling to make the code look uniform would silently re-time every + date-only import to fix nothing. + - **No timezone resolved ⇒ UTC**, never the process clock. That is the fallback + the export's cell path takes in the same case, so the round trip stays exact + for deployments that configure no zone — and a process-`TZ` fallback would + preserve the defect for exactly the deployments that cannot see it. This is + the one **behaviour change for existing deployments**: a host with a non-UTC + `TZ` and no resolved business timezone previously read naive cells in the host + clock and now reads them as UTC. An explicitly resolved `'UTC'` is a resolved + zone, not a missing one. + + Two adjacent legs of the same defect, both on the naive-cell path: + + - **A naive cell landing in a `date` or `time` field** now takes the typed + components verbatim (`2026-08-01 06:00:00` → `2026-08-01` / `06:00:00`). + Those branches also read the process clock, so a host east of the cell stored + the _previous calendar day_ for a `date` column. + - **An xlsx date cell.** An Excel serial date carries no timezone; ExcelJS + materialises it as a `Date` whose UTC components are the sheet's wall clock, + and `import-prepare.ts` rendered it with `toISOString()` — stamping a `Z` the + file never had. That fabricated offset then outranked the business timezone by + the very carve-out above, so every real date cell in a user-authored workbook + imported as UTC whatever the tenant's zone. It now flattens to the same + offset-free `YYYY-MM-DD HH:mm:ss` a CSV export writes, which is what that + function's contract already claimed to produce. + +- e6e1de4: fix(rest): `DELETE /api/v1/packages/:id` answers a driver fault as a 5xx, and stops swallowing coded refusals (#8275) + + `packageService.delete` swallowed every throw and reported failure by returning + a bare `{ success: false }`, so the door answered + `400 PACKAGE_DELETE_FAILED`. The statement behind it is + `DELETE FROM sys_packages WHERE id = ? [AND version = ?]`, so a missing table, a + lock timeout or a foreign-key restriction — a **server** fault — was answered as + a client error: it invited the caller to fix a request that was never the + problem, and it hid a real fault from every dashboard that buckets by status. + + This is the sibling of what #8016 fixed on the throw path and #8131 fixed for + `publish`. `service-package` had been left **partially converted** by #8131 — + the same service answering two different classifications for the same kind of + fault — and this closes that. + + **Two changes, both small:** + + - `delete`'s catch re-throws a throw that **declares its own status**, so a + coded refusal reachable from this call path keeps the producer's status and + code through the door's #8016 mapping (a `409 DESTRUCTIVE_CHANGE` stays a 409) instead of being flattened into one 400. It reuses the existing + `declaresHttpAnswer` predicate rather than declaring a second one. + - an undeclared throw stays a returned failure, and the door answers it **500**. + + ⛔ The discriminant is the **status** channel, never `.code`. Every SQL driver + populates a string `code` on its errors (`ERR_SQLITE_ERROR`, `SQLITE_ERROR`, the + SQLSTATE `42P01`, `ER_NO_SUCH_TABLE`), so a `.code`-reading predicate re-throws + genuine driver faults as if they were refusals — resolving them to a `500 +INTERNAL_ERROR` that carries the driver's own message. Pinned per dialect in + `delete-driver-fault.test.ts`, on this seam rather than inherited from + `publish`'s suite by analogy. + + **4xx is not swept**, which is the other half of the fix: the + repeated-`?version=` refusal is checked before `delete` is called at all, + `PACKAGE_DELETE_PARTIAL` keeps its 400 (per-item uninstall failures are a + different outcome), a declared 4xx thrown from below keeps its own status and + code, and a declared 5xx keeps its own too. + + **No message changed, and that is deliberate.** Unlike `publish`, this path + never disclosed anything: the door builds its sentence from the request's own + `:id` and `?version=`, and the producer returns a bare flag with **no message + channel at all**. Mirroring `publish`'s `driverFault` message here for symmetry + would have _created_ a channel to the wire that nothing filters — the 5xx + withhold (#8086) lives in `sendThrownError`, which a returned failure never + reaches at any status. The new suites pin that absence from both sides: the + producer's returned shape has exactly one key, and the door answers its own + sentence even when handed a producer that grows a message. + + Verified against a real `node:sqlite` database running the real statements from + `index.ts` — including a genuine foreign-key restriction, the fault family only + `DELETE` can have. + +- 7fc01db: REST `/meta` write doors now carry the caller's organization, so audit rows are no longer stamped environment-wide + + `PUT /meta/:type/:name` (both arities), `DELETE /meta/:type/:name`, + `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` passed no + organization, so every `sys_metadata_audit` row a REST-authored metadata write produced was + stamped `organization_id: null`. Composed with the scoped audit read shipped alongside it — + which returns own-org rows **plus** environment-wide ones, a limb that is required rather + than optional — that left every REST-authored audit row readable by every tenant, carrying + its `actor`, `note`, `lock_state` and `request_id`. The read side could not close this: the + rows were genuinely unscoped, so no filter could separate them. + + The organization is taken from the execution context these doors already resolve, and is + threaded through `organizationIdForMetaWrite` — the same registry-derived predicate the + runtime `/metadata` dispatcher uses. Types the registry declares `allowOrgOverride: true` + (`view`, `dashboard`, `report`, `translation`, `email_template`) now scope both the overlay + row and its audit row to the caller's organization; every other type continues to write + environment-wide, because its write genuinely is environment-wide and the protocol refuses + an org-scoped write for it. `null` is now reserved for writes that really are + environment-wide. + + Two behaviour changes ride along, both required for the fix to be usable rather than + separate improvements: `publish` and `rollback` resolve their row through the organization, + so scoping the save without scoping them would have broken the draft → publish loop; and + `GET /meta/:type/:name/published` is now organization-scoped (organization-first, then + environment-wide), without which it would answer 404 for an item the same caller had just + published through the same transport. + + `organizationIdForMetaWrite` / `declaresOrgOverride` moved from `@objectstack/runtime` into + `@objectstack/metadata-core` so both doors share one implementation — `@objectstack/rest` + cannot import from `runtime`, which depends on it. Runtime behaviour is unchanged. + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [e6e1de4] +- Updated dependencies [845e164] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/service-package@17.1.0 + - @objectstack/metadata-core@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/observability@17.1.0 + ## 17.0.0 ### Major Changes @@ -261,8 +569,8 @@ validation }`. Collapsing three layers into one `item` would delete the being a hand-listed union and a bare `string` respectively and reference the catalog, so the three cannot drift apart. - Lowercase is deliberate, not an oversight against ADR-0112's SCREAMING_SNAKE: a - top-level code names the condition the _request_ hit, while a field-level code + Lowercase is deliberate, not an oversight against ADR-0112's SCREAMING*SNAKE: a + top-level code names the condition the \_request* hit, while a field-level code names the _constraint_ the value violated — and constraints are declared in the metadata's own snake_case, so `max_length` the code and `max_length: 50` the property are the same word on purpose. @@ -1968,7 +2276,7 @@ NULL`. #7705 proved that narrowing orphans every org-scoped row — the same recover, which is the same disposition `rest-requireauth-default-flip` took for its own default flip. - + ### Patch Changes @@ -6129,10 +6437,10 @@ IEmailService`, `ExternalDatasourceService implements IExternalDatasourceService write could never have granted any. **What deliberately did NOT move.** The existence check stays non-system-only. An - unresolvable object name is a NOT_FOUND (REST: 404) for a caller who typed it, + unresolvable object name is a NOT*FOUND (REST: 404) for a caller who typed it, but for the evaluator it is a stored `object_name` meeting an engine that may not have that schema registered at this instant — and absence of a schema is absence - of _evidence_ of inertness, not evidence of it. Hard-failing a reconcile pass on + of \_evidence* of inertness, not evidence of it. Hard-failing a reconcile pass on that would refuse a write nobody showed to be inert. An engine with no `getSchema` at all keeps its existing "it cannot know" skip. diff --git a/packages/rest/package.json b/packages/rest/package.json index f569bd00cb..3d4d153ed0 100644 --- a/packages/rest/package.json +++ b/packages/rest/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/rest", - "version": "17.0.0", + "version": "17.1.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 939c2d274b..773a5b50b0 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -1,5 +1,417 @@ # @objectstack/runtime +## 17.1.0 + +### Minor Changes + +- e43d63a: feat(identity): API keys are minted against the minter's active organization, and carry it into the request (#8287) + + + + On a deployment running `OS_TENANCY_POSTURE=isolated`, a minted API key could + read **nothing at all**. `sys_api_key` carried no organization column, so key + authentication established a user but no active organization — and the + `isolated` Layer 0 wall is `organization_id = activeOrganizationId`, which with + no active organization matches no row. Every organization-scoped read answered + `200` with `total 0` while the console went on offering minting, so a tenant + admin could mint a valid-looking secret and discover only at call time that it + read nothing. (There was no cross-tenant leak — the failure was in the other + direction.) + + **The column was absent by an inherited rule, not by oversight.** + `resolveInjectedSystemColumns` injects `organization_id` into every registered + object _except_ `managedBy: 'better-auth'` ones, and `sys_api_key` carries that + flag — even though better-auth's `apiKey` plugin is not loaded and the table is + hand-rolled ObjectStack. So the fix needs the declaration _and_ the ADR-0105 D7 + extension-field registration to stay consistent. The read side, by contrast, + was **already wired**: `resolveApiKeyPrincipal` already read an organization + into `tenantId` and `resolveAuthzContext` already adopted it — it was reading a + column no mint path ever wrote. + + **What changes** + + - `sys_api_key` declares `active_organization_id` (+ index, and the column is + shown in the "My Keys" and "All" list views, because the card's complaint was + a credential whose reach its owner could not see). + - `POST /api/v1/keys` **inherits** the caller's active organization — there is + deliberately no org parameter and no cross-org key — and **re-checks the + caller's `sys_member` membership at mint time**, honouring ADR-0091 validity + windows. Under a walled posture it refuses (400) rather than minting a key + with no organization, and refuses (403) for an organization the caller is not + a member of. The mint response echoes the organization the key is pinned to. + - The verifier reads **one spelling** (Prime Directive #12): the + `row.organization_id ?? row.organizationId` chain it used to carry was a + consumer-side tolerance for a producer that did not exist. + - An **ex-member's key fails closed at verify time** — no principal, not a + degrade to a user-only principal, which would resurrect the same + `200 + total 0` silent-empty. Checked at verify rather than by revoking on + membership loss, because membership ends through many paths (better-auth org + endpoints, SCIM, a direct `sys_member` delete, a lapsing validity window) and + a hook must catch every one or it silently misses. It costs **zero extra + queries**: the resolver has already read `sys_member` for this user. + - **Pre-existing org-less keys are never backfilled** — that would silently + upgrade credentials minted under a different promise. They keep working under + `single` (no wall) and under `group` (whose wall derives from the owner's + memberships independently of the active organization, so they already work + there), and are **refused under `isolated`**, where they are provably dead + today. + + **The column is deliberately named `active_organization_id`, not + `organization_id`** — the `sys_session` spelling, for the same concept: the + organization a credential makes _active_. `objectHasOrgIdField` tests for the + literal `organization_id`, and Layer 0 exempts objects without it, so the other + name would have made `sys_api_key` itself org-walled. Both walled postures + exclude NULL, so every pre-existing org-less row would have vanished from its + **own owner's** "My Keys" list while, under `group`, continuing to + authenticate — a live credential nobody could see or revoke, which is a fresh + instance of the very class this change removes. + +- 20067c5: fix(runtime,mcp,service-datasource): the #6504 consumer sweep — three list consumers stop making claims a known-partial read cannot support (#6504) + + + + `IMetadataService.listDiagnosed?(type)` (PR #7721) lets a plural read say whether + its answer can be trusted as complete. This is the consumer half: the callers + that were restating a possibly-short listing as a fact about the environment. + + Each consumer was qualified individually, per PR #6051's discipline, and most + were left alone — a caller publishing a snapshot with no count has nothing to + mis-state. Three make a claim, and each now withholds exactly that claim while + still serving everything it could read: + + - **`removeDatasource` no longer deletes on a bound-object count it could not + take completely.** The guard `if (bound > 0) throw` is the only thing standing + in front of an irreversible delete that also unbinds the datasource's secret, + and its input is derived from the metadata service's object listing. During a + loader outage that listing goes silently short, and the worst value is the + benign one: `0` reads exactly like "nothing is bound", so the guard OPENED. + It now refuses with `SERVICE_UNAVAILABLE` / 503 — a dependency outage the + operator can retry, not a client error — and the record, its credential and + its pool all survive. + - **The MCP `list_objects` tool stops publishing `totalCount` on a known-partial + listing.** This is the same claim PR #7721 removed from the + `objectstack://objects` resource, on the other MCP primitive: same payload + shape, different door, never covered. A degraded read now serves the same + objects with `totalCount` **absent** and `partial` / `returnedCount` / + `warning` plus the 503 envelope in its place, so a client reading the total + gets `undefined` rather than a believable wrong integer. Both bridges + implement it — stdio (`@objectstack/mcp`) and HTTP (`@objectstack/runtime`) — + because a completeness claim must not depend on which transport a client + connected over. + - **The ADR-0015 §5.2 boot gate stops announcing an all-clear over a sweep it + could not complete.** It validated whatever `listObjects()` returned and then + logged _all federated objects match their remote schema_, with a count. + Federated objects behind an unreadable loader were never validated, so + `onMismatch: 'fail'` could not have fired for them. The gate now warns that + the swept set was incomplete and names what it did validate. ⛔ It does **not** + abort boot on a degraded metadata read: turning a transient outage into a + refusal to start would be a new failure mode bought with a diagnosis fix. + + Every new member is optional in the same way `listDiagnosed` itself is: a host + whose metadata service predates the verdict behaves exactly as it did before, + and a service without it reports nothing degraded — precisely what it could + express. + +### Patch Changes + +- 3d61924: fix(runtime): a `PUT /meta/:type/:name` with a falsy body is refused instead of being answered as a READ (#8842) + + The http-dispatcher's metadata save branch opened `if (method === 'PUT' && body)`. + The `&& body` conjunct was not a guard — it was a hole. Every path inside that + block returns (including the terminal `501`), so a falsy body did not merely skip + the write: execution continued past the whole save block into the read `try` + below, which resolved the type and answered the ordinary metadata **read**. + + A caller who asked to write received what looks like a successful read. No + status, header or field distinguished it from a real write acknowledgement — + the shape "Absence must be loud" exists to prevent. The `manage_metadata` + capability gate, which is the first thing the save branch does, was skipped + entirely for such a request as well. (Not an escalation: the request was answered + by the read path, which runs the same ADR-0106 mask a plain `GET` runs, and + nothing was written. Skipping a write gate on a request that performs no write + grants nothing — the defect is the lie, not a privilege.) + + **Reachable from an ordinary client, measured rather than read.** The host that + mounts this dispatcher path is the Hono adapter's catch-all, which builds the + body as `await c.req.json().catch(() => ({}))`. That `.catch` covers a parse + _failure_ — an empty body or garbage lands on `{}` — but not a _successful_ + parse of a falsy JSON value. Driven against a real Hono app, a `PUT` with + `content-type: application/json` and a payload of `null`, `false`, `0` or `""` + each arrive at the dispatcher falsy. + + **The fix matches the sibling transport rather than inventing a second answer.** + `packages/rest`'s `PUT /meta/:type/:name` already folds `req.body ?? {}` and + proceeds into the save unconditionally, so its bodyless writes are refused + downstream by the per-type schema with `422 INVALID_METADATA`. The dispatcher now + does the same: the branch keys off the method alone, and a nullish body folds to + `{}`. Two doors onto one `saveMetaItem` disagreeing about what a bodyless + metadata write means was the actual defect. + + What callers see instead of a spurious read: + + - holding `manage_metadata` → `422 INVALID_METADATA` from the per-type schema, + with the structured `issues` the Studio form reads; + - not holding it → `403 PERMISSION_DENIED` from the capability gate, which now + runs on this request at all. + + A `PUT` carrying a real body is untouched — it saves exactly as before, and the + body still reaches the writer verbatim. + +- e783e16: fix(runtime): the HTTP MCP prompt bridge reads the merged skill listing, so a runtime meta PUT finally reaches `/api/v1/mcp` (#8726) + + + + `PUT /api/v1/meta/skill/{name}` with `{active:true}` returned 200 and the flip + was **not** reflected over MCP prompts. This is the second of the two skill + reads behind that symptom, and the one #8328's own three-step reproduction + actually runs through. + + The two surfaces read different layers: + + - **stdio** (long-lived server, `packages/mcp` → `bridgePrompts`) — fixed by + PR #8724. + - **HTTP** `/api/v1/mcp`, built **per request** by `packages/runtime` + (`domains/mcp.ts` → `buildMcpBridge.listSkills`) — this change. It read + `metadataService.list('skill')`, the registry/loader listing, one layer + **below** where any `sys_metadata` overlay merging happens. So the overlay row + the PUT wrote was never seen, while `GET /api/v1/meta/skill` served it + correctly from the merged read: two surfaces, one skill name, two answers. + + The read now goes through the protocol layer's `getMetaItems`, per the + maintainer's ruling on #8328 (2026-08-13, option 3) — and ⛔ **not** by pushing + the overlay merge down into `MetadataService.list()` for every consumer, which + is a wider contract change archived unscheduled as #8722. + + **Resolved per request, on the same per-environment seam `getMeta()` already + uses** — never captured once at boot, which on a multi-tenant host would serve + one environment's overlay rows to every other one. Pinned by two + multi-environment tests. + + **⛔ No fallback to the un-merged listing when the merged read throws.** That + would answer registry rows in the shape of merged ones — this exact defect, + restored silently at the moment the overlay store is unreadable, which is + precisely when an overlay is most likely to be the thing being missed. The + throw travels to the MCP client instead. Structural absence is treated as the + different thing it is: a host assembled without the metadata protocol has no + merged read to offer, so it keeps the registry listing unchanged, including the + load-bearing `?? []` for a host with no metadata service at all. + + **#6504's completeness verdict is added here rather than preserved** — unlike + the stdio bridge, this read never had a diagnosed wrapper, so a known-partial + skill surface presented as a complete one. The verdict is asked of + `IMetadataService.listDiagnosed` directly rather than taken from the merged + read, because `getMetaItems` swallows a MetadataService read failure into its + own `catch` and reports a merged list either way. It is reported at `warn` + (functional degradation: the prompt surface is visibly smaller than the + environment declares), and a verdict probe that itself fails is reported as + "could not be determined" rather than failing a read whose items succeeded. + +- 4fc4a3c: **`DELETE` / `PATCH` / `POST` on the dispatcher's `/metadata/:type/:name` are refused with `405` instead of being answered as reads.** + + The `parts.length >= 2` block carried exactly one method-sensitive branch — the `PUT` save — and the read that followed it had no method guard, so every other verb fell into it and was served the ordinary metadata read. `DELETE` was the sharpest case: a caller asking to delete a metadata item received `200` plus the item document, which is indistinguishable from a successful destructive call, while nothing was deleted and `protocol.deleteMetaItem` was never invoked. No status, header or field separated any of those answers from a real `GET`. + + The block now answers `405 METHOD_NOT_ALLOWED` with an `Allow: GET, HEAD, PUT` header naming what it serves, aligning it with every other route in the same file (which already guard their verb). `GET`, `HEAD` and `PUT` are unchanged, and a request that passes no method still defaults to the read. + + Note this narrows an accepted surface: a client that was relying on `DELETE`/`PATCH`/`POST` returning the document now gets a `405`. It never performed the operation the verb named — use `GET` to read, or `packages/rest`'s `DELETE /api/v1/meta/:type/:name` for a real metadata delete. + +- 7fc01db: REST `/meta` write doors now carry the caller's organization, so audit rows are no longer stamped environment-wide + + `PUT /meta/:type/:name` (both arities), `DELETE /meta/:type/:name`, + `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` passed no + organization, so every `sys_metadata_audit` row a REST-authored metadata write produced was + stamped `organization_id: null`. Composed with the scoped audit read shipped alongside it — + which returns own-org rows **plus** environment-wide ones, a limb that is required rather + than optional — that left every REST-authored audit row readable by every tenant, carrying + its `actor`, `note`, `lock_state` and `request_id`. The read side could not close this: the + rows were genuinely unscoped, so no filter could separate them. + + The organization is taken from the execution context these doors already resolve, and is + threaded through `organizationIdForMetaWrite` — the same registry-derived predicate the + runtime `/metadata` dispatcher uses. Types the registry declares `allowOrgOverride: true` + (`view`, `dashboard`, `report`, `translation`, `email_template`) now scope both the overlay + row and its audit row to the caller's organization; every other type continues to write + environment-wide, because its write genuinely is environment-wide and the protocol refuses + an org-scoped write for it. `null` is now reserved for writes that really are + environment-wide. + + Two behaviour changes ride along, both required for the fix to be usable rather than + separate improvements: `publish` and `rollback` resolve their row through the organization, + so scoping the save without scoping them would have broken the draft → publish loop; and + `GET /meta/:type/:name/published` is now organization-scoped (organization-first, then + environment-wide), without which it would answer 404 for an item the same caller had just + published through the same transport. + + `organizationIdForMetaWrite` / `declaresOrgOverride` moved from `@objectstack/runtime` into + `@objectstack/metadata-core` so both doors share one implementation — `@objectstack/rest` + cannot import from `runtime`, which depends on it. Runtime behaviour is unchanged. + +- 19db5fa: fix(runtime): `publish-drafts` no longer discloses driver or subscriber text on `unhideError` / `rebindError` (#8516) + + `POST /api/v1/packages/:id/publish-drafts` answered, on a **200**: + + ```json + { + "success": true, + "data": { + "unhideError": "SQLITE_ERROR: no such table: sys_metadata", + "rebindError": "TypeError: Cannot read properties of undefined (reading 'triggers') at AutomationPlugin.rebind (/srv/objectstack/packages/services/service-automation/dist/index.js:412:31)" + } + } + ``` + + These are the two remaining producers on the response whose `seedApplied` field + #8443 converted — the ADR-0045 visibility flip and the `metadata:reloaded` + announce. Both ride a success body as **data**, so no HTTP boundary's 5xx + message withhold can reach them; the disclosure had to be closed at the + producer. Both were driven for real before being changed, and both reproduced. + + Both now follow the rule already in force next door: a caught sentence is + quoted only when the error **declared** itself a client-facing refusal (4xx + `status`, ADR-0112); anything else gets the stable sentence the field could + already carry, and the original goes to the server log. The rule is imported + from `@objectstack/metadata-protocol` (`clientFacingFailureText`), not restated + locally. + + **Both halves of the rule, because the two sites started in different states.** + The flip already logged its cause in full at `error` with an operator remedy, so + only its payload changed. The announce had **no log line at all** — withholding + alone would have converted an over-disclosure into a silent failure, so it gains + one at `warn`, naming the cause, the concrete consequence (a newly published + record-triggered flow does not bind its trigger until the process restarts) and + the fix (re-run the idempotent publish, or restart). `warn` rather than `error` + because nothing that claimed to persist failed to: the drafts are published and + the flip is stored, and an unbound trigger is AGENTS.md's own worked example of + a functional degradation — the level the sibling announce of this same event + already uses. + + **Authoring feedback is preserved, not blanked.** The flip's authored refusals + all declare 4xx (`ITEM_LOCKED`, `NOT_OVERRIDABLE`, + `OBJECT_OVERLAY_PACKAGE_MISMATCH`, …), so a locked or non-overridable app still + tells its publisher which app and why, verbatim — and the `unhiddenApps` + half-flip report beside it is untouched. A subscriber that declares a 4xx + refusal is quoted by the same positive list. + +- 2b9d33a: Stamp seeded rows with the install's organization so one object runs one autonumber scope (#8686) + + Seed writes and API writes disagreed about tenancy. Seed data is loaded during + app start, before any human user exists, so the seed loader had no organization + to stamp and its rows landed `organization_id = NULL`; API writes carried the + signed-in user's organization. The SQL driver keys its autonumber counter by + exactly that column (`__global__` when NULL), so a single object ran two + independent counters — and the uniqueness index is partitioned by the same key + (`COALESCE(organization_id, '__global__'), `), so the duplicates the + second counter minted were invisible to the constraint. On a single-tenant + install seeded with `CASE-00001..38`, the first four API creates returned + `CASE-00001..4` again: four duplicated values on a field declared `unique`, with + 201s and no warning. + + Seed writes now carry the organization the same way API writes do. The moment an + install's organization first exists, untenanted seed rows are adopted into it and + the `__global__` counter is merged into the organization-scoped one, so the + `__global__` pseudo-tenant stops acting as a peer of a real organization. Existing + installs are repaired by a one-shot boot-time backfill, guarded to single-tenant + installs; a multi-tenant install where a split is detected is never guessed at — + the backfill skips and logs the condition and the remedy. Business identifiers + that were already minted twice are reported for the operator, never silently + renumbered. Platform namespaces (`sys_`/`cloud_`/`ai_`) stay global, exactly as + the seed loader already treats them. + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e7bccaa] +- Updated dependencies [e43d63a] +- Updated dependencies [5047cb8] +- Updated dependencies [cf0d902] +- Updated dependencies [498f4e8] +- Updated dependencies [cc5c07b] +- Updated dependencies [13d7864] +- Updated dependencies [f57fb38] +- Updated dependencies [3508678] +- Updated dependencies [8656d67] +- Updated dependencies [177442d] +- Updated dependencies [950bd94] +- Updated dependencies [3043e98] +- Updated dependencies [6feac91] +- Updated dependencies [5f5e234] +- Updated dependencies [4ea921c] +- Updated dependencies [3ab2488] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [fd6bdf8] +- Updated dependencies [24173e9] +- Updated dependencies [a9df51c] +- Updated dependencies [f8eb736] +- Updated dependencies [ab8b10f] +- Updated dependencies [20067c5] +- Updated dependencies [4bfe1a5] +- Updated dependencies [ead96d0] +- Updated dependencies [b69d0f5] +- Updated dependencies [c15eb23] +- Updated dependencies [4d47afe] +- Updated dependencies [72050cc] +- Updated dependencies [c8806ae] +- Updated dependencies [bb96297] +- Updated dependencies [e6e1de4] +- Updated dependencies [c73eacd] +- Updated dependencies [693c788] +- Updated dependencies [845e164] +- Updated dependencies [8d017eb] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [88ef34d] +- Updated dependencies [add2d19] +- Updated dependencies [2b9d33a] +- Updated dependencies [04f8fdb] +- Updated dependencies [147eadc] +- Updated dependencies [0f59584] +- Updated dependencies [ff08691] +- Updated dependencies [159e299] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [7c2f386] +- Updated dependencies [75e66fc] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [8a9e7f4] +- Updated dependencies [3d0ded8] +- Updated dependencies [a726154] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] +- Updated dependencies [a4acb8d] + - @objectstack/plugin-auth@17.1.0 + - @objectstack/plugin-security@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/rest@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/metadata-protocol@17.1.0 + - @objectstack/service-datasource@17.1.0 + - @objectstack/driver-sql@17.1.0 + - @objectstack/metadata-core@17.1.0 + - @objectstack/objectql@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/metadata@17.1.0 + - @objectstack/driver-memory@17.1.0 + - @objectstack/driver-sqlite-wasm@17.1.0 + - @objectstack/formula@17.1.0 + - @objectstack/observability@17.1.0 + - @objectstack/service-cluster@17.1.0 + - @objectstack/service-i18n@17.1.0 + ## 17.0.0 ### Major Changes diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 183357b674..293a47af3e 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/runtime", - "version": "17.0.0", + "version": "17.1.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 77586671e7..411287dfda 100644 --- a/packages/sdui-parser/CHANGELOG.md +++ b/packages/sdui-parser/CHANGELOG.md @@ -1,5 +1,7 @@ # @objectstack/sdui-parser +## 17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/sdui-parser/package.json b/packages/sdui-parser/package.json index 538c4444c5..9a7e619452 100644 --- a/packages/sdui-parser/package.json +++ b/packages/sdui-parser/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/sdui-parser", - "version": "17.0.0", + "version": "17.1.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 c9a64ce6ee..1676ad74bf 100644 --- a/packages/services/service-analytics/CHANGELOG.md +++ b/packages/services/service-analytics/CHANGELOG.md @@ -1,5 +1,83 @@ # Changelog — @objectstack/service-analytics +## 17.1.0 + +### Patch Changes + +- d09d0fd: Source the comparand-type allow-list and the accepted-set refusal sentence from the shared `@objectstack/spec/data` door instead of re-spelling them locally. + + `comparand-shape.ts`'s `isBindableComparand` / `isRenderableTextComparand` spelled the same six accepted comparand types (`string | number | bigint | boolean | null | Date`) that `isAcceptedFilterComparand` single-sources for the SQL driver family, and two refusal messages hand-copied the accepted-set sentence. Both predicates now delegate the type membership to the door and quote `ACCEPTED_FILTER_COMPARAND_TYPES_SENTENCE`, matching how `driver-sql` and `driver-turso` consume it. + + No comparand is accepted or refused differently: the local copies already agreed with the door, and the full accept/refuse matrix is pinned end to end at both analytics filter doors, in three comparand positions each, measured before the change and re-run unchanged after it. + + One user-visible wording correction falls out of removing the copy: the hand-copied sentence omitted `bigint`, a type both predicates have always accepted and both doors have always compiled, so a refusal message under-described the values it accepts. The message now names the full set. The package-local extras — a binary bindable, and the `undefined` arm both doors already refuse upstream — are unchanged and recorded at their use sites. + +- 402c125: fix(objectql): a temporal filter comparand the platform cannot interpret is refused at the engine door instead of answering 200 with zero rows (#8690) + + + + A `datetime` / `date` / `time` field filtered with a bare string the platform + cannot read — `last_30_days`, `not-a-date-at-all` — was bound **as written** + all the way to the driver, where the comparison is false for every row. The + caller received `HTTP 200`, an empty result set, and nothing to indicate the + filter was meaningless. An unknown `{placeholder}` in the same position was + already refused loudly (`FILTER_TOKEN_UNKNOWN` / 400, listing the resolvable + tokens), so one API answered two shapes of unusable comparand two different + ways. + + It is concretely reachable rather than theoretical: `last_7_days` / + `last_30_days` / `last_90_days` are **declared preset names** in the dashboard + schema. The shipped console lowers them to `{N_days_ago}` macros before they + reach the API, so the console path was always safe — but a saved report, an + integration, an MCP client or an AI-authored query sends the preset name itself + and got a silent zero. An empty chart is the hardest failure to debug: it is + indistinguishable from "there is genuinely no data". + + Such a comparand is now refused at the ObjectQL engine's single filter + collection point, with `code: 'INVALID_FILTER'` and `status: 400`, naming the + field, the value, the key path and the spellings that would work. That seam is + the one place holding the caller's comparand and the field's **declared type** + at the same moment, and every verb (`find` / `findOne` / `count` / `aggregate` + / `update` / `delete`) and both filter spellings (the array sugar and the + lowered condition) pass through it, so all four backends inherit one answer + rather than four. `NativeSQLStrategy` additionally **declines** such a query so + the raw-SQL analytics path falls through to that door instead of binding the + value into its own statement. + + Deliberately unchanged, each by ruling: a `{placeholder}` keeps its existing + refusal one layer down (the door runs before token resolution and steps around + them, so `{30_days_ago}` still resolves normally); non-string comparands are + untouched (a number is epoch milliseconds, a `Date` is an instant); and the + **empty string** keeps today's behaviour exactly — it binds as `''` and matches + every non-null row, which is a separate question that remains its own card. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + ## 17.0.0 ### Major Changes @@ -1051,7 +1129,7 @@ vocabulary − this`), which is what stops the next aggregate added to the spec is untouched; it is simply no longer reachable through a spec-valid request. On the dataset path nothing changes: `compileDataset` refused both by name already. - + - b4be309: fix(analytics): a new spec aggregate can no longer silently return a row count @@ -4772,7 +4850,7 @@ vocabulary − this`), which is what stops the next aggregate added to the spec is untouched; it is simply no longer reachable through a spec-valid request. On the dataset path nothing changes: `compileDataset` refused both by name already. - + - 2bc1876: fix(service-analytics): refuse a dotted `measures` entry loudly instead of aggregating the base column (#5918) diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 7bbe8d0ed2..bc57a783b5 100644 --- a/packages/services/service-analytics/package.json +++ b/packages/services/service-analytics/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-analytics", - "version": "17.0.0", + "version": "17.1.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 8f6695c29d..b2188c9907 100644 --- a/packages/services/service-automation/CHANGELOG.md +++ b/packages/services/service-automation/CHANGELOG.md @@ -1,5 +1,139 @@ # @objectstack/service-automation +## 17.1.0 + +### Patch Changes + +- 2277443: fix(cloud-connection,service-automation): stop two plugin classes renaming themselves in the shipped build, and enforce the class-name identity limb against `Ctor.name` (#8645) + + `Serve.providesCapability` (`packages/cli/src/commands/serve.ts`) decides whether a + host already supplied a capability's provider by comparing, by equality, both a + loaded plugin's `name` and its `constructor.name` against a declared identity + list. Every identity registry in that file therefore declares two spellings per + provider — the registered `plugin.name` id and the exported class name — and the + class-name spelling is a claim about the **built** artifact. + + **Measured against the built packages, two of the 27 declared class-name + identities matched nothing at all:** + + ``` + MISMATCH CAPABILITY_PROVIDERS.automation declared=AutomationServicePlugin runtime=_AutomationServicePlugin + MISMATCH Serve.MARKETPLACE_PROXY_IDENTITIES declared=MarketplaceProxyPlugin runtime=_MarketplaceProxyPlugin + ``` + + Both classes referenced themselves **by name inside their own body** — + `MarketplaceProxyPlugin.prototype.version` building the outbound proxy + User-Agent, and a `private static` backoff helper called from an instance method + in the automation plugin. esbuild rewrites such a class into + `var X = class _X { … _X … }` so the inner reference binds to the class binding + rather than the outer `var`, and the emitted class reports `_X` as its `.name`. + + There was no user-visible impact, because every guard naming these plugins also + declares the registered id, which the instance carries as a plain field no + bundler touches. What was dead is the **redundancy**: a guard running on one + limb it does not know it is running on is one rename away from failing open — + and failing open here means silently mounting a second instance over a host's + own. + + Both source idioms are replaced with module-scope declarations, so the shipped + classes keep their names. The marketplace proxy's self-reference was also + reading a field that was never there (`version` is an instance field, so + `prototype.version` was always `undefined`): its outbound `User-Agent` announced + the `?? '1.0.0'` fallback on every request and now announces the plugin's real + version, `1.1.0`. + + The enforcement half lives in `packages/cli/test/serve-capability-identity.test.ts`: + every declared class-name identity, across `CAPABILITY_PROVIDERS` and the four + marketplace identity lists, is now compared to the runtime `Ctor.name` of the + export it names, and must satisfy `providesCapability` through the class-name + limb alone. The `*_IDENTITIES` statics are re-derived from `Serve` itself, so a + fifth list cannot be added without being enumerated. #8357's local + "modulo one leading underscore" accommodation is retired rather than left as a + third spelling of the same rule. + +- f047810: fix(automation): evaluate a record-change flow's start condition on the re-entrant dispatch its own write causes — the loop-breaker goes back to being a backstop (#8689) + + A `record-after-update` flow whose start condition, **as authored, is false on the + flow's own write-back**, was still re-dispatched for the same record. Nothing ran + away — the engine's last-resort re-entrancy breaker caught it every time — but the + breaker was the _only_ thing working, and its own WARN said so: _"Its start + condition did not suppress the re-fire."_ + + **Which of the two candidate mechanisms — measured, not assumed.** The report named + two readings that need different repairs: the re-entrant dispatch _skips_ condition + evaluation, or evaluation _runs but aborts_ and the abort is counted as a fire. + Measured on a real booted kernel (ObjectQL + automation + record-change trigger on + better-sqlite3), a flow guarded on `record.status != "escalated"` whose data node + writes `status = "escalated"`: + + ``` + dispatches for the record ........ 2 (the re-fire really happened) + start-condition evaluations ...... 1 (the FIRST dispatch only) + evaluations that threw ........... 0 + loop-breaker WARNs for that id ... 1 + ``` + + Two dispatches, one evaluation, zero throws: the first reading is the true one, and + the second is falsified for this path. `AutomationEngine.execute()` checked the + re-entrancy breaker **before** the start-condition gate and returned there, so on the + one dispatch where an author's re-fire guard is load-bearing, the guard was never + consulted at all. + + **The fix is the ordering, not a stronger breaker.** The gate now runs first; the + breaker check moved below it. The re-entrant dispatch already carries the post-write + row, so the condition evaluates `false` and the flow is suppressed with + `condition_not_met` — by the guard its author wrote. Measured after the change on the + same harness: 2 dispatches, **2** evaluations (the second returning `false` against + `status = "escalated"`), **0** breaker WARNs, and the flow still fires and applies its + write exactly as before. + + The breaker is **unchanged in strength**, deliberately — making it catch more while + leaving evaluation broken would have been the wrong direction. A condition that is + genuinely true on re-entry (the 2026-07-06 shape: a `boolean` persists as integer `1` + on SQLite/libsql, and CEL `1 != true` is true, so `is_escalated != true` never trips) + still lands on the breaker, at the same depth, with the same WARN and the same skip + envelope. What changed is that reaching it now _means_ something — the condition was + evaluated and returned true — so the WARN states that as fact instead of inferring it. + + Two consequences worth naming for anyone reading logs or run history: + + - flows whose re-fire guard was already correct stop producing the breaker WARN + entirely, and their re-entrant dispatch is now recorded as `condition_not_met` + rather than `reentrancy_loop_guard`; + - a run skipped by its condition, and a re-entrant dispatch refused by the breaker, + no longer release the re-entrancy key — only the run that took it does. Releasing a + key it never owned would have disarmed the breaker for the run still on the stack, + which is exactly the runaway the breaker exists to stop. + + The regression pins assert the reporter's own three-legged probe design together — + the flow actually fired, no breaker WARN carries that record's id, and the start + condition was **evaluated** at the re-fire against the post-write row and returned a + verdict rather than throwing. Asserting only "the flow terminated" would be vacuous + here: the breaker already made that true. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/formula@17.1.0 + ## 17.0.0 ### Major Changes diff --git a/packages/services/service-automation/package.json b/packages/services/service-automation/package.json index 2584096834..2d57f77f33 100644 --- a/packages/services/service-automation/package.json +++ b/packages/services/service-automation/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-automation", - "version": "17.0.0", + "version": "17.1.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 618fb94689..4fec42751e 100644 --- a/packages/services/service-cache/CHANGELOG.md +++ b/packages/services/service-cache/CHANGELOG.md @@ -1,5 +1,32 @@ # @objectstack/service-cache +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/observability@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/services/service-cache/package.json b/packages/services/service-cache/package.json index 75703b7ede..05ae6e6675 100644 --- a/packages/services/service-cache/package.json +++ b/packages/services/service-cache/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cache", - "version": "17.0.0", + "version": "17.1.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 04b5bb0ba8..5cf5621993 100644 --- a/packages/services/service-cluster-redis/CHANGELOG.md +++ b/packages/services/service-cluster-redis/CHANGELOG.md @@ -1,5 +1,26 @@ # @objectstack/service-cluster-redis +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [f57fb38] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/service-cluster@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/services/service-cluster-redis/package.json b/packages/services/service-cluster-redis/package.json index be11cf7dca..de33525bff 100644 --- a/packages/services/service-cluster-redis/package.json +++ b/packages/services/service-cluster-redis/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cluster-redis", - "version": "17.0.0", + "version": "17.1.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 99446ea46e..e7e2000c31 100644 --- a/packages/services/service-cluster/CHANGELOG.md +++ b/packages/services/service-cluster/CHANGELOG.md @@ -1,5 +1,31 @@ # @objectstack/service-cluster +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/services/service-cluster/package.json b/packages/services/service-cluster/package.json index d829d25719..7a2199940a 100644 --- a/packages/services/service-cluster/package.json +++ b/packages/services/service-cluster/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-cluster", - "version": "17.0.0", + "version": "17.1.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 ccf2e4b16b..285e1de274 100644 --- a/packages/services/service-datasource/CHANGELOG.md +++ b/packages/services/service-datasource/CHANGELOG.md @@ -1,5 +1,259 @@ # @objectstack/service-external-datasource +## 17.1.0 + +### Minor Changes + +- 3508678: feat(service-datasource): operator-initiated re-homing of stored cleartext datasource credentials into `sys_secret` (#8155) + + A datasource row created before #8078 closed the write door can still hold its + credential in cleartext inside `config`. #8081 and #8154 closed the read paths so + none of it is SERVED; neither removes what is already at rest. This adds the + migration that does — `IDatasourceAdminService.migrateCredential(name)`, reached + from the Setup action **"Move credential to the secret store"** on a datasource + record, backed by `POST /api/v1/datasources/:name/migrate-credential`. + + **Per datasource, initiated by an operator, never a sweep.** There is no batch + spelling of the route, deliberately: deciding a stored secret's identity with no + operator present and rewriting rows at boot is the destructive shape the standing + ruling escalates rather than permits. The inventory is free and already exists — + `/meta` badges every affected row `_diagnostics: { valid: false }`, so the + operator works from a list the platform already computes, and it shrinks visibly + as each row is done. + + **Durability ordering.** The secret is written to the store, **read back and + compared**, and only then does a single record write add + `external.credentialsRef` and drop the inline key together. A crash before that + write leaves the row untouched and working on its inline credential; a crash + after it leaves a row referencing a secret this run already proved readable. A + failed read-back or a failed record write unbinds the secret it just minted + rather than orphaning it. It deliberately does NOT write the ref in one step and + delete the key in a second: the connect path is fail-closed on a `credentialsRef` + it cannot resolve (ADR-0062 D3) and never falls back to `config`, so a row + carrying an unverified ref beside its cleartext is not a safe intermediate state. + + **Idempotent.** A row that already references a secret is never bound again — a + re-run answers `already-bound`, writes nothing, and mints no second `sys_secret` + row. A row holding both a ref and an inline copy (an interrupted run, or a wizard + re-entry, whose redacted round-trip carries the stored credential forward by + design) has the copy dropped against the ref it already has. + + **What it refuses, and what it tells the operator instead.** Only the key a + driver's own contract declares as its inline credential slot is re-homed — + `password` for postgres/mysql/mongodb, `authToken` for turso — because that is + exactly the key the injected secret substitutes at connect time. Everything else + is refused with a reason and a remedy rather than guessed at: a credential + embedded in a connection URL (the mysql and mongodb DSN branches hand the URL to + the client verbatim and drop the injected secret, so re-homing it could leave the + datasource connecting unauthenticated), a pre-#8078 alias spelling that no + connection builder reads, turso's still-writable `encryptionKey`, a code-defined + datasource, and a host whose secret binder cannot read a secret back. Nothing is + deleted that was not re-homed, and credential-shaped keys left behind are named + in the result so "migrated" never reads as "this row is now clean". + +- 20067c5: fix(runtime,mcp,service-datasource): the #6504 consumer sweep — three list consumers stop making claims a known-partial read cannot support (#6504) + + + + `IMetadataService.listDiagnosed?(type)` (PR #7721) lets a plural read say whether + its answer can be trusted as complete. This is the consumer half: the callers + that were restating a possibly-short listing as a fact about the environment. + + Each consumer was qualified individually, per PR #6051's discipline, and most + were left alone — a caller publishing a snapshot with no count has nothing to + mis-state. Three make a claim, and each now withholds exactly that claim while + still serving everything it could read: + + - **`removeDatasource` no longer deletes on a bound-object count it could not + take completely.** The guard `if (bound > 0) throw` is the only thing standing + in front of an irreversible delete that also unbinds the datasource's secret, + and its input is derived from the metadata service's object listing. During a + loader outage that listing goes silently short, and the worst value is the + benign one: `0` reads exactly like "nothing is bound", so the guard OPENED. + It now refuses with `SERVICE_UNAVAILABLE` / 503 — a dependency outage the + operator can retry, not a client error — and the record, its credential and + its pool all survive. + - **The MCP `list_objects` tool stops publishing `totalCount` on a known-partial + listing.** This is the same claim PR #7721 removed from the + `objectstack://objects` resource, on the other MCP primitive: same payload + shape, different door, never covered. A degraded read now serves the same + objects with `totalCount` **absent** and `partial` / `returnedCount` / + `warning` plus the 503 envelope in its place, so a client reading the total + gets `undefined` rather than a believable wrong integer. Both bridges + implement it — stdio (`@objectstack/mcp`) and HTTP (`@objectstack/runtime`) — + because a completeness claim must not depend on which transport a client + connected over. + - **The ADR-0015 §5.2 boot gate stops announcing an all-clear over a sweep it + could not complete.** It validated whatever `listObjects()` returned and then + logged _all federated objects match their remote schema_, with a count. + Federated objects behind an unreadable loader were never validated, so + `onMismatch: 'fail'` could not have fired for them. The gate now warns that + the swept set was incomplete and names what it did validate. ⛔ It does **not** + abort boot on a degraded metadata read: turning a transient outage into a + refusal to start would be a new failure mode bought with a diagnosis fix. + + Every new member is optional in the same way `listDiagnosed` itself is: a host + whose metadata service predates the verdict behaves exactly as it did before, + and a service without it reports nothing degraded — precisely what it could + express. + +### Patch Changes + +- f57fb38: feat(spec): refuse credential-bearing URL query parameters (`?authToken=` / `?password=`) in authored driver config at publish (#8337) + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + The third spelling of the same secret: #7990 refused the inline credential + keys, #8082 refused the URL userinfo form (`user:password@host`), and the + query string stayed open — `libsql://x.turso.io?authToken=eyJ…` persisted the + JWT cleartext into `sys_metadata` (served back by the ordinary data API) and, + measured against the clients this tree pins, actually authenticates: + `@libsql/core@0.17.4` assigns the URL's `?authToken=` OVER the config-level + token — so the workaround also silently defeated the binder-injected secret — + and `pg-connection-string@2.14.0` copies every query parameter into the client + config, `?password=` winning over userinfo. + + **What is refused** (write door, shared value-level parse + `urlCredentialQueryParams` beside #8082's `urlUserinfoPassword`): turso + `config.url` / `config.syncUrl` carrying `?authToken=`, postgres `config.url` + carrying `?password=` — matched case-insensitively on the percent-decoded key, + non-empty values only, with the #8082-template prescription (datasource secret + binder / `external.credentialsRef`; runtime-environment DSNs are unaffected). + mysql and mongo URLs are deliberately NOT narrowed: both clients were measured + ignoring `?password=`, so refusing it would widen past the measured defect. + + **What stays accepted:** every credential-free URL byte-identically, benign + query parameters (`?tls=`, `?sslmode=`, …) included, and the parameter-absent + shape the read path serves — which keeps an untouched "Save" on a legacy row + working. + + **Read half** (the same PR, per the card): `redactDatasourceConfig` / + `getDatasource()` now strip credential query parameters from served URLs for + every driver (new `redactUrlCredentials` / `redactUrlCredentialQueryParams` + exports), `restoreRedactedConfig` mirrors the composite so an untouched + round-trip keeps the stored token, and the credential-migration planner + refuses a query-token row with the per-row remedy instead of planning + `nothing-to-migrate` over cleartext. + + ## FROM → TO + + ```yaml + # before — parsed green; JWT stored cleartext in sys_metadata, and at connect + # it silently overrode the binder-injected secret + driver: turso + config: + url: libsql://app-org.turso.io?authToken=eyJhbGciOiJFZERTQSJ9.x.y + + # after — rejected with the binder prescription; bind the secret instead + driver: turso + config: + url: libsql://app-org.turso.io + external: + credentialsRef: sys_secret:01J9ZK4T2N # or the connection form's secret field + ``` + + There is deliberately no automatic rewrite: moving the value requires + encrypting it into `sys_secret` through a running secret binder, which a + source-file transform cannot do — stripping the parameter alone would silently + drop a live credential. + + + +- 72050cc: fix(service-datasource): a bound `external.credentialsRef` reaches the mysql client on the DSN branch instead of being dropped (#8696) + + + + `DatasourceConnectionService` resolves a datasource's `external.credentialsRef` + to a cleartext secret and hands it to the driver factory as `spec.secret`. The + mysql arm then **threw it away** whenever `config.url` was present: the DSN + string became the whole knex `connection`, and the resolved credential reached + nothing. Measured on `origin/main`, driver `mysql`, `config.url` + `mysql://app@db.internal:3306/app`, secret bound: + + ```text + knex connection: typeof=string value="mysql://app@db.internal:3306/app" + ``` + + **This is a broken binding, not a disclosure.** Since #8082 refuses a + `user:password@` userinfo at the publish door, a bare-username DSN plus a bound + secret is the _only_ authorable URL shape for this driver — the exact shape the + connection form produces and the exact shape #8155's re-homing remedy tells + operators to write. Such a datasource therefore connected **unauthenticated**, + or failed with a driver-level auth error naming nothing about the binding, while + its Setup page showed a credential bound and the connect path reported success. + It is the declared-≠-enforced shape one layer below Prime Directive #10: + `MysqlConfigSchema.url` already states the contract this code failed to keep — + _"bind the secret … and it is injected at connect time. A bare username + (`user@host`) stays writable."_ + + **The fix hands mysql2 the DSN and the secret together** — `{ uri, password }` + rather than a hand-parsed URL. mysql2 keeps owning its own DSN grammar (no URL + parsing, no re-encoding, no second dialect of `mysql://…` in this repo), and its + merge gives the **explicit** key precedence, so the bound credential also wins + over a legacy password embedded in a stored pre-#8082 row — the precedence the + postgres arm's DSN branch already declares. Measured on mysql2 3.23.1, knex + 3.3.0 and pg 8.22.0. + + A DSN with **nothing bound passes through unchanged**, as the bare string it has + always been, so the entire blast radius is datasources that bind a secret — the + ones that are broken today. + + Two measured findings this change deliberately does **not** act on, each filed + on its own: + + - **The mongodb arm is still open.** `buildMongoUrl`'s `if (explicit) return +explicit;` drops the bound secret the same way, so a mongo DSN datasource + still reaches `MongoClient` with an **empty** password. The remedy is not a URL + rewrite — `MongoClient`'s `auth` option injects beside an unmodified url, and + it wins over an embedded userinfo password (measured on mongodb 7.5.0) — but it + requires a username as well, and reading the url's userinfo username needs the + platform's own DSN grammar (`new URL()` rejects the multi-host form + `MongoConfigSchema` documents). `@objectstack/spec/data` exports the password + half of that grammar and no username half; adding one belongs beside it rather + than as a second copy of the userinfo boundaries here. + - **The postgres arm passes this assertion at the config layer and is broken one + layer below it.** `pg` merges `parse(connectionString)` **over** the explicit + `password`, so `{connectionString, password}` resolves to the DSN's own + (absent) password — effective `password: null`, measured on pg 8.22.0. Its + `if (url)` branch is not fixed by symmetry with this one; the two clients merge + in opposite directions, which is why each arm's precedence is measured rather + than assumed. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + ## 17.0.0 ### Major Changes diff --git a/packages/services/service-datasource/package.json b/packages/services/service-datasource/package.json index 6cad408cf9..355e892198 100644 --- a/packages/services/service-datasource/package.json +++ b/packages/services/service-datasource/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-datasource", - "version": "17.0.0", + "version": "17.1.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 5bc611cd96..70c376cea7 100644 --- a/packages/services/service-i18n/CHANGELOG.md +++ b/packages/services/service-i18n/CHANGELOG.md @@ -1,5 +1,33 @@ # @objectstack/service-i18n +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/services/service-i18n/package.json b/packages/services/service-i18n/package.json index 196d0edf56..75dc646e81 100644 --- a/packages/services/service-i18n/package.json +++ b/packages/services/service-i18n/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-i18n", - "version": "17.0.0", + "version": "17.1.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 78d83867ac..48be9e34bb 100644 --- a/packages/services/service-job/CHANGELOG.md +++ b/packages/services/service-job/CHANGELOG.md @@ -1,5 +1,39 @@ # @objectstack/service-job +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/services/service-job/package.json b/packages/services/service-job/package.json index f6370c1461..593485e7e6 100644 --- a/packages/services/service-job/package.json +++ b/packages/services/service-job/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-job", - "version": "17.0.0", + "version": "17.1.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 e410185bab..df12e932b6 100644 --- a/packages/services/service-knowledge/CHANGELOG.md +++ b/packages/services/service-knowledge/CHANGELOG.md @@ -1,5 +1,31 @@ # @objectstack/service-knowledge +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/services/service-knowledge/package.json b/packages/services/service-knowledge/package.json index a4251239c3..5b28fd36dc 100644 --- a/packages/services/service-knowledge/package.json +++ b/packages/services/service-knowledge/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-knowledge", - "version": "17.0.0", + "version": "17.1.0", "license": "Apache-2.0", "description": "Knowledge Service for ObjectStack — orchestrator implementing IKnowledgeService over pluggable IKnowledgeAdapter backends (RAGFlow, LlamaIndex, Dify, in-memory).", "type": "module", diff --git a/packages/services/service-messaging/CHANGELOG.md b/packages/services/service-messaging/CHANGELOG.md index 8ec055ee8a..7871afb6ac 100644 --- a/packages/services/service-messaging/CHANGELOG.md +++ b/packages/services/service-messaging/CHANGELOG.md @@ -1,5 +1,41 @@ # @objectstack/service-messaging +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/services/service-messaging/package.json b/packages/services/service-messaging/package.json index 0a554ca705..f5d01f5e72 100644 --- a/packages/services/service-messaging/package.json +++ b/packages/services/service-messaging/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-messaging", - "version": "17.0.0", + "version": "17.1.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 cf828cb095..c5d8f99456 100644 --- a/packages/services/service-package/CHANGELOG.md +++ b/packages/services/service-package/CHANGELOG.md @@ -1,5 +1,85 @@ # @objectstack/service-package +## 17.1.0 + +### Patch Changes + +- e6e1de4: fix(rest): `DELETE /api/v1/packages/:id` answers a driver fault as a 5xx, and stops swallowing coded refusals (#8275) + + `packageService.delete` swallowed every throw and reported failure by returning + a bare `{ success: false }`, so the door answered + `400 PACKAGE_DELETE_FAILED`. The statement behind it is + `DELETE FROM sys_packages WHERE id = ? [AND version = ?]`, so a missing table, a + lock timeout or a foreign-key restriction — a **server** fault — was answered as + a client error: it invited the caller to fix a request that was never the + problem, and it hid a real fault from every dashboard that buckets by status. + + This is the sibling of what #8016 fixed on the throw path and #8131 fixed for + `publish`. `service-package` had been left **partially converted** by #8131 — + the same service answering two different classifications for the same kind of + fault — and this closes that. + + **Two changes, both small:** + + - `delete`'s catch re-throws a throw that **declares its own status**, so a + coded refusal reachable from this call path keeps the producer's status and + code through the door's #8016 mapping (a `409 DESTRUCTIVE_CHANGE` stays a 409) instead of being flattened into one 400. It reuses the existing + `declaresHttpAnswer` predicate rather than declaring a second one. + - an undeclared throw stays a returned failure, and the door answers it **500**. + + ⛔ The discriminant is the **status** channel, never `.code`. Every SQL driver + populates a string `code` on its errors (`ERR_SQLITE_ERROR`, `SQLITE_ERROR`, the + SQLSTATE `42P01`, `ER_NO_SUCH_TABLE`), so a `.code`-reading predicate re-throws + genuine driver faults as if they were refusals — resolving them to a `500 +INTERNAL_ERROR` that carries the driver's own message. Pinned per dialect in + `delete-driver-fault.test.ts`, on this seam rather than inherited from + `publish`'s suite by analogy. + + **4xx is not swept**, which is the other half of the fix: the + repeated-`?version=` refusal is checked before `delete` is called at all, + `PACKAGE_DELETE_PARTIAL` keeps its 400 (per-item uninstall failures are a + different outcome), a declared 4xx thrown from below keeps its own status and + code, and a declared 5xx keeps its own too. + + **No message changed, and that is deliberate.** Unlike `publish`, this path + never disclosed anything: the door builds its sentence from the request's own + `:id` and `?version=`, and the producer returns a bare flag with **no message + channel at all**. Mirroring `publish`'s `driverFault` message here for symmetry + would have _created_ a channel to the wire that nothing filters — the 5xx + withhold (#8086) lives in `sendThrownError`, which a returned failure never + reaches at any status. The new suites pin that absence from both sides: the + producer's returned shape has exactly one key, and the door answers its own + sentence even when handed a producer that grows a message. + + Verified against a real `node:sqlite` database running the real statements from + `index.ts` — including a genuine foreign-key restriction, the fault family only + `DELETE` can have. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [845e164] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/metadata-core@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index 97c6fcc128..36577607ea 100644 --- a/packages/services/service-package/package.json +++ b/packages/services/service-package/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-package", - "version": "17.0.0", + "version": "17.1.0", "license": "Apache-2.0", "description": "Package management service for ObjectStack — publish, install, and manage packages", "type": "module", diff --git a/packages/services/service-queue/CHANGELOG.md b/packages/services/service-queue/CHANGELOG.md index 03920384d0..92e6f30b58 100644 --- a/packages/services/service-queue/CHANGELOG.md +++ b/packages/services/service-queue/CHANGELOG.md @@ -1,5 +1,39 @@ # @objectstack/service-queue +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/services/service-queue/package.json b/packages/services/service-queue/package.json index 4b28eff1b9..2824889fea 100644 --- a/packages/services/service-queue/package.json +++ b/packages/services/service-queue/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-queue", - "version": "17.0.0", + "version": "17.1.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 452fc471c9..36d01c3e05 100644 --- a/packages/services/service-realtime/CHANGELOG.md +++ b/packages/services/service-realtime/CHANGELOG.md @@ -1,5 +1,39 @@ # @objectstack/service-realtime +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/services/service-realtime/package.json b/packages/services/service-realtime/package.json index 3b3aee3f69..f9601a93c5 100644 --- a/packages/services/service-realtime/package.json +++ b/packages/services/service-realtime/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-realtime", - "version": "17.0.0", + "version": "17.1.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 1122e9ba50..d0fe97bc5d 100644 --- a/packages/services/service-settings/CHANGELOG.md +++ b/packages/services/service-settings/CHANGELOG.md @@ -1,5 +1,83 @@ # @objectstack/service-settings +## 17.1.0 + +### Patch Changes + +- 501ed0e: fix(settings): the rotated-secret reaper verifies the repoint instead of inferring it (#8262) + + `SettingsService.reapRotatedSecret` deleted the `sys_secret` row that + `upsertRow` reported as `previousEnc`, and inferred that the repoint it was + cleaning up after had taken effect from `previousEnc !== nextEnc`. That + inference holds for the shipped adapter, which forwards + `context: { isSystem: true }`. It does not hold for an adapter that drops + `context` — the reader `SettingsEngine`'s own doc comment contemplates, and a + documented extension point rather than a mistake nobody makes. + + With `context` dropped, `sys_setting.value_enc` is `readonly: true` so the + UPDATE has it stripped, the row keeps naming the OLD handle, and the reaper + then deleted **the ciphertext still in force**: `materialiseRow` dereferenced a + dangling handle, got nothing, and the setting silently read as empty. That is + unrecoverable — the audit trail records digests, never handles or ciphertext, + so nothing can even name what was destroyed. Measured on the real engine over + the real `SysSetting` / `SysSecret` schemas, three writes gave `sys_secret` + `1 → 1 → 2` with `value_enc` pinned to a row that no longer existed. + + The reaper now re-reads the row after the write and deletes `previousEnc` only + once storage confirms the row no longer names it. The criterion is + `current !== previousEnc` rather than the narrower `current === nextEnc`: + under a concurrent rotation the row may already have moved on to a third + handle, where `previousEnc` is genuinely unreferenced and the narrower test + would leak the orphan the reaping exists to prevent. Both refuse the case that + matters. + + Every refusal branch (unreadable row, failed read, row still naming the + handle) leaves an orphan and logs — the recoverable direction, and the one an + orphan sweep can clean up; there is no recoverable direction on the other + side. The added read sits behind every cheap guard, so it is paid only where a + destructive delete would otherwise follow, and it is inside the same + best-effort guarantee as the delete: a rotation is never failed by it. + + Latent rather than live: no shipped path reaches this, because the shipped + adapter forwards `context`. The population at risk is third-party and custom + `SettingsEngine` adapter authors — who also had no discovery path, since the + warning on `SettingsEngine.update` still described only the pre-#8063 + consequence ("the rotated-away credential stays in force"). That warning now + states the real consequence, and a non-forwarding adapter announces itself in + the log instead of failing silently. + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/services/service-settings/package.json b/packages/services/service-settings/package.json index 6a99147bbc..ae78edc810 100644 --- a/packages/services/service-settings/package.json +++ b/packages/services/service-settings/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-settings", - "version": "17.0.0", + "version": "17.1.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 86cd9e951c..ede4f90208 100644 --- a/packages/services/service-sms/CHANGELOG.md +++ b/packages/services/service-sms/CHANGELOG.md @@ -1,5 +1,34 @@ # @objectstack/service-sms +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [04f8fdb] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/plugin-auth@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/services/service-sms/package.json b/packages/services/service-sms/package.json index 6a010f9471..193f6b5941 100644 --- a/packages/services/service-sms/package.json +++ b/packages/services/service-sms/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-sms", - "version": "17.0.0", + "version": "17.1.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 bcd8111ba3..621b152982 100644 --- a/packages/services/service-storage/CHANGELOG.md +++ b/packages/services/service-storage/CHANGELOG.md @@ -1,5 +1,42 @@ # @objectstack/service-storage +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/platform-objects@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/observability@17.1.0 + ## 17.0.0 ### Major Changes diff --git a/packages/services/service-storage/package.json b/packages/services/service-storage/package.json index 713d537bfc..c6b5740756 100644 --- a/packages/services/service-storage/package.json +++ b/packages/services/service-storage/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/service-storage", - "version": "17.0.0", + "version": "17.1.0", "license": "Apache-2.0", "description": "Storage Service for ObjectStack — implements IStorageService with local filesystem and S3 adapter skeleton", "type": "module", diff --git a/packages/spec/CHANGELOG.md b/packages/spec/CHANGELOG.md index 3a5414de80..60bf0635d8 100644 --- a/packages/spec/CHANGELOG.md +++ b/packages/spec/CHANGELOG.md @@ -1,5 +1,926 @@ # @objectstack/spec +## 17.1.0 + +### Minor Changes + +- 720ee95: fix(security): the shipped admin permission sets no longer grant export on the `*` wildcard (#8681) + + + + **BREAKING for any deployment whose administrators export today.** Landing after + the v17.0.0 cut, so it ships as `minor` under the lockstep launch-window + convention; the migration prescription is registered under protocol major 18, + where `objectstack migrate meta` users will look. + + `admin_full_access`, `organization_admin` and the derived + `organization_admin_no_bypass` shipped `objects['*'].allowExport = true`. That + single line made the 17.0 export axis **undeniable** for anyone holding an admin + set: an application could declare an object exportable by nobody, ship it, and + the platform would export it anyway. + + Measured on 17.0.0 GA — 40 export probes, 5 principals, 8 objects, real Bearer + tokens — an org owner exported `crm_quote` (9 rows), `crm_campaign` (13) and + `crm_task` (15) with 200 and full data. No app permission set granted export on + any of the three, and the app had no way to say no: + + 1. the wildcard lives in code-package metadata, so editing it answers + `403 [not_overridable] Metadata item 'permission/admin_full_access' is +provided by a code package`; + 2. the org admin holds no app-authored permission set, so there is nowhere to + author the per-object `allowExport: false` that would otherwise have won. + + **This was never a gate defect.** The same run proves the export gate exact for + every other principal: a token refused on one object exports another on the same + route, granting `allowExport` at runtime flips 403 to 200, and revoking it flips + it back. A plain member carrying `'*': { allowExport: true }` exported too — the + wildcard was simply doing what it said. What changes is that the platform stops + shipping that grant. + + This is #5491 applied to the export axis. That change removed `member_default`'s + CRUD wildcard because a wildcard in a set every principal resolves is not a + default but a floor no app can get under; the export wildcard survived by + omission rather than by decision, one tier up. + + **Migration — grant `allowExport` explicitly in an app permission set where + admin export is intended.** There is no automatic replacement, deliberately: + which principals may take a bulk machine-readable copy of a table is the + segregation-of-duties judgement the axis exists to make explicit. + + ```ts + // In YOUR app's permission set — not a platform set (those are not overridable). + { + name: 'system_admin', + objects: { + crm_account: { allowRead: true, allowExport: true }, // export intended + crm_quote: { allowRead: true }, // export withheld + }, + } + ``` + + ⚠️ **Nothing fails at parse time, and the shipped sets are re-seeded on + upgrade.** A deployment that upgrades without editing anything is valid metadata + whose administrators have quietly lost export on every object no app set names — + the first sign is a support report, not an error. Verify behaviourally: sign in + as an org owner and call `GET /api/v1/data//export`, expecting 200 where + export is intended and 403 `EXPORT_NOT_PERMITTED` where it is not. + + **What is deliberately unchanged.** READ is untouched — an admin still sees + every record they saw before; this narrows bulk egress only. `allowExport` on a + `'*'` entry remains a supported, honoured authoring shape in an app's own sets. + Specific-over-wildcard precedence is unchanged (an explicit per-object entry + still overrides the wildcard). The `viewAllRecords` / `modifyAllRecords` + super-user bits still do not imply export, exactly as before. And an app's own + admin set already gets precisely its declared posture — declared `false` answers + 403, declared `true` answers 200 — which is what makes withdrawing the platform + grant safe rather than merely restrictive. + + Both admin sets are fixed together, and the org-admin pair from one declaration + (`organization_admin_no_bypass` is derived from `organization_admin`). Fixing + one and not the other was rejected outright: a half-closed export boundary reads + as closed and is not. + +- f287435: feat(spec): refuse undeclared keys on the analytics authoring surface (#4001 data batch D) + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + All 8 `data/analytics.zod.ts` sites are strict: the cube family (`CubeSchema` + + its `refreshKey` block, `MetricSchema` + its `filters[]` items, + `DimensionSchema`, `CubeJoinSchema`) and the query family + (`AnalyticsQuerySchema` + its `timeDimensions[]` items). Before this change an + undeclared key on any of them was silently dropped: a join authored with a + typo'd `relationship` registered with the `many_to_one` default — a different + join shape than the author declared — and a cube's misspelled key vanished + under a successful parse. + + The subtle half is the query: `/analytics/query`'s TOP level has been strict + since #3878 (`AnalyticsQueryRequestSchema`), but top-level strictness does not + recurse — measured on `main`, `timeDimensions: [{ dimension, granuarity: +'day' }]` rode through the strict wrapper with the typo silently stripped, so + the query bucketed the whole range as one group under an ordinary 200. The + nested item is now strict, and the base schema's own strictness makes the + posture hold at every door instead of only at the wrapper that re-applied it. + + **What is refused:** any key the shape does not declare, with a prescriptive + message — the surface, the offending key, and a rename (`title` → `label` on a + metric/dimension, `label` → `title` on the cube, `table`/`sqlTable` → `sql`, + `granularity` → `granularities` on a dimension and the reverse on a query time + dimension, `orderBy` → `order`; `filters` on a query gets the `where` + prescription matching the dispatcher's #3878 hint). + + **What stays accepted:** every declared key byte-identically, including the + `#3878` tombstones on the request wrapper (`query`/`format` still answer their + migration text). + + ## FROM → TO + + ```ts + // before — parsed green; the join fell back to many_to_one silently + defineCube({ + name: "orders", + sql: "orders", + measures: { + revenue: { + name: "revenue", + label: "Revenue", + type: "sum", + sql: "amount", + }, + }, + dimensions: {}, + joins: { + customers: { + name: "customers", + sql: "a.id = b.a_id", + relationshipp: "one_to_many", + }, + }, + }); + + // after — rejected with `relationshipp` → `relationship`; write the declared key + defineCube({ + name: "orders", + sql: "orders", + measures: { + revenue: { + name: "revenue", + label: "Revenue", + type: "sum", + sql: "amount", + }, + }, + dimensions: {}, + joins: { + customers: { + name: "customers", + sql: "a.id = b.a_id", + relationship: "one_to_many", + }, + }, + }); + ``` + + There is deliberately no automatic rewrite: an undeclared key is either a + spelling of a declared one (the rejection names the rename) or names a + capability the analytics layer does not deliver, and blessing it would be + declared-but-unenforced surface (ADR-0078). `os migrate meta` surfaces the + change as a structured TODO (semantic entry + `analytics-authorable-unknown-keys-refused`, protocol major 18 — this refusal + is not part of the v17.0.0 cut). + + + +- f57fb38: feat(spec): refuse credential-bearing URL query parameters (`?authToken=` / `?password=`) in authored driver config at publish (#8337) + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + The third spelling of the same secret: #7990 refused the inline credential + keys, #8082 refused the URL userinfo form (`user:password@host`), and the + query string stayed open — `libsql://x.turso.io?authToken=eyJ…` persisted the + JWT cleartext into `sys_metadata` (served back by the ordinary data API) and, + measured against the clients this tree pins, actually authenticates: + `@libsql/core@0.17.4` assigns the URL's `?authToken=` OVER the config-level + token — so the workaround also silently defeated the binder-injected secret — + and `pg-connection-string@2.14.0` copies every query parameter into the client + config, `?password=` winning over userinfo. + + **What is refused** (write door, shared value-level parse + `urlCredentialQueryParams` beside #8082's `urlUserinfoPassword`): turso + `config.url` / `config.syncUrl` carrying `?authToken=`, postgres `config.url` + carrying `?password=` — matched case-insensitively on the percent-decoded key, + non-empty values only, with the #8082-template prescription (datasource secret + binder / `external.credentialsRef`; runtime-environment DSNs are unaffected). + mysql and mongo URLs are deliberately NOT narrowed: both clients were measured + ignoring `?password=`, so refusing it would widen past the measured defect. + + **What stays accepted:** every credential-free URL byte-identically, benign + query parameters (`?tls=`, `?sslmode=`, …) included, and the parameter-absent + shape the read path serves — which keeps an untouched "Save" on a legacy row + working. + + **Read half** (the same PR, per the card): `redactDatasourceConfig` / + `getDatasource()` now strip credential query parameters from served URLs for + every driver (new `redactUrlCredentials` / `redactUrlCredentialQueryParams` + exports), `restoreRedactedConfig` mirrors the composite so an untouched + round-trip keeps the stored token, and the credential-migration planner + refuses a query-token row with the per-row remedy instead of planning + `nothing-to-migrate` over cleartext. + + ## FROM → TO + + ```yaml + # before — parsed green; JWT stored cleartext in sys_metadata, and at connect + # it silently overrode the binder-injected secret + driver: turso + config: + url: libsql://app-org.turso.io?authToken=eyJhbGciOiJFZERTQSJ9.x.y + + # after — rejected with the binder prescription; bind the secret instead + driver: turso + config: + url: libsql://app-org.turso.io + external: + credentialsRef: sys_secret:01J9ZK4T2N # or the connection form's secret field + ``` + + There is deliberately no automatic rewrite: moving the value requires + encrypting it into `sys_secret` through a running secret binder, which a + source-file transform cannot do — stripping the parameter alone would silently + drop a live credential. + + + +- d575779: feat(spec): refuse malformed field `scale`/`precision` declarations at authoring time (#8321) + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + `Field.scale` ("Decimal places") and `Field.precision` ("Total digits") are + digit counts, but both parsed as bare `z.number()` — admitting `scale: 2.5` + and `scale: -1`, neither of which has a defined meaning as a count. That + looseness became load-bearing when #7501 made `scale` enforced at write time: + the runtime branch deliberately guards on `Number.isInteger(def.scale) && +def.scale >= 0` (inventing floor/round semantics in a consumer would be PD #12 + guessing), so a typo'd declaration silently got **no enforcement at all** — + the declared-but-inert shape that hides AI-authored metadata errors. + + **What is refused:** a non-integer or negative `scale` or `precision`, at + parse time with the issue path and substance (`invalid_type` "expected int" / + `too_small` ">=0") — the house `z.number().int().min(0)` shape (ADR-0078 + declared=enforced). + + **What stays accepted:** every well-formed declaration byte-identically + (`0`, `2`, any non-negative integer, or no declaration). + `CurrencyConfigSchema.precision` (under `currencyConfig`) is a **different + surface** with its own bounds and `scale → precision` alias table — unchanged. + + **Stored metadata is not hard-broken:** a `sys_metadata` row already at rest + with a malformed value keeps loading — the ADR-0087 D2 conversion + `field-malformed-scale-precision-removed` (retired from the load path, + replayed by the stored-row rehydration seam and `os migrate meta`) drops the + meaningless key, which is behaviour-preserving because a malformed declaration + enforced nothing. The semantic entry + `field-scale-precision-integer-refused` (protocol major 18) tells authors to + re-declare the digit count they meant. + + + +- 4bfe1a5: feat(spec): refuse `${…}` placeholder syntax in memory `persistence.path` / `persistence.key` at publish (#8495) + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + The #8336 defect one surface over: a `${…}` placeholder written in the memory + driver's persistence config (e.g. `persistence: { type: 'file', path: +'${DATA_DIR}/mem.json' }`) is resolved by **nothing** — the driver would create + and write a literal `./${DATA_DIR}/…` path, or write under the literal + placeholder-bearing localStorage key, with no error naming the unresolved + placeholder. #8336's ruling (refuse loudly at authoring time — the value was + authored under a false belief) applies to these two keys with its reason + intact: they are config-material like the connection keys, not record data. + + **What is refused:** a complete `${…}` span in memory `persistence.path` (file + persistence and the `auto` override) or `persistence.key` (localStorage and the + `auto` override) — the same shared judgment (`placeholderFree`) the + connection-material keys use, so the policy cannot drift per key. + + **What stays accepted:** every literal path/key byte-identically, including + placeholder-looking near-misses (`$VAR`, `{name}`, an unclosed `${`) — and the + memory driver's `initialData` stays deliberately **unjudged**: it carries + arbitrary record values, where a literal `${…}` may be legitimate data (the + mother ruling's deliberate memory-driver exclusion, which reached exactly as + far as its reason did). + + ## FROM → TO + + ```ts + // before — parsed green; the driver created a literal `./${DATA_DIR}/…` path + defineDatasource({ + name: "scratch", + driver: "memory", + config: { persistence: { type: "file", path: "${DATA_DIR}/scratch.json" } }, + }); + + // after — write the literal path (or leave it unset: the shared datasource + // factory scopes the default destination per datasource) + defineDatasource({ + name: "scratch", + driver: "memory", + config: { persistence: { type: "file", path: "./data/scratch.json" } }, + }); + ``` + + There is deliberately **no automatic rewrite**: the placeholder names a value + that exists only in the author's intended deployment environment, which a + source-file transform cannot know. `os migrate meta` surfaces the change as a + structured TODO (semantic entry `memory-persistence-placeholder-refused`, + protocol major 18 — this refusal is not part of the v17.0.0 cut). + + + +- b69d0f5: fix(metadata): `PUT /meta/:type` refuses a type name the platform does not have, instead of minting a namespace for it (#8421) + + + + **BREAKING** accept-set narrowing on a published HTTP surface, landing after the + v17.0.0 cut (the lockstep launch-window convention ships it as `minor`). A write + that answered `200 {"success":true}` now answers `400 INVALID_REQUEST`: + + ``` + PUT /api/v1/meta/fieldz/showcase_task.title + before → 200, sys_metadata row persisted with type='fieldz' + after → 400 INVALID_REQUEST, nothing persisted + ``` + + `fieldz` — or any typo — was neither a declared metadata type nor a known plural + spelling of one, so the boundary classified it as PLUGIN-registered, which every + authorization gate is permissive toward by construction. The row was persisted + under a type nothing reads and nothing serves, and the caller was told it had + succeeded. That silence is the real cost: a metadata-type typo, from a human or + from generated code, produced `success: true` and no indication the type is not + real. + + **Why this is only now safe to refuse.** #7894 closed the sibling case (a plural + spelling of a type the platform DECLARES) and left this one open on purpose: a + static predicate cannot tell `fieldz` from a plugin kind, and the live-registry + alternative was measured to be worse than the defect — the live type set is + ITEM-POPULATED, so it omits every legitimate kind that has no items yet, which + is the state each kind is in immediately before its first create. What changed + is the platform, not the boundary's information: #8586 retired + `MetadataPluginConfig.additionalTypes` and with it the last channel by which a + plugin could DECLARE a metadata kind, so an unrecognised name can no longer be a + declaration this refusal has not heard about (maintainer ruling 2026-08-14). + + **What still passes, pinned in both directions.** Every declared type in + `DEFAULT_METADATA_TYPE_REGISTRY`, in canonical and REST-plural spelling; every + manifest spelling and the singular each folds to; and the six plugin kinds that + have no static registry entry at all — `theme`, `webhook`, `connector`, + `sharing_rule`, `analytics_cube`, `rag_pipeline`. `PUT /meta/theme/dark` on a + deployment with zero themes is explicitly covered, because that first create is + exactly what a live-registry check would have broken. + + **The refusal is scoped to the door that mints.** Reads still ANSWER: a running + kernel legitimately holds live type keys the static contract does not — `data`, + `kind` and `package` all enter the registry during an ordinary `registerApp`, + and `GET /api/v1/meta/types` lists that live set — so refusing unrecognised + names on the read path would answer 400 for types the same service advertises. + `DELETE` is untouched for the mirror-image reason: rows minted under an + unrecognised type before this change are real, nothing rewrites them on upgrade, + and refusing their deletion would turn the accumulation this fixes into an + accumulation nobody can clear. + + **…but one published ADVERTISEMENT narrows with it, and that is a second + behaviour change worth reading on its own.** `GET /api/v1/meta/types` keeps + listing every live type, and every entry keeps every field — what changes is the + VALUE of one boolean: + + ``` + GET /api/v1/meta/types → entries[] where type ∈ {policy, data, package, kind} + before → allowRuntimeCreate: true + after → allowRuntimeCreate: false + ``` + + The listing synthesised `allowRuntimeCreate: true` for every live type with no + static registry entry, on the same expired premise as the write door: a name the + registry does not carry might be a kind some plugin declared. It now derives that + flag from the SAME predicate the mint door enforces, so the two endpoints agree + by construction instead of via two rules maintained apart. Nothing ever honoured + a runtime create on those four — they are internal bookkeeping (seed datasets, + package rows, kind descriptors) — so the advertisement was a promise the platform + did not keep, which is the same defect this card is about, relocated to the read + door. Direct precedent: `api` declared `allowRuntimeCreate: true`, the runtime + never honoured it, and the 2026-08-07 ruling removed the declaration rather than + converging the read path onto it. + + ⛔ The six plugin kinds with no registry entry — `theme`, `webhook`, `connector`, + `sharing_rule`, `analytics_cube`, `rag_pipeline` — are **not** affected: they are + in the static spelling contract, stay advertised `allowRuntimeCreate: true`, and + stay mintable. A UI reading this field (Setup → Metadata, the Studio designers) + therefore loses create affordances on exactly the four types whose creates were + already refused, and keeps them everywhere else. + + **The premise behind both halves is a CURRENT posture, not a closed door.** + Maintainer ruling, 2026-08-15, verbatim and untranslated: + 暂时不考虑让插件申明新的元数据类型 — plugins do not declare new metadata types + _for now_. That word is recorded deliberately: plugin-declared kinds were + considered and deferred, not ruled out. If they are ever wanted, the two sites + that encode the deferral name it and its date in place — + `getMetaTypes()`'s synthesis and `isRuntimeCreateAllowed` in + `@objectstack/metadata-protocol` — so the decision is findable rather than + re-derived from the code's silence. + + **Two shapes reaching the mint door are exempt, and each is a fact about the + request rather than a claim the caller makes.** + + 1. _The COMPOUND arity carries an OBJECT name in the `:type` segment._ + `PUT /api/v1/meta/lead/views/all_leads` is `type='lead'`, + `name='views/all_leads'` — one operation reaching one save, the shape both + the runtime dispatcher and the REST route document verbatim. `lead` is an + object, i.e. runtime data no static contract can enumerate, so a type verdict + applied there would refuse every object name that is not coincidentally a + metadata type. The ruling is about metadata TYPE names like `fieldz`. + ⚠️ Residue, stated rather than hidden: `PUT /meta/fieldz/a/b` is therefore + still accepted, because at that arity `fieldz` is a claim about an object and + the only way to check it is the live-registry lookup this card ruled out. + 2. _A namespace that already exists is not being minted._ `duplicatePackage` + re-saves every row of a package under a new name, taking each type from the + stored row — measured: a package holding one pre-existing residue row + answered `{success: false, copiedCount: 0, failedCount: 1}`, i.e. could not + be duplicated at all. That contradicts the `DELETE` reasoning above, so the + store (never the request) exempts a type that already has rows. The probe + runs only once the refusal has already fired, and a store that cannot answer + refuses — a fresh deployment has no residue to protect. + `migrate meta --stored` was read as a third victim and measured NOT to be + one: an unrecognised type has no manifest collection, hence no ADR-0087 + chain, hence no notice, so such a row is reported `canonical` and the mint + door is never reached. + + **What breaks.** A caller creating metadata at runtime, at the simple arity, + under a type name that is in neither half of the static spelling contract and + has no rows already. That set is **not** empty in this repo — measured on + `objectql`, `runtime` and `rest`, three in-tree fixtures minted `trigger` (a kind + ADR-0088 retired outright), `policy`, and a synthetic `my_plugin_kind`. All three + are corrected here rather than exempted, and each for its own reason: the + `trigger` specimens were debt independent of any ruling (a retired kind cannot + demonstrate a live tier, and they were green only through the hole this card + closes), `policy` becomes a refusal case of its own, and #7894's control keeps + its `metaUrlSpellingRefusal` claim while its boundary expectation follows the + narrowing. An out-of-tree plugin that made its kind live by registering an item + of it, and then accepted runtime writes to that kind through `/meta`, needs its + spelling in the contract; there is no declared-kind channel to register one + through today — that is the trade #8586's retirement made, and the `暂时` above + is what makes it revisitable. + + `@objectstack/spec` gains one export, `unrecognisedMetaTypeRefusal`, alongside + the #7894 verdict it deliberately does not merge with: one says _you spelled a + declared type wrongly_ and can name the replacement, the other says _there is no + such type_ and never guesses. The residue pin #7894 left behind + (`metadata-url-spelling.test.ts`, the case that asserted `fieldz` was refused by + nobody) is **flipped, not deleted**. ⚠️ #7894's positive control keeps its own + claim intact — `metaUrlSpellingRefusal` still cannot refuse a kind that is a + misspelling of nothing, which is what makes that control true by construction — + but the BOUNDARY it drives now refuses six of the twelve names it exercises, + and that case says so in place rather than leaving it to inference. + +- 4d47afe: feat(spec): retire the inert `additionalTypes` key from `MetadataPluginConfig` (#8586, ADR-0049) + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + `MetadataPluginConfig.additionalTypes` was declared, authorable, and documented + on four docs pages as THE way a plugin registers a custom metadata type — and + read by **nothing**. The only production writer of the manager's type registry + is `setTypeRegistry(DEFAULT_METADATA_TYPE_REGISTRY)`, called exactly once, and + it replaces the array outright: measured on the real `MetadataManager`, + declared count == live count (27 == 27). An author who followed the published + instructions wrote the key, got no error, and nothing happened — the #4212 + `onInstall` silence trap one level down (maintainer-ruled REMOVE, 2026-08-14). + + **What is refused:** an authored `additionalTypes` on `MetadataPluginConfig` + (inline or via the manifest's `config` embed). The key is a `retiredKey()` + tombstone — the schema is not `.strict()`, so a plain deletion would have + silently stripped it — refused at `tsc` (typed `never`) and at the parse + (`invalid_type` at path `additionalTypes`, message carrying the prescription). + + **What stays accepted:** every `MetadataPluginConfig` without the key, + byte-identically. Runtime behaviour is unchanged: nothing ever read the key, + so removing it removes no behaviour. + + The retirement kit: + + - tombstone at the schema (`packages/spec/src/kernel/metadata-plugin.zod.ts`) + - ADR-0087 registration: retired-key entry + `kernel/MetadataPluginConfig:additionalTypes` + D3 semantic entry + `metadata-plugin-additional-types-retired`, both under protocol 18 (no D2 + conversion — a plugin config is not a stack collection member, the + `kernel/Manifest:loading` precedent) + - pin tests (`additional-types-retirement.test.ts`) + - docs corrected: `content/docs/plugins/adding-a-metadata-type.mdx` (four + sites) now describes how a kind actually enters the live set — as a side + effect of registering an item of that kind; the generated reference page + follows the schema + - the two source comments that asserted the phantom growth path + (`metadata-manager.ts`, `metadata-protocol/src/protocol.ts`) and the + `registerMetadataTypeSchema` doc note corrected + + ## FROM → TO + + ```ts + // before — parsed green; the entries were merged into nothing + const config: MetadataPluginConfig = { + storage: {}, + additionalTypes: [ + { + type: "chart", + label: "Chart", + filePatterns: ["**/*.chart.ts"], + domain: "ui", + }, + ], + }; + + // after — delete the key; register items of the kind instead, and bind its schema + const config: MetadataPluginConfig = { storage: {} }; + // in the plugin: registerMetadataTypeSchema('chart', ChartSchema) from init(ctx); + // the kind enters the live set when an item of it is registered. + ``` + + + +- 30d3752: fix(spec): `record:chatter` / `record:discussion` `position` speaks the renderer's vocabulary, and the row's schema defaults are dropped (#8762) + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + `RecordChatterProps.position` declared `sidebar | inline | drawer` — a + vocabulary NO renderer read point ever compared. Measured at objectui pin + `665661ab0932`, the renderer chain is self-consistent in three places and + speaks `bottom | right | left`: `RecordChatterPanel` docks on `right`/`left` + and renders in flow on `bottom`, the designer registration publishes + `enum: ['bottom', 'right', 'left']`, and the renderer merge falls back to + `bottom`. So the schema's own default (`sidebar`, materialized onto every + parsed node that said nothing) was a silent no-op falling through to the + in-flow render, while the value that actually docks the panel (`right`) was + refused at publish. The maintainer ruling (2026-08-15) converged the row on + the renderer's vocabulary — one vocabulary, no mapping layer. + + **FROM → TO:** `position: 'sidebar'` → `'right'` (the docked side panel the + spelling meant); `'inline'` → `'bottom'` (the in-flow branch it already + landed in); `'drawer'` → `'right'` (no overlay drawer was ever implemented — + the docked panel is the nearest surviving intent). One-line fix: re-spell + `position` to `bottom`/`right`/`left`; `os migrate meta` rewrites sources + mechanically via the ADR-0087 conversion + `record-chatter-position-vocabulary`, and stored `sys_metadata` rows replay + clean through the rehydration seam. A live author gets a per-value "was + removed" prescription from the enum's own error map. + + **All three schema defaults are dropped** (`position: 'sidebar'`, + `collapsible: true`, `defaultCollapsed: false`) per the `maxVisible` + principle — renderer fallbacks stay the renderer's facts. The old + `collapsible` default _inverted_ the renderer merge's own `false` fallback, + turning "the author said nothing" into "the author asked for collapsible". A + page that wants the collapse affordance authors `collapsible: true` + explicitly; unset keys now parse to nothing and the renderer decides. + + The row stays ONE shared schema object for `record:chatter` AND + `record:discussion` (the #8744 pairing) — both names accept and refuse + identically. The objectui renderer is unchanged. + + + +- 7901b2d: feat(spec): stamp-only `tenancy.organizationField` — audit rows can follow the record's organization on objects that must stay unwalled (#8778, closes the #8707 remainder) + + The platform had one answer to "what is this object WALLED by" + (`tenancy.tenantField`) and no answer to "which column says who this row is + ABOUT". For ordinary objects the two coincide; for credential tables they + deliberately do not — `sys_api_key` records the organization a key + authenticates into under `active_organization_id` precisely so the credential + table is not org-walled (#8287). #8777's schema-resolved audit stamping could + therefore reach every shipped object except the one that motivated it, and + revocation rows on `sys_api_key` kept stamping the revoker's organization. + + `TenancyConfigSchema` now accepts an optional `organizationField` — a + READ-NEUTRAL, STAMP-ONLY declaration (maintainer-ruled option A on #8778): + + - The audit writer's `resolveRecordOrganizationField` consults it first, ahead + of the ADR-0066 `enabled: false` opt-out — an author declaring it on an + unwalled object is stating exactly that the audit trail should follow the + record's own organization even though no wall does. It is honoured only when + the object really has the field (the #5315 guard `tenantField` carries). + - No read path reads it: `applyTenantScope`, `injectTenantOnInsert`, + `computeTenantLayer0Filter` and `resolveInjectedSystemColumns` are all + measured blind to it, and that read-neutrality is pinned by tests beside + each. Declaring it never walls an object and never hides rows. + - ⛔ Scope pin from the ruling: this is ONE stamp-only key, not the opening + move of a general field-roles mechanism. A consumer other than audit + stamping needs its own ruling before reading it. + + `sys_api_key` now declares + `tenancy: { enabled: false, organizationField: 'active_organization_id' }`, + so revoking another user's key from a different active organization lands the + audit row behind the wall of the KEY's organization — where the tenant admin + who can act on it reads it. The `enabled: false` is measured + behavior-identical to the previous absent block for this object on every read + path (injection bails on `managedBy: 'better-auth'` first; the SQL driver's + tenant field resolves null either way; Layer 0 is exempt either way; the + memory/mongo boot guards count only an explicit `enabled: true`). + +- 79394d7: feat(spec): declare `record:alert` / `record:quick_actions` / `record:history` / `record:discussion` in `ComponentPropsMap` — undeclared keys on the four are refused (#8744) + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + These were the four `record:*` types #8691's rail fix left in the rail's own + pre-fix position: a registered objectui renderer, a `PageComponentType` entry + and a console palette slot (bar `record:discussion`, which was authorable only + through the type union's open string arm), and no `ComponentPropsMap` row — so + the #5068 component-props gate's dispatch skipped them as unregistered and + every authored key rode through. A typo'd `severty` on the platform's own + banner surface parsed, typechecked, validated, built and shipped as a silent + no-op while sibling components in the same file drew loud diagnostics. + + The new rows are strict and declare exactly what the renderers read, measured + from their read points at the objectui pin — not from the registrations' + declared-input lists, which are wrong in both directions here: + + - `record:alert` — `severity?`, `title?` / `body?` (string **or inline locale + map** — this renderer resolves both through `pickLocalized`, the opposite + verdict from the rail's literal-string `title`, measured the same way), + `visible?` (boolean | CEL string | `{ dialect, source }` envelope), `icon?` + (read here, unlike the rail's), `action?` `{ actionName, label?, variant? }`, + `dismissible?`, `dismissKey?`. `visibleWhen` / `visibility` rename to + `visible` as aliases — this is the one record component whose props-level + predicate is real, so the wrong-layer visibility guidance does not apply. + - `record:quick_actions` — `actionNames?`, `requiredPermissions?`, `location?` + (the spec's own `ActionLocationSchema`, retirement prescriptions included), + `align?`, `inline?`, `variant?` / `size?` (the Button primitive's delivered + vocabulary). `actions` is refused with a prescription (as a name list it is + `actionNames`; as inline defs it is the host synthesizer's runtime channel). + `aria` is refused rather than declared: the renderer reads `aria.label`, a + spelling the shared `AriaPropsSchema` refuses, and reads nothing else of the + bag — declaring either spelling would be declared-but-unenforced surface + (the renderer-side fix is objectui's, filed). + - `record:history` — `limit?`, `emptyText?` / `unknownUserText?` (literal + strings — the timeline renders them raw; a locale map would paint + `[object Object]`). `entries` / `loading` are refused as the host's data + channel: omit them and the block self-fetches the record's `sys_activity` + history. + - `record:discussion` — `record:chatter`'s own row, deliberately the same + schema object (one renderer registered under two names must keep one accept + face), plus a `PageComponentType` entry so the name is no longer a + string-arm stowaway. + + **What stays accepted:** every declared key byte-identically — the platform + `sys_user` page's banner and self-service action bars and the showcase task + page pass with zero findings. No row carries a schema default (renderer + fallbacks stay the renderer's facts). The one parse-time normalization is + `ExpressionInputSchema`'s own: a bare-string `visible` becomes the canonical + `{ dialect: 'cel', source }` envelope. + + ## FROM → TO + + ```ts + // before — parsed green everywhere; the banner styled itself `info` anyway + { + type: 'record:alert', + properties: { + severty: 'warning', // silent no-op typo + title: 'Awaiting review', + }, + } + + // after — the typo is a publish-time refusal naming the rename; write the + // measured shape + { + type: 'record:alert', + properties: { + severity: 'warning', + title: 'Awaiting review', + visible: "record.status == 'in_review'", + }, + } + ``` + + There is deliberately no automatic rewrite: an undeclared key is either a + spelling of a declared one (the rejection names the rename) or names a + capability the renderer does not deliver, and blessing either would be + declared-but-unenforced surface (ADR-0078). `os migrate meta` surfaces the + change as a structured TODO (semantic entry + `ui-record-blocks-unknown-keys-refused`, protocol major 18 — this refusal is + not part of the v17.0.0 cut). + + + +- 730fd9a: feat(spec): declare `record:reference_rail` in `ComponentPropsMap` — undeclared rail keys are refused (#8691) + + **BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep + launch-window convention ships it as `minor`; the migration prescription is + registered under protocol major 18, where `os migrate meta` users will look). + + `record:reference_rail` had a registered renderer, a `PageComponentType` entry + and a console palette slot, but no row in `ComponentPropsMap` — so the #5068 + component-props gate's dispatch skipped it as unregistered and every authored + key rode through. Measured on 17.0.0 GA end to end: a planted entry `filter` + passed tsc, `objectstack validate` and `objectstack build`, shipped verbatim in + `dist/objectstack.json`, and the rendered rail kept counting and listing + unfiltered rows — while the very same build loudly reported + `record:related_list` keys in the same file. + + The new row is strict and declares exactly the shape the renderer reads + (measured from its read points at the objectui pin, not from its TS + interface): `entries[]` of `{ objectName, relationshipField, title?, limit?, +displayField? }` plus a component-level `hideEmpty`. + + **What is refused:** any key the shape does not declare, with a prescriptive + message — the planted `filter` (the rail issues one fixed query per entry; + `record:related_list` is where `filter` is real), the interface's `icon` (read + by no render path — declaring it would be declared-but-unenforced surface), + entry-level `hideEmpty` (a component-level key), and the neighbouring-surface + spellings `items`/`related` → `entries`, `object` → `objectName`, `label` → + `title`. `title` is a literal `z.string()` — the renderer paints it as a raw + React child, so an inline locale map is refused rather than shipped as + `[object Object]`. + + **What stays accepted:** every declared key byte-identically. `limit` and + `hideEmpty` carry no schema default (the renderer's `3` / `true` fallbacks stay + the renderer's), so a minimal entry round-trips unchanged. + + ## FROM → TO + + ```ts + // before — parsed green everywhere; the badge kept counting everything + { + type: 'record:reference_rail', + properties: { + entries: [{ + objectName: 'task', relationshipField: 'project_id', + filter: [{ field: 'status', op: 'neq', value: 'completed' }], // silent no-op + icon: 'CheckSquare', // read by nothing + }], + }, + } + + // after — both keys are publish-time refusals with prescriptions; write only + // what the renderer reads + { + type: 'record:reference_rail', + properties: { + entries: [{ objectName: 'task', relationshipField: 'project_id', limit: 3 }], + hideEmpty: false, + }, + } + ``` + + There is deliberately no automatic rewrite: an undeclared key is either a + spelling of a declared one (the rejection names the rename) or names a + capability the rail does not deliver — a per-entry `filter` and an inline + `title` locale map are open capability questions for the console seat, and + blessing either spelling now would be declared-but-unenforced surface + (ADR-0078). `os migrate meta` surfaces the change as a structured TODO + (semantic entry `ui-reference-rail-unknown-keys-refused`, protocol major 18 — + this refusal is not part of the v17.0.0 cut). + + + +### Patch Changes + +- 856527c: + + docs(spec): register the FILTER-axis formula refusal in the ADR-0087 ledger (#8370) + + The refusal itself shipped in 17.0.0 (#8296 / PR #8369): a `where` naming a + `formula` field is `400 INVALID_FIELD` at both doors — the REST ingress + (`assertFilterFieldsExist`) and the engine's own filter seam + (`assertFilterIsMaterializable`), which saved reports, flows and dashboard + widgets reach directly. It shipped with **no** ADR-0087 semantic entry, so + `objectstack migrate meta`, `spec-changes.json` and the generated upgrade guide + said nothing about it. + + Its SORT-axis twin (#7095, `engine-find-formula-order-by-refused`) carries one, + for the identical shape. This adds the FILTER-axis sibling — + `engine-find-formula-filter-refused` under protocol 17 — and regenerates the two + projections of the registry. + + For a code-path API there is no `sys_metadata` row for the D2 chain to rewrite + and no mechanical rewrite in either direction (the platform cannot invent the + stored column, and it must not filter post-hoc — `driver.find` has already + applied `limit` / `offset`, so a post-hoc predicate would filter an arbitrary + PAGE), which makes the ledger entry the only notification channel this class + has. The remedy it prescribes is the one the sort and search axes already + prescribe, in the same words: denormalise the value onto a stored field written + when the source changes, and filter that. `summary` and `autonumber` fields need + no action — both get real maintained columns and filter correctly. + + No behaviour changes: registration and regenerated artifacts only. + +- ff08691: fix(engine-core): a system-context insert on a tenant-scoped object resolves the install's organization the way a session write does, or is refused — the runtime producer of the autonumber fork #8686's backfill cannot reach (#8844) + + + + #8686 fixed **one** producer of untenanted rows — the seed loader — and shipped + a one-shot backfill for what it had already written. This card is the **other + producer, which is still running**: an ordinary application write made under a + system execution context (a hook, a scheduled job, a custom endpoint, a + `runAs: system` flow). A backfill cannot reach it, because it mints a fresh + duplicate on every tick — which makes #8686's repair **self-undoing on any + install with server-side automation**, i.e. every business app. + + **Measured on 17.0.0 GA**, a single-tenant EHR/MES install with ~44 autonumbered + objects: two records, same object, same install, the **same** value on a field + the app declared `unique`, with no error and no warning. The `notification` case + shows both producers side by side — `NT-00002 .. NT-00011` each existing twice, + copy A written by the "maintenance overdue" cron job, copy B by a user action. + + **Mechanism.** A session write carries the caller's active organization, the SQL + driver stamps it onto the row (`injectTenantOnInsert`), and the autonumber + counter reads it back off the row (`fillAutoNumberFields`, resolving + `row[tenantField] ?? options.tenantId ?? null`). A system-context write carries + none, so the column lands `NULL` and the counter files the row under the + `__global__` pseudo-tenant. One object then runs two counters that cannot see + each other, each correct within its own scope, and the partitioned unique index + — `(COALESCE(organization_id, '__global__'), )`, ADR-0120 D3 — cannot see + across the two partitions either. + + ⛔ **Not a counter bug**, and not fixed by making the allocator smarter: both + counters are already correct within their own scopes (the reasoning #8686 + recorded, unchanged). The defect is upstream of the counter. + + **The fix, per the 2026-08-15 maintainer ruling (Option 1)** — a system-context + write resolves the install's organization the way a session write does, at the + engine's stamp resolution, so every driver is covered at the source (which + matters here because `fillAutoNumberFields` is duplicated in `driver-sql` and + `driver-turso`; neither driver changed): + + - **Single-tenant, exactly one organization ⇒ derive and stamp.** The + `__global__` fork stops being minted by hooks, cron and system endpoints. + - **Multi-organization ⇒ carry an explicit organization or be REFUSED LOUDLY**, + never silently defaulted. A walled posture (`group` / `isolated`), or a + `single` posture whose data holds several organizations, has no derivable + answer — the refusal is `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (500, + registered in the ADR-0112 ledger), thrown before anything reaches the driver, + and its message names the condition, what would otherwise have been written, + and both remedies. + - **Already-minted duplicates are reported, never rewritten** — the #8686 + posture, ruled again here. Nothing in this change renumbers anything. + + **Three populations are outside the rule by construction, not by exemption**, so + that the refusal cannot break unattended automation that was never at risk: + objects with no organization column, objects declaring `tenancy: { enabled: +false }` (ADR-0066 — the _declared_ way to hold org-less rows, rather than a + per-write bypass flag) and federated objects (ADR-0015); the platform namespaces + `sys_` / `cloud_` / `ai_`, whose rows are deliberately global (#8672's reasoning, + which this ruling confirms holds for platform objects and does **not** generalize + to application objects); and any write that already carries an organization — on + the execution context, on the record, or stamped by a `beforeInsert` hook. + + **First boot is untouched:** before any organization exists there is nothing to + derive and no second partition to fork away from, so those rows still land + org-less for #8686's `sys_organization`-insert handoff to adopt. + + Scoped to **insert**, deliberately: the ruling's yardstick is "the way a session + write does", and stamping the organization is an insert-side mechanism — an + update neither stamps it nor can fork a counter. + +- 44bc51d: refactor(spec): the union-branch selection policy has ONE implementation, and a parity test that keeps it that way (#8318) + + `shared/error-map.zod.ts` (the prose renderer, #4971/#5389) and + `api/zod-issues-to-fields.ts` (the ADR-0114 D3 wire mapper, #8124) carried the + SAME union-branch selection policy as two separate implementations — + kind-mismatch drop, fewest-issues ranking, `unrecognized_keys` tie-break, + declaration-order determinism, depth limit 3, branch cap 3, and the + `invalid_key` / `invalid_element` container codes. While the mapper still lived + in `@objectstack/rest` the duplication was forced; #8124 moved it into this + package, so the two sat one directory apart with their module headers — and + nothing mechanical — asking whoever edits one to edit the other. + + The policy now lives in one package-internal module, + `src/shared/union-branch-policy.ts`, which both walks import. It is deliberately + NOT a public export: it is absent from every barrel, and `api-surface/` and + `export-origins/` do not move. + + The two WALKS stay separate implementations, as they should — one renders + indented `✗ path: message` prose for a terminal, the other produces + `{field, code, message}` entries for a JSON envelope, and only the renderer + emits the trailing "… and N more branches rejected this value" line. That + asymmetry is now explicit rather than implicit: `selectUnionBranches` returns + `{selected, omitted}`, the renderer prints `omitted`, and the mapper + destructures `selected` alone at a commented line, because a `fields[]` entry + must name a real field and carry a catalog code and an omission count has + neither. + + `src/shared/union-branch-policy.parity.test.ts` is the enforcement the module + headers lacked: one `safeParse` per fixture feeds BOTH walks, and their outputs + are compared pair for pair after a normalisation that removes the indent, the + `✗` glyph and the `(root)` spelling — nothing else. The corpus covers every rule + of the policy (kind-mismatch drop, all-kind-mismatch, fewest-issues ranking, the + `unrecognized_keys` tie-break, declaration-order determinism, the depth limit, + the branch cap, and container descent for both `invalid_key` and + `invalid_element`), and the one deliberate asymmetry is asserted rather than + normalised away. + + Behaviour is unchanged for every issue zod produces: the ranking, both limits + and the container-code set are byte-identical to what each walk applied before. + The single deliberate widening is that the shared policy reads a missing or + non-array `path` as the root — the wire mapper's already-shipped normalisation, + now applied to the renderer too, which previously threw on such an issue object. + No value satisfying the renderer's own `ZodIssueMinimal` type is affected. + ## 17.0.0 ### Major Changes @@ -483,7 +1404,7 @@ vocabulary − this`), which is what stops the next aggregate added to the spec is untouched; it is simply no longer reachable through a spec-valid request. On the dataset path nothing changes: `compileDataset` refused both by name already. - + - 3f7f14e: refactor(spec,objectql)!: retire `AggregationNode.distinct` — one face honoured it, five ignored it, and the same query answered two plausible numbers (#6815, ADR-0049) @@ -3457,7 +4378,7 @@ stack?, code? }`). Neither side had any consumer outside spec; the produced a correct answer on any backend, so no behaviour that worked stops working: rewrite it as a scalar comparison, per the message above. - + **ADR-0087 conversion: not required**, and the reason is not blast radius alone. @@ -4002,7 +4923,7 @@ security } })` / `StackServerConfigSchema` (#5006) parses exactly as it did in 1 If host-implementer conformance becomes a real requirement it returns through the ENFORCE route: an adapter contract with a checker behind it, vocabulary second. - + - c3f4916: fix(spec)!: `ImportRequest.runAutomations` declares the default the import route actually applies (#6704, ADR-0049) @@ -6700,7 +7621,7 @@ nonsenseKey: 1 }).success === true`). That is tracked in the #4001 campaign map Related: #4674, #4720, #4363, #4371, #4001, ADR-0049. - + - efedd28: refactor(spec)!: retire `IStorageService.list(prefix)` — one contract method, two adapter dialects, both silently incomplete, and no caller (#5540, ADR-0049 enforce-or-remove) @@ -7736,7 +8657,7 @@ nonsenseKey: 1 }).success === true`). That is tracked in the #4001 campaign map belongs on the START node's `config` (`{ objectName, triggerType, condition, schedule }`), not on the flow. - + - 7055c22: Close the responsive/SDUI-styling shapes against unknown keys (#4001 batch 13, ADR-0078) @@ -10106,8 +11027,8 @@ mimeType?, alt?, duration? }` with `url` required. It replaces D1's loose being a hand-listed union and a bare `string` respectively and reference the catalog, so the three cannot drift apart. - Lowercase is deliberate, not an oversight against ADR-0112's SCREAMING_SNAKE: a - top-level code names the condition the _request_ hit, while a field-level code + Lowercase is deliberate, not an oversight against ADR-0112's SCREAMING*SNAKE: a + top-level code names the condition the \_request* hit, while a field-level code names the _constraint_ the value violated — and constraints are declared in the metadata's own snake_case, so `max_length` the code and `max_length: 50` the property are the same word on purpose. @@ -21771,7 +22692,7 @@ true` there — that is the flag the HITL approval queue reads degrades to "not shown" with no error; removing that badge is a follow-up in that repo. - + - 1363084: feat(spec,objectql): `engine.transaction` 契约收紧第一批 —— `opts.require` fail-closed 与 `owned` 信号 (#5696) @@ -22291,7 +23212,7 @@ NULL`. #7705 proved that narrowing orphans every org-scoped row — the same recover, which is the same disposition `rest-requireauth-default-flip` took for its own default flip. - + - 1e6ab15: feat(spec): `unique` scope vocabulary gains `'organization'` — scope is said, not positional (#4986, ADR-0120 D1/D6) @@ -34093,7 +35014,7 @@ vocabulary − this`), which is what stops the next aggregate added to the spec is untouched; it is simply no longer reachable through a spec-valid request. On the dataset path nothing changes: `compileDataset` refused both by name already. - + - 3f7f14e: refactor(spec,objectql)!: retire `AggregationNode.distinct` — one face honoured it, five ignored it, and the same query answered two plausible numbers (#6815, ADR-0049) @@ -34751,7 +35672,7 @@ security } })` / `StackServerConfigSchema` (#5006) parses exactly as it did in 1 If host-implementer conformance becomes a real requirement it returns through the ENFORCE route: an adapter contract with a checker behind it, vocabulary second. - + - c3f4916: fix(spec)!: `ImportRequest.runAutomations` declares the default the import route actually applies (#6704, ADR-0049) diff --git a/packages/spec/package.json b/packages/spec/package.json index de9b333e0c..5e889978e8 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/spec", - "version": "17.0.0", + "version": "17.1.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 128a55faed..645b0cfee7 100644 --- a/packages/triggers/trigger-api/CHANGELOG.md +++ b/packages/triggers/trigger-api/CHANGELOG.md @@ -1,5 +1,31 @@ # @objectstack/trigger-api +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/triggers/trigger-api/package.json b/packages/triggers/trigger-api/package.json index 1f663940b4..0b261fb5ee 100644 --- a/packages/triggers/trigger-api/package.json +++ b/packages/triggers/trigger-api/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-api", - "version": "17.0.0", + "version": "17.1.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 aa845204dc..6ecf089608 100644 --- a/packages/triggers/trigger-record-change/CHANGELOG.md +++ b/packages/triggers/trigger-record-change/CHANGELOG.md @@ -1,5 +1,31 @@ # @objectstack/plugin-trigger-record-change +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/triggers/trigger-record-change/package.json b/packages/triggers/trigger-record-change/package.json index 76aa1df59a..2e50729349 100644 --- a/packages/triggers/trigger-record-change/package.json +++ b/packages/triggers/trigger-record-change/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-record-change", - "version": "17.0.0", + "version": "17.1.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 02fa8e5935..0a0c309c2c 100644 --- a/packages/triggers/trigger-schedule/CHANGELOG.md +++ b/packages/triggers/trigger-schedule/CHANGELOG.md @@ -1,5 +1,31 @@ # @objectstack/plugin-trigger-schedule +## 17.1.0 + +### Patch Changes + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e43d63a] +- Updated dependencies [f57fb38] +- Updated dependencies [5f5e234] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [f8eb736] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + - @objectstack/core@17.1.0 + ## 17.0.0 ### Patch Changes diff --git a/packages/triggers/trigger-schedule/package.json b/packages/triggers/trigger-schedule/package.json index 52e8dd621a..4e5a11f8ec 100644 --- a/packages/triggers/trigger-schedule/package.json +++ b/packages/triggers/trigger-schedule/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/trigger-schedule", - "version": "17.0.0", + "version": "17.1.0", "license": "Apache-2.0", "description": "Schedule flow trigger for ObjectStack — auto-launches flows on a cron/interval/once schedule via the IJobService (ADR-0018)", "main": "dist/index.js", diff --git a/packages/types/CHANGELOG.md b/packages/types/CHANGELOG.md index a6ae93a20b..a21072718f 100644 --- a/packages/types/CHANGELOG.md +++ b/packages/types/CHANGELOG.md @@ -1,5 +1,89 @@ # @objectstack/types +## 17.1.0 + +### Patch Changes + +- bbbfcfc: fix(types): `isUniqueViolationError` stops claiming the sentences that say a unique constraint is ABSENT (#8590) + + The shared predicate's message limb was a bare `unique constraint`, and a word + pair is not a condition. Every dialect that can say "this row violated a unique + constraint" can also say "there is no unique constraint here", and the same two + words sit adjacent in both — so the predicate answered **true** for errors + meaning the exact opposite of what it detects. `rest-server.ts` maps that + verdict to `409 UNIQUE_VIOLATION`, which tells a client to change a value when + nothing was ever compared, on a status an SDK will not retry. + + **Measured on live servers for this fix, all three supported dialect families** + — SQLite via better-sqlite3, PostgreSQL 16.13 via `pg` 8.22.0, MariaDB 10.11.14 + via `mysql2` 3.23.1, all through knex 3.3.0 — driving each dialect through both + conditions plus the NOT NULL / FOREIGN KEY near misses: + + ``` + sqlite ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint + -> was true, WRONG (the reported defect, #8590) + postgres there is no unique constraint matching given keys for referenced table "t" + -> was true, WRONG (42830 — found by this fix's dialect sweep) + postgres there is no unique or exclusion constraint matching the ON CONFLICT specification + -> false (the pair is not adjacent here) + mysql the condition cannot arise: knex compiles to ON DUPLICATE KEY UPDATE, + which carries no conflict target (confirmed against a live server) + ``` + + **Postgres was not clean either, and that chose the fix.** #8590 was filed + reading the collision as SQLite-only, with Postgres escaping "by luck of word + order". The sweep raised **42830** — a `FOREIGN KEY` referencing a non-unique + column — where Postgres puts `unique constraint` adjacent in its own absence + sentence. The card offered two candidate fixes; only one survives 42830. A + negative lookahead on SQLite's missing-index sentence is a blocklist that can + only enumerate absence sentences somebody already tripped over, and it answers + `true` on 42830. So the limb now requires a **violation phrasing** — + `unique constraint failed` (SQLite) or `violates unique constraint` (Postgres) — + which restores the module's own stated default, _unrecognised is `false`_, to + the message channel. + + **Both spellings the retired limb covered are preserved exactly**, which was the + constraint on the fix: the limb was inherited verbatim from the REST branch + #6250 replaced and covered SQLite's `UNIQUE constraint failed: t.c` _and_ + Postgres' `... violates unique constraint "..."`. The `unique violation`, + `duplicate key` and `duplicate entry` limbs are untouched, as are the `code` and + `errno` channels — MySQL's `Duplicate entry` path never went through the + narrowed limb at all. + + **No user-visible behaviour changes today; this closes a latent inversion.** The + one site compiling a caller-supplied conflict target (`SqlDriver.upsert`) + recognises the unbacked target _first_ in its catch and throws a refusal + declaring `status: 400`, and `mapDataError` reads `declaredHttpStatus` before it + reaches the unique-violation branch — so the 409 was gated off the wire by + ordering, not by the verdict. That ordering was the only thing standing between + this and a wrong status, which is why the verdict is now pinned rather than left + to it. A repo-wide scan of every string literal whose verdict moves found no + consumer relying on the old answer: all of them are prose, a different + predicate's vocabulary (`looksLikeInternalErrorLeak` keeps its own list), or + fixtures asserted through the status-passthrough path. + + `unbacked-conflict-target.test.ts`'s pin — written by #8567 to point at itself + rather than go quietly green — is **inverted, not deleted**, and + `unique-violation-absence-sentences.test.ts` pins the absence sentences per + dialect in both directions, including the code channel, so re-reading `code` + cannot undo the message-side fix from the other side. + +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [f57fb38] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [30d3752] +- Updated dependencies [ff08691] +- Updated dependencies [7901b2d] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [44bc51d] + - @objectstack/spec@17.1.0 + ## 17.0.0 ### Minor Changes diff --git a/packages/types/package.json b/packages/types/package.json index cfbc837ded..b3164bb367 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/types", - "version": "17.0.0", + "version": "17.1.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 73e0c9218b..db633660cb 100644 --- a/packages/verify/CHANGELOG.md +++ b/packages/verify/CHANGELOG.md @@ -1,5 +1,84 @@ # @objectstack/verify +## 17.1.0 + +### Patch Changes + +- Updated dependencies [c9f5950] +- Updated dependencies [720ee95] +- Updated dependencies [f287435] +- Updated dependencies [e7bccaa] +- Updated dependencies [e43d63a] +- Updated dependencies [5047cb8] +- Updated dependencies [2277443] +- Updated dependencies [cf0d902] +- Updated dependencies [498f4e8] +- Updated dependencies [cc5c07b] +- Updated dependencies [f57fb38] +- Updated dependencies [3508678] +- Updated dependencies [04d03c3] +- Updated dependencies [8656d67] +- Updated dependencies [3d61924] +- Updated dependencies [6feac91] +- Updated dependencies [5f5e234] +- Updated dependencies [4ea921c] +- Updated dependencies [3ab2488] +- Updated dependencies [d575779] +- Updated dependencies [856527c] +- Updated dependencies [24173e9] +- Updated dependencies [b705a6c] +- Updated dependencies [f8eb736] +- Updated dependencies [20067c5] +- Updated dependencies [d09d0fd] +- Updated dependencies [e783e16] +- Updated dependencies [4bfe1a5] +- Updated dependencies [b69d0f5] +- Updated dependencies [4d47afe] +- Updated dependencies [4fc4a3c] +- Updated dependencies [72050cc] +- Updated dependencies [e6e1de4] +- Updated dependencies [c73eacd] +- Updated dependencies [693c788] +- Updated dependencies [501ed0e] +- Updated dependencies [f047810] +- Updated dependencies [30d3752] +- Updated dependencies [7fc01db] +- Updated dependencies [19db5fa] +- Updated dependencies [2b9d33a] +- Updated dependencies [b53d38e] +- Updated dependencies [04f8fdb] +- Updated dependencies [6158146] +- Updated dependencies [84cb121] +- Updated dependencies [147eadc] +- Updated dependencies [a675b4d] +- Updated dependencies [b887013] +- Updated dependencies [ff08691] +- Updated dependencies [402c125] +- Updated dependencies [7901b2d] +- Updated dependencies [7c2f386] +- Updated dependencies [b3f9831] +- Updated dependencies [79394d7] +- Updated dependencies [730fd9a] +- Updated dependencies [8a9e7f4] +- Updated dependencies [3d0ded8] +- Updated dependencies [44bc51d] +- Updated dependencies [bbbfcfc] + - @objectstack/platform-objects@17.1.0 + - @objectstack/plugin-auth@17.1.0 + - @objectstack/plugin-security@17.1.0 + - @objectstack/spec@17.1.0 + - @objectstack/rest@17.1.0 + - @objectstack/core@17.1.0 + - @objectstack/runtime@17.1.0 + - @objectstack/service-automation@17.1.0 + - @objectstack/service-datasource@17.1.0 + - @objectstack/plugin-sharing@17.1.0 + - @objectstack/service-analytics@17.1.0 + - @objectstack/service-settings@17.1.0 + - @objectstack/objectql@17.1.0 + - @objectstack/types@17.1.0 + - @objectstack/plugin-hono-server@17.1.0 + ## 17.0.0 ### Major Changes diff --git a/packages/verify/package.json b/packages/verify/package.json index 5790def0b2..f61225c2b7 100644 --- a/packages/verify/package.json +++ b/packages/verify/package.json @@ -1,6 +1,6 @@ { "name": "@objectstack/verify", - "version": "17.0.0", + "version": "17.1.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",